Files
dreport/frontend/src/components/editor/InteractionOverlay.vue
Duhan BALCI 5ffc6d866c
Some checks failed
CI / frontend (push) Successful in 1m55s
CI / publish-crates (push) Successful in 23s
CI / rust (push) Successful in 49s
CI / wasm (push) Successful in 1m44s
CI / publish-npm (push) Failing after 1m50s
format
2026-04-07 01:56:40 +03:00

948 lines
27 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useTemplateStore } from '../../stores/template'
import { useEditorStore } from '../../stores/editor'
import type { LayoutMapEntry } from '../../core/layout-types'
import type { TemplateElement, SizeValue, ContainerElement } from '../../core/types'
import { isContainer, sz } from '../../core/types'
import ElementToolbar from './ElementToolbar.vue'
import { useSnapGuides } from '../../composables/useSnapGuides'
const PAGE_GAP_PX = 24
const props = defineProps<{
scale: number
layoutMap: Record<string, LayoutMapEntry>
pageCount?: number
pageHeightPx?: number
}>()
const templateStore = useTemplateStore()
const editorStore = useEditorStore()
const { activeGuides, collectEdges, calculateSnap, calculateResizeSnap, clearGuides } =
useSnapGuides()
// 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)
}
}
}
// Header ve footer container'larını ve elemanlarını dahil et
if (templateStore.template.header) {
result.push(templateStore.template.header as unknown as TemplateElement)
walk(templateStore.template.header as unknown as TemplateElement)
}
walk(templateStore.template.root)
if (templateStore.template.footer) {
result.push(templateStore.template.footer as unknown as TemplateElement)
walk(templateStore.template.footer as unknown as TemplateElement)
}
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)
}
}
if (templateStore.template.header) {
result.push(templateStore.template.header)
for (const child of templateStore.template.header.children) walk(child)
}
for (const child of templateStore.template.root.children) walk(child)
if (templateStore.template.footer) {
result.push(templateStore.template.footer)
for (const child of templateStore.template.footer.children) walk(child)
}
return result
})
/** Sayfa index'ine göre y offset hesapla (sayfalar arası gap dahil) */
function pageYOffset(pageIndex: number): number {
if (pageIndex <= 0) return 0
const pageH = props.pageHeightPx ?? templateStore.template.page.height * props.scale
return pageIndex * (pageH + PAGE_GAP_PX)
}
function getElementStyle(el: TemplateElement) {
const l = props.layoutMap[el.id]
if (!l) return { display: 'none' }
const s = props.scale
const h = l.height_mm * s
const minH = 8
const actualH = Math.max(h, minH)
const yOff = h < minH ? (minH - h) / 2 : 0
const pYOff = pageYOffset(l.pageIndex)
return {
position: 'absolute' as const,
left: `${l.x_mm * s}px`,
top: `${l.y_mm * s - yOff + pYOff}px`,
width: `${l.width_mm * s}px`,
height: `${actualH}px`,
}
}
// --- Seçim ---
function onElementClick(e: PointerEvent, id: string) {
e.stopPropagation()
if (didDrag.value) return
if (e.shiftKey) {
editorStore.toggleSelection(id)
} else {
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 = props.scale
let best: ContainerElement = templateStore.template.root
for (const c of allContainers.value) {
if (c.id === excludeId) continue
const l = props.layoutMap[c.id]
if (!l) continue
const cx = l.x_mm * s
const cy = l.y_mm * s + pageYOffset(l.pageIndex)
const cw = l.width_mm * s
const ch = l.height_mm * s
if (mouseX >= cx && mouseX <= cx + cw && mouseY >= cy && mouseY <= cy + ch) {
// Daha küçük (daha derin) container'ı tercih et
const bestL = props.layoutMap[best.id]
if (!bestL || cw * ch < bestL.width_mm * s * bestL.height_mm * s) {
best = c
}
}
}
return best
}
/** Container içinde drop index hesapla */
function computeDropIndex(
container: ContainerElement,
mouseX: number,
mouseY: number,
excludeId?: string,
) {
const s = props.scale
const flowChildren = container.children.filter(
(c) => c.type !== 'page_break' && c.position.type !== 'absolute' && c.id !== excludeId,
)
const isRow = container.direction === 'row'
let visualIdx = flowChildren.length
for (let i = 0; i < flowChildren.length; i++) {
const l = props.layoutMap[flowChildren[i].id]
if (!l) continue
if (isRow) {
const centerX = l.x_mm * s + (l.width_mm * s) / 2
if (mouseX < centerX) {
visualIdx = i
break
}
} else {
const centerY = l.y_mm * s + pageYOffset(l.pageIndex) + (l.height_mm * 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.type !== 'page_break' && 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, mouseX, 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 = props.scale
const idx = dropVisualIndex.value
const isRow = container.direction === 'row'
// Sürüklenen elemanı çıkar
const dragId = dragElementId.value
const flowChildren = container.children.filter(
(c) => c.type !== 'page_break' && c.position.type !== 'absolute' && c.id !== dragId,
)
const cl = props.layoutMap[container.id]
if (!cl) return { display: 'none' }
if (isRow) {
// Row container: dikey gösterge çizgisi
let x = 0
if (idx === 0 && flowChildren.length > 0) {
const l = props.layoutMap[flowChildren[0].id]
if (l) x = (cl.x_mm * s + l.x_mm * s) / 2
else x = cl.x_mm * s
} else if (idx < flowChildren.length && idx > 0) {
const left = props.layoutMap[flowChildren[idx - 1].id]
const right = props.layoutMap[flowChildren[idx].id]
if (left && right) {
const leftEnd = (left.x_mm + left.width_mm) * s
const rightStart = right.x_mm * s
x = (leftEnd + rightStart) / 2
}
} else if (idx === 0 && flowChildren.length === 0) {
x = cl.x_mm * s + 8
} else if (flowChildren.length > 0) {
const last = flowChildren[flowChildren.length - 1]
const l = props.layoutMap[last.id]
if (l) {
const gapPx = container.gap * props.scale
x = (l.x_mm + l.width_mm) * s + gapPx / 2
}
}
const clPageOff = pageYOffset(cl.pageIndex)
const top = cl.y_mm * s + clPageOff
const height = cl.height_mm * s
return {
position: 'absolute' as const,
left: `${x}px`,
top: `${top}px`,
width: '2px',
height: `${height}px`,
background: 'rgb(59, 130, 246)',
borderRadius: '1px',
zIndex: 1000,
pointerEvents: 'none' as const,
}
}
// Column container: yatay gösterge çizgisi
const colPageOff = pageYOffset(cl.pageIndex)
let y = 0
if (idx === 0 && flowChildren.length > 0) {
const l = props.layoutMap[flowChildren[0].id]
if (l) {
y = (cl.y_mm * s + colPageOff + l.y_mm * s + pageYOffset(l.pageIndex)) / 2
} else {
y = cl.y_mm * s + colPageOff - 4
}
} else if (idx < flowChildren.length && idx > 0) {
const above = props.layoutMap[flowChildren[idx - 1].id]
const below = props.layoutMap[flowChildren[idx].id]
if (above && below) {
const aboveBottom = (above.y_mm + above.height_mm) * s + pageYOffset(above.pageIndex)
const belowTop = below.y_mm * s + pageYOffset(below.pageIndex)
y = (aboveBottom + belowTop) / 2
}
} else if (idx === 0 && flowChildren.length === 0) {
y = cl.y_mm * s + colPageOff + 8
} else if (flowChildren.length > 0) {
const last = flowChildren[flowChildren.length - 1]
const l = props.layoutMap[last.id]
if (l) {
const gapPx = container.gap * props.scale
y = (l.y_mm + l.height_mm) * s + pageYOffset(l.pageIndex) + gapPx / 2
}
}
const x = cl.x_mm * s
const width = cl.width_mm * s
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.type === 'page_break') return
if (el.position.type === 'absolute') {
onAbsoluteDragStart(e, el)
return
}
const l = props.layoutMap[el.id]
if (!l) return
const s = props.scale
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_mm * s,
y: l.y_mm * s,
width: l.width_mm * s,
height: l.height_mm * 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,
}
collectEdges(
props.layoutMap,
el.id,
templateStore.template.page.width,
templateStore.template.page.height,
)
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 proposedX = Math.max(0, absoluteDragStart.value.elX + dx * pxToMm)
const proposedY = Math.max(0, absoluteDragStart.value.elY + dy * pxToMm)
const layout = props.layoutMap[absoluteDragId.value]
const elW = layout ? layout.width_mm : 0
const elH = layout ? layout.height_mm : 0
const snap = calculateSnap(proposedX, proposedY, elW, elH)
const newX = snap.snappedX_mm
const newY = snap.snappedY_mm
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)
clearGuides()
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 })
const resizeAspectRatio = ref(0) // > 0 ise aspect ratio korunur (width / height)
function onResizeStart(e: PointerEvent, elId: string, handle: string) {
e.stopPropagation()
e.preventDefault()
const l = props.layoutMap[elId]
if (!l) return
resizeElementId.value = elId
resizeHandle.value = handle
isResizing.value = true
const s = props.scale
// Barkod ve görsel elemanları için aspect ratio'yu kaydet
const el = flatElements.value.find((e) => e.id === elId)
resizeAspectRatio.value =
(el?.type === 'barcode' || el?.type === 'image') && l.height_mm > 0
? l.width_mm / l.height_mm
: 0
resizeStart.value = {
mouseX: e.clientX,
mouseY: e.clientY,
x: l.x_mm * s,
y: l.y_mm * s,
width: l.width_mm * s,
height: l.height_mm * s,
}
resizeGhost.value = {
x: l.x_mm * s,
y: l.y_mm * s,
width: l.width_mm * s,
height: l.height_mm * s,
}
resizeFinalMm.value = { width: l.width_mm, height: l.height_mm }
collectEdges(
props.layoutMap,
elId,
templateStore.template.page.width,
templateStore.template.page.height,
)
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
const ar = resizeAspectRatio.value
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
}
// Aspect ratio koruma (barkod)
if (ar > 0) {
gh = gw / ar
}
resizeGhost.value = { x: gx, y: gy, width: gw, height: gh }
const startWMm = resizeStart.value.width * pxToMm
const startHMm = resizeStart.value.height * pxToMm
const startXMm = resizeStart.value.x * pxToMm
const startYMm = resizeStart.value.y * pxToMm
let wMm = startWMm,
hMm = startHMm
if (handle.includes('e')) {
const rightEdge = calculateResizeSnap('right', startXMm + startWMm + dx * pxToMm)
wMm = Math.max(5, rightEdge - startXMm)
}
if (handle.includes('w')) {
const leftEdge = calculateResizeSnap('left', startXMm + dx * pxToMm)
wMm = Math.max(5, startXMm + startWMm - leftEdge)
}
if (handle.includes('s')) {
const bottomEdge = calculateResizeSnap('bottom', startYMm + startHMm + dy * pxToMm)
hMm = Math.max(3, bottomEdge - startYMm)
}
if (handle.includes('n')) {
const topEdge = calculateResizeSnap('top', startYMm + dy * pxToMm)
hMm = Math.max(3, startYMm + startHMm - topEdge)
}
if (ar > 0) {
hMm = wMm / ar
}
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 ar = resizeAspectRatio.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)
// Aspect ratio aktifken her zaman hem width hem height güncelle
if (ar > 0) {
sizeUpdate.width = sz.fixed(resizeFinalMm.value.width)
sizeUpdate.height = sz.fixed(resizeFinalMm.value.height)
}
templateStore.updateElementSize(resizeElementId.value, sizeUpdate)
}
isResizing.value = false
resizeElementId.value = null
resizeHandle.value = ''
clearGuides()
}
// ============================================================
// 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.isSelected(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.isSelected(el.id)" class="selection-border" />
<!-- Resize handles (sadece tek seçimde) -->
<template
v-if="
editorStore.isSelected(el.id) &&
editorStore.selectedElementIds.size === 1 &&
!isResizing &&
el.type !== 'page_break'
"
>
<template v-if="el.type === 'barcode' || el.type === 'image'">
<!-- Barkod/Görsel: sadece yatay resize (aspect ratio korunur) -->
<div
class="resize-handle resize-handle--e"
@pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'e')"
/>
<div
class="resize-handle resize-handle--w"
@pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'w')"
/>
</template>
<template v-else>
<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>
</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" />
<!-- Snap guides -->
<div
v-for="(guide, gi) in activeGuides"
:key="'guide-' + gi"
class="snap-guide"
:style="{
position: 'absolute',
...(guide.type === 'vertical'
? { left: `${guide.position_mm * scale}px`, top: '0', bottom: '0', width: '1px' }
: { top: `${guide.position_mm * scale}px`, left: '0', right: '0', height: '1px' }),
background: '#3b82f6',
opacity: 0.7,
pointerEvents: 'none',
zIndex: 9999,
}"
/>
<!-- Element toolbar — seçili elemanın üstünde -->
<ElementToolbar
v-if="!isDragging && !isResizing"
:scale="scale"
:layout-map="layoutMap"
:page-height-px="pageHeightPx"
/>
</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;
}
.resize-handle--e {
right: -3px;
top: calc(50% - 3px);
cursor: e-resize;
}
.resize-handle--w {
left: -3px;
top: calc(50% - 3px);
cursor: w-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>