mirror of
https://github.com/duhanbalci/dreport.git
synced 2026-07-02 02:49:16 +00:00
faz 1 & 2
This commit is contained in:
150
frontend/src/components/editor/EditorCanvas.vue
Normal file
150
frontend/src/components/editor/EditorCanvas.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useTemplateStore } from '../../stores/template'
|
||||
import { useEditorStore } from '../../stores/editor'
|
||||
import { useTypstCompiler } from '../../composables/useTypstCompiler'
|
||||
import TypstSvgLayer from './TypstSvgLayer.vue'
|
||||
import InteractionOverlay from './InteractionOverlay.vue'
|
||||
|
||||
const templateStore = useTemplateStore()
|
||||
const editorStore = useEditorStore()
|
||||
const { typstMarkup } = storeToRefs(templateStore)
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const containerWidth = ref(800)
|
||||
|
||||
// Typst compiler
|
||||
const { svg, error, compiling, layout, dispose } = useTypstCompiler(typstMarkup)
|
||||
|
||||
// mm → px dönüşüm katsayısı
|
||||
const scale = computed(() => {
|
||||
return (containerWidth.value / templateStore.template.page.width) * editorStore.zoom
|
||||
})
|
||||
|
||||
// Sayfa boyutu px cinsinden + margin CSS variables
|
||||
const pageStyle = computed(() => {
|
||||
const w = templateStore.template.page.width * scale.value
|
||||
const h = templateStore.template.page.height * scale.value
|
||||
const m = templateStore.template.root.padding
|
||||
return {
|
||||
width: `${w}px`,
|
||||
height: `${h}px`,
|
||||
'--page-margin-top': `${m.top * scale.value}px`,
|
||||
'--page-margin-right': `${m.right * scale.value}px`,
|
||||
'--page-margin-bottom': `${m.bottom * scale.value}px`,
|
||||
'--page-margin-left': `${m.left * scale.value}px`,
|
||||
}
|
||||
})
|
||||
|
||||
// Container boyutunu izle
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
onMounted(() => {
|
||||
if (containerRef.value) {
|
||||
resizeObserver = new ResizeObserver(entries => {
|
||||
const entry = entries[0]
|
||||
if (entry) containerWidth.value = entry.contentRect.width
|
||||
})
|
||||
resizeObserver.observe(containerRef.value)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
dispose()
|
||||
})
|
||||
|
||||
// Zoom
|
||||
function onWheel(e: WheelEvent) {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault()
|
||||
const delta = e.deltaY > 0 ? -0.1 : 0.1
|
||||
editorStore.setZoom(editorStore.zoom + delta)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="editor-canvas" ref="containerRef" @wheel="onWheel">
|
||||
<!-- Hata banner -->
|
||||
<div v-if="error" class="editor-canvas__error">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Derleme göstergesi -->
|
||||
<div v-if="compiling" class="editor-canvas__compiling">
|
||||
Derleniyor...
|
||||
</div>
|
||||
|
||||
<!-- Sayfa -->
|
||||
<div class="editor-canvas__page" :style="pageStyle">
|
||||
<TypstSvgLayer :svg="svg" />
|
||||
<InteractionOverlay :scale="scale" :layout="layout" :page-width-pt="templateStore.template.page.width * 2.8346" />
|
||||
</div>
|
||||
|
||||
<!-- Zoom göstergesi -->
|
||||
<div class="editor-canvas__zoom">
|
||||
%{{ editorStore.zoomPercent }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.editor-canvas {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background: #e5e7eb;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.editor-canvas__page {
|
||||
background: white;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.editor-canvas__error {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 6px;
|
||||
padding: 6px 16px;
|
||||
font-size: 13px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.editor-canvas__compiling {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 16px;
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
border-radius: 6px;
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.editor-canvas__zoom {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
right: 16px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
z-index: 100;
|
||||
}
|
||||
</style>
|
||||
211
frontend/src/components/editor/ElementHandle.vue
Normal file
211
frontend/src/components/editor/ElementHandle.vue
Normal file
@@ -0,0 +1,211 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import type { TemplateElement, ContainerElement } from '../../core/types'
|
||||
import { isContainer } from '../../core/types'
|
||||
import { useEditorStore } from '../../stores/editor'
|
||||
import { useTemplateStore } from '../../stores/template'
|
||||
|
||||
const props = defineProps<{
|
||||
element: TemplateElement
|
||||
scale: number
|
||||
}>()
|
||||
|
||||
const editorStore = useEditorStore()
|
||||
const templateStore = useTemplateStore()
|
||||
|
||||
const isSelected = computed(() => editorStore.selectedElementId === props.element.id)
|
||||
const isContainerEl = computed(() => isContainer(props.element))
|
||||
const isAbsolute = computed(() => props.element.position.type === 'absolute')
|
||||
|
||||
// --- CSS style: layout'u Typst ile eşleştir ---
|
||||
const layoutStyle = computed(() => {
|
||||
const el = props.element
|
||||
const s = props.scale
|
||||
const style: Record<string, string> = {}
|
||||
|
||||
// Absolute positioning
|
||||
if (el.position.type === 'absolute') {
|
||||
style.position = 'absolute'
|
||||
style.left = `${el.position.x * s}px`
|
||||
style.top = `${el.position.y * s}px`
|
||||
}
|
||||
|
||||
// Boyut
|
||||
const w = el.size.width
|
||||
const h = el.size.height
|
||||
if (w.type === 'fixed') style.width = `${w.value * s}px`
|
||||
else if (w.type === 'fr') style.flex = `${w.value} 1 0%`
|
||||
// auto → doğal boyut, CSS default
|
||||
|
||||
if (h.type === 'fixed') style.height = `${h.value * s}px`
|
||||
// auto/fr height → CSS default
|
||||
|
||||
// Container ise flexbox
|
||||
if (isContainer(el)) {
|
||||
const c = el as ContainerElement
|
||||
style.display = 'flex'
|
||||
style.flexDirection = c.direction === 'row' ? 'row' : 'column'
|
||||
if (c.gap > 0) style.gap = `${c.gap * s}px`
|
||||
if (c.padding.top || c.padding.right || c.padding.bottom || c.padding.left) {
|
||||
style.padding = `${c.padding.top * s}px ${c.padding.right * s}px ${c.padding.bottom * s}px ${c.padding.left * s}px`
|
||||
}
|
||||
|
||||
// align (cross-axis)
|
||||
const alignMap = { start: 'flex-start', center: 'center', end: 'flex-end', stretch: 'stretch' }
|
||||
if (c.direction === 'column') {
|
||||
style.alignItems = alignMap[c.align] || 'stretch'
|
||||
} else {
|
||||
style.alignItems = alignMap[c.align] || 'flex-start'
|
||||
}
|
||||
|
||||
// justify (main-axis)
|
||||
const justifyMap = { start: 'flex-start', center: 'center', end: 'flex-end', 'space-between': 'space-between' }
|
||||
style.justifyContent = justifyMap[c.justify] || 'flex-start'
|
||||
}
|
||||
|
||||
return style
|
||||
})
|
||||
|
||||
// --- Drag state (sadece absolute elemanlar) ---
|
||||
const pointerStart = ref({ x: 0, y: 0 })
|
||||
const isDragging = ref(false)
|
||||
const dragTransform = ref({ x: 0, y: 0 })
|
||||
|
||||
const isInteracting = computed(() => isDragging.value)
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
e.stopPropagation()
|
||||
editorStore.selectElement(props.element.id)
|
||||
|
||||
if (!isAbsolute.value) return
|
||||
|
||||
const target = e.currentTarget as HTMLElement
|
||||
target.setPointerCapture(e.pointerId)
|
||||
|
||||
pointerStart.value = { x: e.clientX, y: e.clientY }
|
||||
dragTransform.value = { x: 0, y: 0 }
|
||||
isDragging.value = true
|
||||
editorStore.setDragging(true)
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!isDragging.value) return
|
||||
dragTransform.value = {
|
||||
x: e.clientX - pointerStart.value.x,
|
||||
y: e.clientY - pointerStart.value.y,
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
if (!isDragging.value) return
|
||||
isDragging.value = false
|
||||
editorStore.setDragging(false)
|
||||
|
||||
if (props.element.position.type !== 'absolute') return
|
||||
|
||||
const dxMm = dragTransform.value.x / props.scale
|
||||
const dyMm = dragTransform.value.y / props.scale
|
||||
|
||||
if (Math.abs(dxMm) > 0.1 || Math.abs(dyMm) > 0.1) {
|
||||
templateStore.updateElementPosition(props.element.id, {
|
||||
type: 'absolute',
|
||||
x: Math.round((props.element.position.x + dxMm) * 10) / 10,
|
||||
y: Math.round((props.element.position.y + dyMm) * 10) / 10,
|
||||
})
|
||||
}
|
||||
dragTransform.value = { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
const dragStyle = computed(() => {
|
||||
if (!isDragging.value) return {}
|
||||
return { transform: `translate(${dragTransform.value.x}px, ${dragTransform.value.y}px)` }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="element-handle"
|
||||
:class="{
|
||||
'element-handle--selected': isSelected,
|
||||
'element-handle--interacting': isInteracting,
|
||||
'element-handle--container': isContainerEl,
|
||||
'element-handle--absolute': isAbsolute,
|
||||
'element-handle--leaf': !isContainerEl,
|
||||
}"
|
||||
:style="{ ...layoutStyle, ...dragStyle }"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
>
|
||||
<!-- Container çocuklarını recursive render et -->
|
||||
<template v-if="isContainerEl && 'children' in element">
|
||||
<ElementHandle
|
||||
v-for="child in (element as ContainerElement).children"
|
||||
:key="child.id"
|
||||
:element="child"
|
||||
:scale="scale"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Seçim göstergesi -->
|
||||
<div v-if="isSelected" class="selection-border" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.element-handle {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
min-height: 2px;
|
||||
}
|
||||
|
||||
.element-handle--absolute {
|
||||
position: absolute;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.element-handle--leaf {
|
||||
/* Leaf elemanlar tıklanabilir alan */
|
||||
min-height: 4px;
|
||||
}
|
||||
|
||||
/* Hover efekti */
|
||||
.element-handle:hover > .selection-border,
|
||||
.element-handle--selected > .selection-border {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.selection-border {
|
||||
display: none;
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
border: 1.5px solid rgb(59, 130, 246);
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.element-handle--container > .selection-border {
|
||||
border-color: rgb(139, 92, 246);
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.element-handle:hover > .selection-border {
|
||||
border-color: rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.element-handle--container:hover > .selection-border {
|
||||
border-color: rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
|
||||
.element-handle--selected > .selection-border {
|
||||
border-color: rgb(59, 130, 246);
|
||||
}
|
||||
|
||||
.element-handle--selected.element-handle--container > .selection-border {
|
||||
border-color: rgb(139, 92, 246);
|
||||
}
|
||||
|
||||
.element-handle--interacting {
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
670
frontend/src/components/editor/InteractionOverlay.vue
Normal file
670
frontend/src/components/editor/InteractionOverlay.vue
Normal file
@@ -0,0 +1,670 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useTemplateStore } from '../../stores/template'
|
||||
import { useEditorStore } from '../../stores/editor'
|
||||
import type { ElementLayout } from '../../core/template-to-typst'
|
||||
import type { TemplateElement, SizeValue, ContainerElement } from '../../core/types'
|
||||
import { isContainer, sz } from '../../core/types'
|
||||
|
||||
const props = defineProps<{
|
||||
scale: number
|
||||
layout: Record<string, ElementLayout>
|
||||
pageWidthPt: number
|
||||
}>()
|
||||
|
||||
const templateStore = useTemplateStore()
|
||||
const editorStore = useEditorStore()
|
||||
|
||||
// pt→px dönüşüm katsayısı
|
||||
const ptToPx = computed(() => {
|
||||
const pageWidthPx = templateStore.template.page.width * props.scale
|
||||
return props.pageWidthPt > 0 ? pageWidthPx / props.pageWidthPt : 1
|
||||
})
|
||||
|
||||
// Tüm elemanları flat olarak topla (root hariç)
|
||||
const flatElements = computed(() => {
|
||||
const result: TemplateElement[] = []
|
||||
function walk(el: TemplateElement) {
|
||||
if (isContainer(el)) {
|
||||
for (const child of el.children) {
|
||||
result.push(child)
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(templateStore.template.root)
|
||||
return result
|
||||
})
|
||||
|
||||
// Tüm container'lar (root dahil) — drop target tespiti için
|
||||
const allContainers = computed(() => {
|
||||
const result: ContainerElement[] = [templateStore.template.root]
|
||||
function walk(el: TemplateElement) {
|
||||
if (isContainer(el)) {
|
||||
result.push(el)
|
||||
for (const child of el.children) walk(child)
|
||||
}
|
||||
}
|
||||
for (const child of templateStore.template.root.children) walk(child)
|
||||
return result
|
||||
})
|
||||
|
||||
function getElementStyle(el: TemplateElement) {
|
||||
const l = props.layout[el.id]
|
||||
if (!l) return { display: 'none' }
|
||||
|
||||
const s = ptToPx.value
|
||||
const h = l.height * s
|
||||
const minH = 8
|
||||
const actualH = Math.max(h, minH)
|
||||
const yOffset = h < minH ? (minH - h) / 2 : 0
|
||||
|
||||
return {
|
||||
position: 'absolute' as const,
|
||||
left: `${l.x * s}px`,
|
||||
top: `${l.y * s - yOffset}px`,
|
||||
width: `${l.width * s}px`,
|
||||
height: `${actualH}px`,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Seçim ---
|
||||
|
||||
function onElementClick(e: PointerEvent, id: string) {
|
||||
e.stopPropagation()
|
||||
if (didDrag.value) return
|
||||
editorStore.selectElement(id)
|
||||
}
|
||||
|
||||
function onCanvasClick() {
|
||||
editorStore.selectElement('root')
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Ortak drop target sistemi
|
||||
// ============================================================
|
||||
|
||||
const dropTargetContainerId = ref<string | null>(null)
|
||||
const dropVisualIndex = ref<number | null>(null)
|
||||
const dropLogicalIndex = ref<number | null>(null)
|
||||
|
||||
/** Mouse pozisyonuna göre en derin container'ı bul */
|
||||
function findDeepestContainer(mouseX: number, mouseY: number, excludeId?: string): ContainerElement {
|
||||
const s = ptToPx.value
|
||||
let best: ContainerElement = templateStore.template.root
|
||||
|
||||
for (const c of allContainers.value) {
|
||||
if (c.id === excludeId) continue
|
||||
const l = props.layout[c.id]
|
||||
if (!l) continue
|
||||
|
||||
const cx = l.x * s
|
||||
const cy = l.y * s
|
||||
const cw = l.width * s
|
||||
const ch = l.height * s
|
||||
|
||||
if (mouseX >= cx && mouseX <= cx + cw && mouseY >= cy && mouseY <= cy + ch) {
|
||||
// Daha küçük (daha derin) container'ı tercih et
|
||||
const bestL = props.layout[best.id]
|
||||
if (!bestL || (cw * ch < bestL.width * s * bestL.height * s)) {
|
||||
best = c
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/** Container içinde drop index hesapla */
|
||||
function computeDropIndex(container: ContainerElement, mouseY: number, excludeId?: string) {
|
||||
const s = ptToPx.value
|
||||
const flowChildren = container.children.filter(c => c.position.type !== 'absolute' && c.id !== excludeId)
|
||||
|
||||
let visualIdx = flowChildren.length
|
||||
|
||||
for (let i = 0; i < flowChildren.length; i++) {
|
||||
const l = props.layout[flowChildren[i].id]
|
||||
if (!l) continue
|
||||
const centerY = l.y * s + (l.height * s) / 2
|
||||
if (mouseY < centerY) {
|
||||
visualIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Mantıksal index: excludeId aynı container'daysa offset hesapla
|
||||
let logicalIdx = visualIdx
|
||||
if (excludeId) {
|
||||
const allFlow = container.children.filter(c => c.position.type !== 'absolute')
|
||||
const currentIdx = allFlow.findIndex(c => c.id === excludeId)
|
||||
if (currentIdx >= 0) {
|
||||
// visualIdx, excludeId çıkarılmış listede. Gerçek listedeki pozisyona çevir.
|
||||
// flowChildren zaten excludeId hariç, dolayısıyla visualIdx doğrudan gerçek insert indexi
|
||||
// Ama reorderChild fromIndex/toIndex aynı liste üzerinde çalışır
|
||||
// Gerçek listedeki index'e çevir
|
||||
let realIdx = 0
|
||||
let count = 0
|
||||
for (let i = 0; i < allFlow.length; i++) {
|
||||
if (allFlow[i].id === excludeId) continue
|
||||
if (count === visualIdx) { realIdx = i; break }
|
||||
count++
|
||||
realIdx = i + 1
|
||||
}
|
||||
logicalIdx = realIdx
|
||||
if (realIdx > currentIdx) logicalIdx--
|
||||
}
|
||||
}
|
||||
|
||||
return { visualIdx, logicalIdx }
|
||||
}
|
||||
|
||||
function updateDropFromMouse(mouseX: number, mouseY: number, excludeId?: string) {
|
||||
const container = findDeepestContainer(mouseX, mouseY, excludeId)
|
||||
dropTargetContainerId.value = container.id
|
||||
|
||||
const { visualIdx, logicalIdx } = computeDropIndex(container, mouseY, excludeId)
|
||||
dropVisualIndex.value = visualIdx
|
||||
dropLogicalIndex.value = logicalIdx
|
||||
}
|
||||
|
||||
function clearDropTarget() {
|
||||
dropTargetContainerId.value = null
|
||||
dropVisualIndex.value = null
|
||||
dropLogicalIndex.value = null
|
||||
}
|
||||
|
||||
// Drop indicator pozisyonu (ortak)
|
||||
const dropIndicatorStyle = computed(() => {
|
||||
if (dropTargetContainerId.value === null || dropVisualIndex.value === null) {
|
||||
return { display: 'none' }
|
||||
}
|
||||
|
||||
const container = templateStore.getElementById(dropTargetContainerId.value)
|
||||
if (!container || !isContainer(container)) return { display: 'none' }
|
||||
|
||||
const s = ptToPx.value
|
||||
const idx = dropVisualIndex.value
|
||||
|
||||
// Sürüklenen elemanı çıkar
|
||||
const dragId = dragElementId.value
|
||||
const flowChildren = container.children.filter(c => c.position.type !== 'absolute' && c.id !== dragId)
|
||||
|
||||
// Gap'in ortasına yerleştir: üstteki elemanın alt kenarı ile alttaki elemanın üst kenarı arası
|
||||
let y = 0
|
||||
if (idx === 0 && flowChildren.length > 0) {
|
||||
// İlk pozisyon: ilk elemanın üst kenarı ile container üst kenarı arası
|
||||
const l = props.layout[flowChildren[0].id]
|
||||
const cl = props.layout[container.id]
|
||||
if (l && cl) {
|
||||
y = (cl.y * s + l.y * s) / 2 // container top ile eleman top arası
|
||||
} else if (l) {
|
||||
y = l.y * s - 4
|
||||
}
|
||||
} else if (idx < flowChildren.length && idx > 0) {
|
||||
// Ortada: üstteki elemanın altı ile alttaki elemanın üstü arası
|
||||
const above = props.layout[flowChildren[idx - 1].id]
|
||||
const below = props.layout[flowChildren[idx].id]
|
||||
if (above && below) {
|
||||
const aboveBottom = (above.y + above.height) * s
|
||||
const belowTop = below.y * s
|
||||
y = (aboveBottom + belowTop) / 2
|
||||
}
|
||||
} else if (idx === 0 && flowChildren.length === 0) {
|
||||
// Boş container
|
||||
const cl = props.layout[container.id]
|
||||
if (cl) y = cl.y * s + 8
|
||||
} else if (flowChildren.length > 0) {
|
||||
// Son pozisyon: son elemanın altından gap kadar aşağıda
|
||||
const last = flowChildren[flowChildren.length - 1]
|
||||
const l = props.layout[last.id]
|
||||
if (l) {
|
||||
const gapPx = container.gap * props.scale
|
||||
y = (l.y + l.height) * s + gapPx / 2
|
||||
}
|
||||
}
|
||||
|
||||
const cl = props.layout[container.id]
|
||||
const x = cl ? cl.x * s : 0
|
||||
const width = cl ? cl.width * s : 100
|
||||
|
||||
return {
|
||||
position: 'absolute' as const,
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
width: `${width}px`,
|
||||
height: '2px',
|
||||
background: 'rgb(59, 130, 246)',
|
||||
borderRadius: '1px',
|
||||
zIndex: 1000,
|
||||
pointerEvents: 'none' as const,
|
||||
}
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Mevcut eleman sürükleme (reorder + cross-container move)
|
||||
// ============================================================
|
||||
|
||||
const isDragging = ref(false)
|
||||
const didDrag = ref(false)
|
||||
const dragElementId = ref<string | null>(null)
|
||||
const dragOffset = ref({ x: 0, y: 0 })
|
||||
const dragGhost = ref({ x: 0, y: 0, width: 0, height: 0 })
|
||||
|
||||
function onDragStart(e: PointerEvent, el: TemplateElement) {
|
||||
if (el.position.type === 'absolute') {
|
||||
onAbsoluteDragStart(e, el)
|
||||
return
|
||||
}
|
||||
|
||||
const l = props.layout[el.id]
|
||||
if (!l) return
|
||||
|
||||
const s = ptToPx.value
|
||||
dragElementId.value = el.id
|
||||
didDrag.value = false
|
||||
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
dragOffset.value = { x: e.clientX - rect.left, y: e.clientY - rect.top }
|
||||
dragGhost.value = {
|
||||
x: l.x * s,
|
||||
y: l.y * s,
|
||||
width: l.width * s,
|
||||
height: l.height * s,
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onDragMove)
|
||||
window.addEventListener('pointerup', onDragEnd)
|
||||
}
|
||||
|
||||
function onDragMove(e: PointerEvent) {
|
||||
if (!dragElementId.value) return
|
||||
|
||||
const overlayEl = document.querySelector('.interaction-overlay')
|
||||
if (!overlayEl) return
|
||||
const overlayRect = overlayEl.getBoundingClientRect()
|
||||
|
||||
const x = e.clientX - overlayRect.left - dragOffset.value.x
|
||||
const y = e.clientY - overlayRect.top - dragOffset.value.y
|
||||
const mouseX = e.clientX - overlayRect.left
|
||||
const mouseY = e.clientY - overlayRect.top
|
||||
|
||||
if (!isDragging.value) {
|
||||
const dx = Math.abs(x - dragGhost.value.x)
|
||||
const dy = Math.abs(y - dragGhost.value.y)
|
||||
if (dx < 4 && dy < 4) return
|
||||
isDragging.value = true
|
||||
didDrag.value = true
|
||||
editorStore.setDragging(true)
|
||||
}
|
||||
|
||||
dragGhost.value.x = x
|
||||
dragGhost.value.y = y
|
||||
|
||||
updateDropFromMouse(mouseX, mouseY, dragElementId.value)
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
window.removeEventListener('pointermove', onDragMove)
|
||||
window.removeEventListener('pointerup', onDragEnd)
|
||||
|
||||
if (isDragging.value && dragElementId.value && dropTargetContainerId.value !== null && dropLogicalIndex.value !== null) {
|
||||
const currentParent = templateStore.getParent(dragElementId.value)
|
||||
const targetContainerId = dropTargetContainerId.value
|
||||
|
||||
if (currentParent && currentParent.id === targetContainerId) {
|
||||
// Aynı container içinde reorder
|
||||
const currentIdx = currentParent.children.findIndex(c => c.id === dragElementId.value)
|
||||
if (currentIdx !== -1 && currentIdx !== dropLogicalIndex.value) {
|
||||
templateStore.reorderChild(currentParent.id, currentIdx, dropLogicalIndex.value)
|
||||
}
|
||||
} else {
|
||||
// Farklı container'a taşı
|
||||
templateStore.moveElement(dragElementId.value, targetContainerId, dropLogicalIndex.value)
|
||||
}
|
||||
}
|
||||
|
||||
isDragging.value = false
|
||||
dragElementId.value = null
|
||||
editorStore.setDragging(false)
|
||||
clearDropTarget()
|
||||
setTimeout(() => { didDrag.value = false }, 50)
|
||||
}
|
||||
|
||||
// --- Absolute eleman drag ---
|
||||
|
||||
const absoluteDragId = ref<string | null>(null)
|
||||
const absoluteDragStart = ref({ mouseX: 0, mouseY: 0, elX: 0, elY: 0 })
|
||||
|
||||
function onAbsoluteDragStart(e: PointerEvent, el: TemplateElement) {
|
||||
if (el.position.type !== 'absolute') return
|
||||
|
||||
absoluteDragId.value = el.id
|
||||
didDrag.value = false
|
||||
absoluteDragStart.value = {
|
||||
mouseX: e.clientX,
|
||||
mouseY: e.clientY,
|
||||
elX: el.position.x,
|
||||
elY: el.position.y,
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onAbsoluteDragMove)
|
||||
window.addEventListener('pointerup', onAbsoluteDragEnd)
|
||||
}
|
||||
|
||||
function onAbsoluteDragMove(e: PointerEvent) {
|
||||
if (!absoluteDragId.value) return
|
||||
|
||||
const dx = e.clientX - absoluteDragStart.value.mouseX
|
||||
const dy = e.clientY - absoluteDragStart.value.mouseY
|
||||
|
||||
if (!isDragging.value) {
|
||||
if (Math.abs(dx) < 4 && Math.abs(dy) < 4) return
|
||||
isDragging.value = true
|
||||
didDrag.value = true
|
||||
editorStore.setDragging(true)
|
||||
}
|
||||
|
||||
const pxToMm = 1 / props.scale
|
||||
const newX = Math.max(0, absoluteDragStart.value.elX + dx * pxToMm)
|
||||
const newY = Math.max(0, absoluteDragStart.value.elY + dy * pxToMm)
|
||||
|
||||
templateStore.updateElementPosition(absoluteDragId.value, {
|
||||
type: 'absolute',
|
||||
x: Math.round(newX * 10) / 10,
|
||||
y: Math.round(newY * 10) / 10,
|
||||
})
|
||||
}
|
||||
|
||||
function onAbsoluteDragEnd() {
|
||||
window.removeEventListener('pointermove', onAbsoluteDragMove)
|
||||
window.removeEventListener('pointerup', onAbsoluteDragEnd)
|
||||
|
||||
isDragging.value = false
|
||||
absoluteDragId.value = null
|
||||
editorStore.setDragging(false)
|
||||
setTimeout(() => { didDrag.value = false }, 50)
|
||||
}
|
||||
|
||||
// --- Resize ---
|
||||
|
||||
const isResizing = ref(false)
|
||||
const resizeElementId = ref<string | null>(null)
|
||||
const resizeHandle = ref('')
|
||||
const resizeStart = ref({ mouseX: 0, mouseY: 0, x: 0, y: 0, width: 0, height: 0 })
|
||||
const resizeGhost = ref({ x: 0, y: 0, width: 0, height: 0 })
|
||||
const resizeFinalMm = ref({ width: 0, height: 0 })
|
||||
|
||||
function onResizeStart(e: PointerEvent, elId: string, handle: string) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
|
||||
const l = props.layout[elId]
|
||||
if (!l) return
|
||||
|
||||
resizeElementId.value = elId
|
||||
resizeHandle.value = handle
|
||||
isResizing.value = true
|
||||
|
||||
const s = ptToPx.value
|
||||
const ptToMm = 1 / 2.8346
|
||||
|
||||
resizeStart.value = {
|
||||
mouseX: e.clientX, mouseY: e.clientY,
|
||||
x: l.x * s, y: l.y * s,
|
||||
width: l.width * s, height: l.height * s,
|
||||
}
|
||||
resizeGhost.value = { x: l.x * s, y: l.y * s, width: l.width * s, height: l.height * s }
|
||||
resizeFinalMm.value = { width: l.width * ptToMm, height: l.height * ptToMm }
|
||||
|
||||
window.addEventListener('pointermove', onResizeMove)
|
||||
window.addEventListener('pointerup', onResizeEnd)
|
||||
}
|
||||
|
||||
function onResizeMove(e: PointerEvent) {
|
||||
if (!resizeElementId.value) return
|
||||
|
||||
const dx = e.clientX - resizeStart.value.mouseX
|
||||
const dy = e.clientY - resizeStart.value.mouseY
|
||||
const handle = resizeHandle.value
|
||||
const pxToMm = 1 / props.scale
|
||||
|
||||
let gx = resizeStart.value.x, gy = resizeStart.value.y
|
||||
let gw = resizeStart.value.width, gh = resizeStart.value.height
|
||||
|
||||
if (handle.includes('e')) gw = Math.max(20, resizeStart.value.width + dx)
|
||||
if (handle.includes('w')) { gw = Math.max(20, resizeStart.value.width - dx); gx = resizeStart.value.x + dx }
|
||||
if (handle.includes('s')) gh = Math.max(10, resizeStart.value.height + dy)
|
||||
if (handle.includes('n')) { gh = Math.max(10, resizeStart.value.height - dy); gy = resizeStart.value.y + dy }
|
||||
|
||||
resizeGhost.value = { x: gx, y: gy, width: gw, height: gh }
|
||||
|
||||
const startWMm = resizeStart.value.width * pxToMm
|
||||
const startHMm = resizeStart.value.height * pxToMm
|
||||
let wMm = startWMm, hMm = startHMm
|
||||
if (handle.includes('e')) wMm = Math.max(5, startWMm + dx * pxToMm)
|
||||
if (handle.includes('w')) wMm = Math.max(5, startWMm - dx * pxToMm)
|
||||
if (handle.includes('s')) hMm = Math.max(3, startHMm + dy * pxToMm)
|
||||
if (handle.includes('n')) hMm = Math.max(3, startHMm - dy * pxToMm)
|
||||
|
||||
resizeFinalMm.value = { width: Math.round(wMm * 10) / 10, height: Math.round(hMm * 10) / 10 }
|
||||
}
|
||||
|
||||
function onResizeEnd() {
|
||||
window.removeEventListener('pointermove', onResizeMove)
|
||||
window.removeEventListener('pointerup', onResizeEnd)
|
||||
|
||||
if (resizeElementId.value) {
|
||||
const handle = resizeHandle.value
|
||||
const sizeUpdate: { width?: SizeValue; height?: SizeValue } = {}
|
||||
if (handle.includes('e') || handle.includes('w')) sizeUpdate.width = sz.fixed(resizeFinalMm.value.width)
|
||||
if (handle.includes('s') || handle.includes('n')) sizeUpdate.height = sz.fixed(resizeFinalMm.value.height)
|
||||
templateStore.updateElementSize(resizeElementId.value, sizeUpdate)
|
||||
}
|
||||
|
||||
isResizing.value = false
|
||||
resizeElementId.value = null
|
||||
resizeHandle.value = ''
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Toolbox sürükle-bırak (HTML5 drag API)
|
||||
// ============================================================
|
||||
|
||||
function onToolboxDragOver(e: DragEvent) {
|
||||
if (!editorStore.draggedNewElement) return
|
||||
e.preventDefault()
|
||||
|
||||
const overlayEl = e.currentTarget as HTMLElement
|
||||
const rect = overlayEl.getBoundingClientRect()
|
||||
const mouseX = e.clientX - rect.left
|
||||
const mouseY = e.clientY - rect.top
|
||||
|
||||
updateDropFromMouse(mouseX, mouseY)
|
||||
}
|
||||
|
||||
function onToolboxDragLeave() {
|
||||
clearDropTarget()
|
||||
}
|
||||
|
||||
function onToolboxDrop(e: DragEvent) {
|
||||
const newEl = editorStore.draggedNewElement
|
||||
if (!newEl) return
|
||||
|
||||
const targetId = dropTargetContainerId.value ?? 'root'
|
||||
const idx = dropLogicalIndex.value ?? undefined
|
||||
|
||||
templateStore.addChild(targetId, newEl, idx)
|
||||
editorStore.selectElement(newEl.id)
|
||||
editorStore.endDragNewElement()
|
||||
clearDropTarget()
|
||||
}
|
||||
|
||||
// Aktif sürükleme var mı (eleman veya toolbox)
|
||||
const isAnyDragActive = computed(() =>
|
||||
(isDragging.value && dragElementId.value !== null) || !!editorStore.draggedNewElement
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="interaction-overlay"
|
||||
:class="{ 'interaction-overlay--drop-active': isAnyDragActive }"
|
||||
@pointerdown.self="onCanvasClick"
|
||||
@dragover.prevent="onToolboxDragOver"
|
||||
@dragleave="onToolboxDragLeave"
|
||||
@drop.prevent="onToolboxDrop"
|
||||
>
|
||||
<!-- Element handles -->
|
||||
<div
|
||||
v-for="el in flatElements"
|
||||
:key="el.id"
|
||||
class="element-handle"
|
||||
:class="{
|
||||
'element-handle--selected': editorStore.selectedElementId === el.id,
|
||||
'element-handle--container': isContainer(el),
|
||||
'element-handle--dragging': isDragging && dragElementId === el.id,
|
||||
'element-handle--drop-target': isContainer(el) && dropTargetContainerId === el.id && isAnyDragActive,
|
||||
}"
|
||||
:style="getElementStyle(el)"
|
||||
@pointerdown="(e: PointerEvent) => { onElementClick(e, el.id); onDragStart(e, el) }"
|
||||
>
|
||||
<!-- Selection border -->
|
||||
<div v-if="editorStore.selectedElementId === el.id" class="selection-border" />
|
||||
|
||||
<!-- Resize handles -->
|
||||
<template v-if="editorStore.selectedElementId === el.id && !isResizing">
|
||||
<div class="resize-handle resize-handle--se" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'se')" />
|
||||
<div class="resize-handle resize-handle--sw" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'sw')" />
|
||||
<div class="resize-handle resize-handle--ne" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'ne')" />
|
||||
<div class="resize-handle resize-handle--nw" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'nw')" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Drag ghost (mevcut eleman sürükleme) -->
|
||||
<div
|
||||
v-if="isDragging && dragElementId"
|
||||
class="drag-ghost"
|
||||
:style="{
|
||||
left: `${dragGhost.x}px`,
|
||||
top: `${dragGhost.y}px`,
|
||||
width: `${dragGhost.width}px`,
|
||||
height: `${dragGhost.height}px`,
|
||||
}"
|
||||
/>
|
||||
|
||||
<!-- Resize ghost -->
|
||||
<div
|
||||
v-if="isResizing && resizeElementId"
|
||||
class="resize-ghost"
|
||||
:style="{
|
||||
left: `${resizeGhost.x}px`,
|
||||
top: `${resizeGhost.y}px`,
|
||||
width: `${resizeGhost.width}px`,
|
||||
height: `${resizeGhost.height}px`,
|
||||
}"
|
||||
/>
|
||||
|
||||
<!-- Drop indicator (ortak — hem eleman hem toolbox sürükleme) -->
|
||||
<div v-if="isAnyDragActive" :style="dropIndicatorStyle" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.interaction-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.element-handle {
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.element-handle--dragging {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* Selection border */
|
||||
.selection-border {
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
border: 1.5px solid rgb(59, 130, 246);
|
||||
pointer-events: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.element-handle--container > .selection-border {
|
||||
border-color: rgb(139, 92, 246);
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
/* Container'ları hafif kenarlıkla göster (root hariç — root overlay'de flatElements'te yok) */
|
||||
.element-handle--container {
|
||||
outline: 1px dashed rgba(139, 92, 246, 0.25);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
/* Hover efekti */
|
||||
.element-handle:not(.element-handle--selected):hover::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
border: 1.5px solid rgba(59, 130, 246, 0.4);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Resize handles */
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: white;
|
||||
border: 1.5px solid rgb(59, 130, 246);
|
||||
border-radius: 1px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.resize-handle--se { right: -3px; bottom: -3px; cursor: se-resize; }
|
||||
.resize-handle--sw { left: -3px; bottom: -3px; cursor: sw-resize; }
|
||||
.resize-handle--ne { right: -3px; top: -3px; cursor: ne-resize; }
|
||||
.resize-handle--nw { left: -3px; top: -3px; cursor: nw-resize; }
|
||||
|
||||
/* Drag ghost */
|
||||
.drag-ghost {
|
||||
position: absolute;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border: 1.5px dashed rgb(59, 130, 246);
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
/* Resize ghost */
|
||||
.resize-ghost {
|
||||
position: absolute;
|
||||
border: 1.5px solid rgb(59, 130, 246);
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
pointer-events: none;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
/* Sürükleme aktifken container'ları göster */
|
||||
.interaction-overlay--drop-active .element-handle--container::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 1.5px dashed rgba(139, 92, 246, 0.5);
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Drop hedef container highlight */
|
||||
.element-handle--drop-target::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
border: 2px solid rgb(139, 92, 246) !important;
|
||||
background: rgba(139, 92, 246, 0.08);
|
||||
border-radius: 3px;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
35
frontend/src/components/editor/TypstSvgLayer.vue
Normal file
35
frontend/src/components/editor/TypstSvgLayer.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
svg: string | null
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="typst-svg-layer" v-if="svg" v-html="svg" />
|
||||
<div class="typst-svg-layer typst-svg-layer--empty" v-else>
|
||||
<span>Derleniyor...</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.typst-svg-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.typst-svg-layer :deep(svg) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.typst-svg-layer--empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
446
frontend/src/components/panels/PropertiesPanel.vue
Normal file
446
frontend/src/components/panels/PropertiesPanel.vue
Normal file
@@ -0,0 +1,446 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useTemplateStore } from '../../stores/template'
|
||||
import { useEditorStore } from '../../stores/editor'
|
||||
import { isContainer } from '../../core/types'
|
||||
import type {
|
||||
TemplateElement,
|
||||
ContainerElement,
|
||||
StaticTextElement,
|
||||
LineElement,
|
||||
TextStyle,
|
||||
SizeValue,
|
||||
} from '../../core/types'
|
||||
|
||||
const templateStore = useTemplateStore()
|
||||
const editorStore = useEditorStore()
|
||||
|
||||
const selectedElement = computed(() => {
|
||||
const id = editorStore.selectedElementId
|
||||
if (!id) return null
|
||||
return templateStore.getElementById(id) ?? null
|
||||
})
|
||||
|
||||
const parentElement = computed(() => {
|
||||
const id = editorStore.selectedElementId
|
||||
if (!id) return null
|
||||
return templateStore.getParent(id) ?? null
|
||||
})
|
||||
|
||||
// --- Generic updater ---
|
||||
|
||||
function update(updates: Partial<TemplateElement>) {
|
||||
const id = editorStore.selectedElementId
|
||||
if (!id) return
|
||||
templateStore.updateElement(id, updates)
|
||||
}
|
||||
|
||||
function updateStyle(key: string, value: unknown) {
|
||||
const el = selectedElement.value
|
||||
if (!el) return
|
||||
update({ style: { ...el.style, [key]: value } } as Partial<TemplateElement>)
|
||||
}
|
||||
|
||||
function updateSize(axis: 'width' | 'height', sv: SizeValue) {
|
||||
const id = editorStore.selectedElementId
|
||||
if (!id) return
|
||||
templateStore.updateElementSize(id, { [axis]: sv })
|
||||
}
|
||||
|
||||
// --- Positioning ---
|
||||
|
||||
function togglePositioning() {
|
||||
const el = selectedElement.value
|
||||
if (!el) return
|
||||
if (el.position.type === 'flow') {
|
||||
templateStore.updateElementPosition(el.id, { type: 'absolute', x: 0, y: 0 })
|
||||
} else {
|
||||
templateStore.updateElementPosition(el.id, { type: 'flow' })
|
||||
}
|
||||
}
|
||||
|
||||
// --- Delete ---
|
||||
|
||||
function deleteElement() {
|
||||
const id = editorStore.selectedElementId
|
||||
if (!id || id === 'root') return
|
||||
editorStore.clearSelection()
|
||||
templateStore.removeElement(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="properties-panel">
|
||||
<div v-if="!selectedElement" class="properties-panel__empty">
|
||||
Bir eleman seçin
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Header -->
|
||||
<div class="prop-section">
|
||||
<div class="prop-section__title">
|
||||
{{ selectedElement.type === 'container' ? 'Container' : selectedElement.type === 'static_text' ? 'Metin' : selectedElement.type === 'line' ? 'Çizgi' : 'Eleman' }}
|
||||
<span class="prop-id">{{ selectedElement.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Positioning -->
|
||||
<div class="prop-section">
|
||||
<div class="prop-section__title">Pozisyon</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Mod</label>
|
||||
<select class="prop-input prop-select" :value="selectedElement.position.type" @change="togglePositioning">
|
||||
<option value="flow">Flow</option>
|
||||
<option value="absolute">Absolute</option>
|
||||
</select>
|
||||
</div>
|
||||
<template v-if="selectedElement.position.type === 'absolute'">
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">X (mm)</label>
|
||||
<input class="prop-input" type="number" step="0.5"
|
||||
:value="selectedElement.position.x"
|
||||
@input="(e) => templateStore.updateElementPosition(selectedElement!.id, { type: 'absolute', x: parseFloat((e.target as HTMLInputElement).value) || 0, y: (selectedElement!.position as any).y ?? 0 })" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Y (mm)</label>
|
||||
<input class="prop-input" type="number" step="0.5"
|
||||
:value="selectedElement.position.y"
|
||||
@input="(e) => templateStore.updateElementPosition(selectedElement!.id, { type: 'absolute', x: (selectedElement!.position as any).x ?? 0, y: parseFloat((e.target as HTMLInputElement).value) || 0 })" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Size -->
|
||||
<div class="prop-section">
|
||||
<div class="prop-section__title">Boyut</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Genişlik</label>
|
||||
<select class="prop-input prop-select"
|
||||
:value="selectedElement.size.width.type"
|
||||
@change="(e) => {
|
||||
const t = (e.target as HTMLSelectElement).value
|
||||
if (t === 'auto') updateSize('width', { type: 'auto' })
|
||||
else if (t === 'fr') updateSize('width', { type: 'fr', value: 1 })
|
||||
else updateSize('width', { type: 'fixed', value: 50 })
|
||||
}">
|
||||
<option value="auto">Otomatik</option>
|
||||
<option value="fixed">Sabit (mm)</option>
|
||||
<option value="fr">Oran (fr)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="selectedElement.size.width.type === 'fixed'" class="prop-row">
|
||||
<label class="prop-label">mm</label>
|
||||
<input class="prop-input" type="number" step="1" min="1"
|
||||
:value="(selectedElement.size.width as any).value"
|
||||
@input="(e) => updateSize('width', { type: 'fixed', value: parseFloat((e.target as HTMLInputElement).value) || 10 })" />
|
||||
</div>
|
||||
<div v-if="selectedElement.size.width.type === 'fr'" class="prop-row">
|
||||
<label class="prop-label">fr</label>
|
||||
<input class="prop-input" type="number" step="1" min="1"
|
||||
:value="(selectedElement.size.width as any).value"
|
||||
@input="(e) => updateSize('width', { type: 'fr', value: parseFloat((e.target as HTMLInputElement).value) || 1 })" />
|
||||
</div>
|
||||
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Yükseklik</label>
|
||||
<select class="prop-input prop-select"
|
||||
:value="selectedElement.size.height.type"
|
||||
@change="(e) => {
|
||||
const t = (e.target as HTMLSelectElement).value
|
||||
if (t === 'auto') updateSize('height', { type: 'auto' })
|
||||
else if (t === 'fr') updateSize('height', { type: 'fr', value: 1 })
|
||||
else updateSize('height', { type: 'fixed', value: 20 })
|
||||
}">
|
||||
<option value="auto">Otomatik</option>
|
||||
<option value="fixed">Sabit (mm)</option>
|
||||
<option value="fr">Oran (fr)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="selectedElement.size.height.type === 'fixed'" class="prop-row">
|
||||
<label class="prop-label">mm</label>
|
||||
<input class="prop-input" type="number" step="1" min="1"
|
||||
:value="(selectedElement.size.height as any).value"
|
||||
@input="(e) => updateSize('height', { type: 'fixed', value: parseFloat((e.target as HTMLInputElement).value) || 10 })" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Text style (static_text, text) -->
|
||||
<div v-if="selectedElement.type === 'static_text' || selectedElement.type === 'text'" class="prop-section">
|
||||
<div class="prop-section__title">Metin Stili</div>
|
||||
|
||||
<div v-if="selectedElement.type === 'static_text'" class="prop-row">
|
||||
<label class="prop-label">Metin</label>
|
||||
<input class="prop-input" type="text"
|
||||
:value="(selectedElement as StaticTextElement).content"
|
||||
@input="(e) => update({ content: (e.target as HTMLInputElement).value } as any)" />
|
||||
</div>
|
||||
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Boyut (pt)</label>
|
||||
<input class="prop-input" type="number" step="1" min="1"
|
||||
:value="(selectedElement.style as TextStyle).fontSize ?? 11"
|
||||
@input="(e) => updateStyle('fontSize', parseFloat((e.target as HTMLInputElement).value) || 11)" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Kalınlık</label>
|
||||
<select class="prop-input prop-select"
|
||||
:value="(selectedElement.style as TextStyle).fontWeight ?? 'normal'"
|
||||
@change="(e) => updateStyle('fontWeight', (e.target as HTMLSelectElement).value)">
|
||||
<option value="normal">Normal</option>
|
||||
<option value="bold">Kalın</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Renk</label>
|
||||
<input class="prop-input prop-color" type="color"
|
||||
:value="(selectedElement.style as TextStyle).color ?? '#000000'"
|
||||
@input="(e) => updateStyle('color', (e.target as HTMLInputElement).value)" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Hizalama</label>
|
||||
<select class="prop-input prop-select"
|
||||
:value="(selectedElement.style as TextStyle).align ?? 'left'"
|
||||
@change="(e) => updateStyle('align', (e.target as HTMLSelectElement).value)">
|
||||
<option value="left">Sol</option>
|
||||
<option value="center">Orta</option>
|
||||
<option value="right">Sag</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Line style -->
|
||||
<div v-if="selectedElement.type === 'line'" class="prop-section">
|
||||
<div class="prop-section__title">Çizgi Stili</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Kalınlık (pt)</label>
|
||||
<input class="prop-input" type="number" step="0.25" min="0.25"
|
||||
:value="(selectedElement as LineElement).style.strokeWidth ?? 0.5"
|
||||
@input="(e) => updateStyle('strokeWidth', parseFloat((e.target as HTMLInputElement).value) || 0.5)" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Renk</label>
|
||||
<input class="prop-input prop-color" type="color"
|
||||
:value="(selectedElement as LineElement).style.strokeColor ?? '#000000'"
|
||||
@input="(e) => updateStyle('strokeColor', (e.target as HTMLInputElement).value)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Container properties -->
|
||||
<div v-if="isContainer(selectedElement)" class="prop-section">
|
||||
<div class="prop-section__title">Container Ayarları</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Yön</label>
|
||||
<select class="prop-input prop-select"
|
||||
:value="(selectedElement as ContainerElement).direction"
|
||||
@change="(e) => update({ direction: (e.target as HTMLSelectElement).value } as any)">
|
||||
<option value="column">Dikey</option>
|
||||
<option value="row">Yatay</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Boşluk (mm)</label>
|
||||
<input class="prop-input" type="number" step="1" min="0"
|
||||
:value="(selectedElement as ContainerElement).gap"
|
||||
@input="(e) => update({ gap: parseFloat((e.target as HTMLInputElement).value) || 0 } as any)" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Hizalama</label>
|
||||
<select class="prop-input prop-select"
|
||||
:value="(selectedElement as ContainerElement).align"
|
||||
@change="(e) => update({ align: (e.target as HTMLSelectElement).value } as any)">
|
||||
<option value="start">Baş</option>
|
||||
<option value="center">Orta</option>
|
||||
<option value="end">Son</option>
|
||||
<option value="stretch">Esnet</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Padding -->
|
||||
<div class="prop-section__subtitle">Padding (mm)</div>
|
||||
<div class="prop-row-grid">
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Üst</label>
|
||||
<input class="prop-input" type="number" step="1" min="0"
|
||||
:value="(selectedElement as ContainerElement).padding.top"
|
||||
@input="(e) => update({ padding: { ...(selectedElement as ContainerElement).padding, top: parseFloat((e.target as HTMLInputElement).value) || 0 } } as any)" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Sag</label>
|
||||
<input class="prop-input" type="number" step="1" min="0"
|
||||
:value="(selectedElement as ContainerElement).padding.right"
|
||||
@input="(e) => update({ padding: { ...(selectedElement as ContainerElement).padding, right: parseFloat((e.target as HTMLInputElement).value) || 0 } } as any)" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Alt</label>
|
||||
<input class="prop-input" type="number" step="1" min="0"
|
||||
:value="(selectedElement as ContainerElement).padding.bottom"
|
||||
@input="(e) => update({ padding: { ...(selectedElement as ContainerElement).padding, bottom: parseFloat((e.target as HTMLInputElement).value) || 0 } } as any)" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Sol</label>
|
||||
<input class="prop-input" type="number" step="1" min="0"
|
||||
:value="(selectedElement as ContainerElement).padding.left"
|
||||
@input="(e) => update({ padding: { ...(selectedElement as ContainerElement).padding, left: parseFloat((e.target as HTMLInputElement).value) || 0 } } as any)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Container Style -->
|
||||
<div class="prop-section__subtitle">Stil</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Arka plan</label>
|
||||
<div class="prop-row-inline">
|
||||
<input class="prop-input prop-color" type="color"
|
||||
:value="(selectedElement as ContainerElement).style.backgroundColor ?? '#ffffff'"
|
||||
@input="(e) => updateStyle('backgroundColor', (e.target as HTMLInputElement).value)" />
|
||||
<button v-if="(selectedElement as ContainerElement).style.backgroundColor" class="prop-clear" @click="updateStyle('backgroundColor', undefined)">x</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Kenarlık rengi</label>
|
||||
<div class="prop-row-inline">
|
||||
<input class="prop-input prop-color" type="color"
|
||||
:value="(selectedElement as ContainerElement).style.borderColor ?? '#000000'"
|
||||
@input="(e) => updateStyle('borderColor', (e.target as HTMLInputElement).value)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Kenarlık (pt)</label>
|
||||
<input class="prop-input" type="number" step="0.5" min="0"
|
||||
:value="(selectedElement as ContainerElement).style.borderWidth ?? 0"
|
||||
@input="(e) => updateStyle('borderWidth', parseFloat((e.target as HTMLInputElement).value) || 0)" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Radius (pt)</label>
|
||||
<input class="prop-input" type="number" step="1" min="0"
|
||||
:value="(selectedElement as ContainerElement).style.borderRadius ?? 0"
|
||||
@input="(e) => updateStyle('borderRadius', parseFloat((e.target as HTMLInputElement).value) || 0)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete -->
|
||||
<div v-if="selectedElement.id !== 'root'" class="prop-section">
|
||||
<button class="prop-delete-btn" @click="deleteElement">Sil</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.properties-panel {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.properties-panel__empty {
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.prop-section {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.prop-section__title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.prop-section__subtitle {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #94a3b8;
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
|
||||
.prop-id {
|
||||
font-weight: 400;
|
||||
color: #94a3b8;
|
||||
font-size: 10px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.prop-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.prop-row-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.prop-row-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.prop-label {
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
flex-shrink: 0;
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.prop-input {
|
||||
width: 100px;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
background: white;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.prop-input:focus {
|
||||
outline: none;
|
||||
border-color: #93c5fd;
|
||||
}
|
||||
|
||||
.prop-select {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.prop-color {
|
||||
width: 32px;
|
||||
height: 24px;
|
||||
padding: 1px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.prop-clear {
|
||||
background: none;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
padding: 2px 5px;
|
||||
}
|
||||
|
||||
.prop-delete-btn {
|
||||
width: 100%;
|
||||
padding: 6px;
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.prop-delete-btn:hover {
|
||||
background: #fee2e2;
|
||||
}
|
||||
</style>
|
||||
156
frontend/src/components/panels/ToolboxPanel.vue
Normal file
156
frontend/src/components/panels/ToolboxPanel.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import { useEditorStore } from '../../stores/editor'
|
||||
import type { TemplateElement } from '../../core/types'
|
||||
import { sz } from '../../core/types'
|
||||
|
||||
const editorStore = useEditorStore()
|
||||
|
||||
let idCounter = Date.now()
|
||||
function nextId(prefix: string) {
|
||||
return `${prefix}_${(++idCounter).toString(36)}`
|
||||
}
|
||||
|
||||
interface ToolItem {
|
||||
label: string
|
||||
icon: string
|
||||
create: () => TemplateElement
|
||||
}
|
||||
|
||||
const tools: ToolItem[] = [
|
||||
{
|
||||
label: 'Metin',
|
||||
icon: 'T',
|
||||
create: () => ({
|
||||
id: nextId('txt'),
|
||||
type: 'static_text',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.auto(), height: sz.auto() },
|
||||
style: { fontSize: 11, color: '#000000' },
|
||||
content: 'Yeni metin',
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Container',
|
||||
icon: '▢',
|
||||
create: () => ({
|
||||
id: nextId('cnt'),
|
||||
type: 'container',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.fr(1), height: sz.auto() },
|
||||
direction: 'column' as const,
|
||||
gap: 3,
|
||||
padding: { top: 5, right: 5, bottom: 5, left: 5 },
|
||||
align: 'stretch' as const,
|
||||
justify: 'start' as const,
|
||||
style: {},
|
||||
children: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Cizgi',
|
||||
icon: '—',
|
||||
create: () => ({
|
||||
id: nextId('ln'),
|
||||
type: 'line',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.fr(1), height: sz.auto() },
|
||||
style: { strokeColor: '#000000', strokeWidth: 0.5 },
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
function onDragStart(e: DragEvent, tool: ToolItem) {
|
||||
const el = tool.create()
|
||||
editorStore.startDragNewElement(el)
|
||||
// Drag data (fallback)
|
||||
e.dataTransfer?.setData('text/plain', el.id)
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
}
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
editorStore.endDragNewElement()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="toolbox-panel">
|
||||
<div class="toolbox-panel__title">Arac Kutusu</div>
|
||||
<div class="toolbox-panel__grid">
|
||||
<div
|
||||
v-for="tool in tools"
|
||||
:key="tool.label"
|
||||
class="toolbox-item"
|
||||
draggable="true"
|
||||
@dragstart="(e) => onDragStart(e, tool)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<span class="toolbox-item__icon">{{ tool.icon }}</span>
|
||||
<span class="toolbox-item__label">{{ tool.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbox-panel {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.toolbox-panel__title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.toolbox-panel__grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.toolbox-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
cursor: grab;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.toolbox-item:hover {
|
||||
background: #eff6ff;
|
||||
border-color: #bfdbfe;
|
||||
}
|
||||
|
||||
.toolbox-item:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.toolbox-item__icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f1f5f9;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.toolbox-item__label {
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user