mirror of
https://github.com/duhanbalci/dreport.git
synced 2026-07-01 18:39:16 +00:00
948 lines
27 KiB
Vue
948 lines
27 KiB
Vue
<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>
|