mirror of
https://github.com/duhanbalci/dreport.git
synced 2026-07-02 02:49:16 +00:00
fix bugs
This commit is contained in:
BIN
frontend/public/wasm/dreport_layout_bg.wasm
Normal file
BIN
frontend/public/wasm/dreport_layout_bg.wasm
Normal file
Binary file not shown.
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { computed, ref, watch, provide, 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 { useLayoutEngine } from '../../composables/useLayoutEngine'
|
||||
import LayoutRenderer from './LayoutRenderer.vue'
|
||||
import InteractionOverlay from './InteractionOverlay.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
@@ -15,7 +15,7 @@ const props = withDefaults(defineProps<{
|
||||
|
||||
const templateStore = useTemplateStore()
|
||||
const editorStore = useEditorStore()
|
||||
const { template, mockData } = storeToRefs(templateStore)
|
||||
const { template, mockData, layoutVersion } = storeToRefs(templateStore)
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const containerWidth = ref(800)
|
||||
@@ -24,8 +24,11 @@ const emit = defineEmits<{
|
||||
'compile-error': [error: string | null]
|
||||
}>()
|
||||
|
||||
// Typst compiler — template + data'yı worker'a gönderir, WASM ile derlenir
|
||||
const { svg, error, compiling, layout, dispose } = useTypstCompiler(template, mockData)
|
||||
// Layout engine — template + data'yı worker'a gönderir, WASM ile layout hesaplar
|
||||
const { layout, layoutMap, error, computing: compiling, generateBarcode, dispose } = useLayoutEngine(template, mockData, layoutVersion)
|
||||
|
||||
// LayoutRenderer'ın barcode üretmek için kullanabileceği fonksiyon
|
||||
provide('generateBarcode', generateBarcode)
|
||||
|
||||
watch(error, (val) => emit('compile-error', val))
|
||||
|
||||
@@ -89,15 +92,76 @@ onBeforeUnmount(() => {
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
})
|
||||
|
||||
// Zoom
|
||||
// Zoom & Pan via wheel/trackpad
|
||||
const pageRef = ref<HTMLElement | null>(null)
|
||||
|
||||
let zoomRAF: number | null = null
|
||||
let zoomDeltaAccum = 0
|
||||
let zoomClientX = 0
|
||||
let zoomClientY = 0
|
||||
|
||||
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)
|
||||
|
||||
zoomDeltaAccum += e.deltaY
|
||||
zoomClientX = e.clientX
|
||||
zoomClientY = e.clientY
|
||||
|
||||
if (zoomRAF === null) {
|
||||
zoomRAF = requestAnimationFrame(() => {
|
||||
const delta = Math.max(-4, Math.min(4, zoomDeltaAccum))
|
||||
if (Math.abs(delta) > 0.01) {
|
||||
applyZoom(delta, zoomClientX, zoomClientY)
|
||||
}
|
||||
zoomDeltaAccum = 0
|
||||
zoomRAF = null
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// İki parmak pan (touchpad) veya normal scroll
|
||||
e.preventDefault()
|
||||
editorStore.setPan(
|
||||
editorStore.panX - e.deltaX,
|
||||
editorStore.panY - e.deltaY,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function applyZoom(delta: number, clientX: number, clientY: number) {
|
||||
const pageEl = pageRef.value
|
||||
if (!pageEl) return
|
||||
|
||||
const oldZoom = editorStore.zoom
|
||||
const zoomFactor = Math.pow(0.99, delta)
|
||||
const newZoom = Math.max(0.25, Math.min(4, oldZoom * zoomFactor))
|
||||
if (newZoom === oldZoom) return
|
||||
|
||||
// Sayfa elemanının şu anki ekran pozisyonunu al (centering + pan dahil)
|
||||
const pageRect = pageEl.getBoundingClientRect()
|
||||
|
||||
// Mouse'un sayfa üzerindeki pozisyonu (mm cinsinden)
|
||||
const baseScale = containerWidth.value / templateStore.template.page.width
|
||||
const oldScale = baseScale * oldZoom
|
||||
const newScale = baseScale * newZoom
|
||||
const mousePageMmX = (clientX - pageRect.left) / oldScale
|
||||
const mousePageMmY = (clientY - pageRect.top) / oldScale
|
||||
|
||||
// Flex centering kayması: sayfa genişliği değişince ortalama kayar
|
||||
// X ekseni: justify-content: center → kayma = (eskiBoyut - yeniBoyut) / 2
|
||||
const pageW = templateStore.template.page.width
|
||||
const centerShiftX = pageW * (oldScale - newScale) / 2
|
||||
// Y ekseni: align-items: flex-start → kayma yok
|
||||
const centerShiftY = 0
|
||||
|
||||
// Yeni pan: mouse'un gösterdiği mm noktası aynı ekran pozisyonunda kalmalı
|
||||
const newPanX = editorStore.panX + (mousePageMmX - pageW / 2) * (oldScale - newScale)
|
||||
const newPanY = editorStore.panY + mousePageMmY * (oldScale - newScale)
|
||||
|
||||
editorStore.setZoom(newZoom)
|
||||
editorStore.setPan(newPanX, newPanY)
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.code === 'Space' && !e.repeat && !(e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement || e.target instanceof HTMLTextAreaElement)) {
|
||||
e.preventDefault()
|
||||
@@ -146,9 +210,9 @@ function onPointerUp(e: PointerEvent) {
|
||||
@pointerup="onPointerUp"
|
||||
>
|
||||
<!-- Sayfa -->
|
||||
<div class="editor-canvas__page" :style="[pageStyle, panTransform ? { transform: panTransform } : {}]">
|
||||
<TypstSvgLayer :svg="svg" />
|
||||
<InteractionOverlay :scale="scale" :layout="layout" :page-width-pt="templateStore.template.page.width * 2.8346" />
|
||||
<div ref="pageRef" class="editor-canvas__page" :style="[pageStyle, panTransform ? { transform: panTransform } : {}]">
|
||||
<LayoutRenderer :layout="layout" :scale="scale" />
|
||||
<InteractionOverlay :scale="scale" :layout-map="layoutMap" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -170,12 +234,14 @@ function onPointerUp(e: PointerEvent) {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
overflow: hidden;
|
||||
background: #e5e7eb;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -2,25 +2,18 @@
|
||||
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 { ElementLayout } from '../../core/layout-types'
|
||||
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
|
||||
layoutMap: Record<string, ElementLayout>
|
||||
}>()
|
||||
|
||||
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[] = []
|
||||
@@ -50,20 +43,20 @@ const allContainers = computed(() => {
|
||||
})
|
||||
|
||||
function getElementStyle(el: TemplateElement) {
|
||||
const l = props.layout[el.id]
|
||||
const l = props.layoutMap[el.id]
|
||||
if (!l) return { display: 'none' }
|
||||
|
||||
const s = ptToPx.value
|
||||
const h = l.height * s
|
||||
const s = props.scale
|
||||
const h = l.height_mm * 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`,
|
||||
left: `${l.x_mm * s}px`,
|
||||
top: `${l.y_mm * s - yOffset}px`,
|
||||
width: `${l.width_mm * s}px`,
|
||||
height: `${actualH}px`,
|
||||
}
|
||||
}
|
||||
@@ -90,23 +83,23 @@ 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
|
||||
const s = props.scale
|
||||
let best: ContainerElement = templateStore.template.root
|
||||
|
||||
for (const c of allContainers.value) {
|
||||
if (c.id === excludeId) continue
|
||||
const l = props.layout[c.id]
|
||||
const l = props.layoutMap[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
|
||||
const cx = l.x_mm * s
|
||||
const cy = l.y_mm * s
|
||||
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.layout[best.id]
|
||||
if (!bestL || (cw * ch < bestL.width * s * bestL.height * s)) {
|
||||
const bestL = props.layoutMap[best.id]
|
||||
if (!bestL || (cw * ch < bestL.width_mm * s * bestL.height_mm * s)) {
|
||||
best = c
|
||||
}
|
||||
}
|
||||
@@ -116,20 +109,20 @@ function findDeepestContainer(mouseX: number, mouseY: number, excludeId?: string
|
||||
|
||||
/** Container içinde drop index hesapla */
|
||||
function computeDropIndex(container: ContainerElement, mouseX: number, mouseY: number, excludeId?: string) {
|
||||
const s = ptToPx.value
|
||||
const s = props.scale
|
||||
const flowChildren = container.children.filter(c => 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.layout[flowChildren[i].id]
|
||||
const l = props.layoutMap[flowChildren[i].id]
|
||||
if (!l) continue
|
||||
if (isRow) {
|
||||
const centerX = l.x * s + (l.width * s) / 2
|
||||
const centerX = l.x_mm * s + (l.width_mm * s) / 2
|
||||
if (mouseX < centerX) { visualIdx = i; break }
|
||||
} else {
|
||||
const centerY = l.y * s + (l.height * s) / 2
|
||||
const centerY = l.y_mm * s + (l.height_mm * s) / 2
|
||||
if (mouseY < centerY) { visualIdx = i; break }
|
||||
}
|
||||
}
|
||||
@@ -184,7 +177,7 @@ const dropIndicatorStyle = computed(() => {
|
||||
const container = templateStore.getElementById(dropTargetContainerId.value)
|
||||
if (!container || !isContainer(container)) return { display: 'none' }
|
||||
|
||||
const s = ptToPx.value
|
||||
const s = props.scale
|
||||
const idx = dropVisualIndex.value
|
||||
const isRow = container.direction === 'row'
|
||||
|
||||
@@ -192,37 +185,37 @@ const dropIndicatorStyle = computed(() => {
|
||||
const dragId = dragElementId.value
|
||||
const flowChildren = container.children.filter(c => c.position.type !== 'absolute' && c.id !== dragId)
|
||||
|
||||
const cl = props.layout[container.id]
|
||||
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.layout[flowChildren[0].id]
|
||||
if (l) x = (cl.x * s + l.x * s) / 2
|
||||
else x = cl.x * s
|
||||
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.layout[flowChildren[idx - 1].id]
|
||||
const right = props.layout[flowChildren[idx].id]
|
||||
const left = props.layoutMap[flowChildren[idx - 1].id]
|
||||
const right = props.layoutMap[flowChildren[idx].id]
|
||||
if (left && right) {
|
||||
const leftEnd = (left.x + left.width) * s
|
||||
const rightStart = right.x * s
|
||||
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 * s + 8
|
||||
x = cl.x_mm * s + 8
|
||||
} else if (flowChildren.length > 0) {
|
||||
const last = flowChildren[flowChildren.length - 1]
|
||||
const l = props.layout[last.id]
|
||||
const l = props.layoutMap[last.id]
|
||||
if (l) {
|
||||
const gapPx = container.gap * props.scale
|
||||
x = (l.x + l.width) * s + gapPx / 2
|
||||
x = (l.x_mm + l.width_mm) * s + gapPx / 2
|
||||
}
|
||||
}
|
||||
|
||||
const top = cl.y * s
|
||||
const height = cl.height * s
|
||||
const top = cl.y_mm * s
|
||||
const height = cl.height_mm * s
|
||||
|
||||
return {
|
||||
position: 'absolute' as const,
|
||||
@@ -240,33 +233,33 @@ const dropIndicatorStyle = computed(() => {
|
||||
// Column container: yatay gösterge çizgisi
|
||||
let y = 0
|
||||
if (idx === 0 && flowChildren.length > 0) {
|
||||
const l = props.layout[flowChildren[0].id]
|
||||
const l = props.layoutMap[flowChildren[0].id]
|
||||
if (l) {
|
||||
y = (cl.y * s + l.y * s) / 2
|
||||
y = (cl.y_mm * s + l.y_mm * s) / 2
|
||||
} else {
|
||||
y = cl.y * s - 4
|
||||
y = cl.y_mm * s - 4
|
||||
}
|
||||
} else if (idx < flowChildren.length && idx > 0) {
|
||||
const above = props.layout[flowChildren[idx - 1].id]
|
||||
const below = props.layout[flowChildren[idx].id]
|
||||
const above = props.layoutMap[flowChildren[idx - 1].id]
|
||||
const below = props.layoutMap[flowChildren[idx].id]
|
||||
if (above && below) {
|
||||
const aboveBottom = (above.y + above.height) * s
|
||||
const belowTop = below.y * s
|
||||
const aboveBottom = (above.y_mm + above.height_mm) * s
|
||||
const belowTop = below.y_mm * s
|
||||
y = (aboveBottom + belowTop) / 2
|
||||
}
|
||||
} else if (idx === 0 && flowChildren.length === 0) {
|
||||
y = cl.y * s + 8
|
||||
y = cl.y_mm * s + 8
|
||||
} else if (flowChildren.length > 0) {
|
||||
const last = flowChildren[flowChildren.length - 1]
|
||||
const l = props.layout[last.id]
|
||||
const l = props.layoutMap[last.id]
|
||||
if (l) {
|
||||
const gapPx = container.gap * props.scale
|
||||
y = (l.y + l.height) * s + gapPx / 2
|
||||
y = (l.y_mm + l.height_mm) * s + gapPx / 2
|
||||
}
|
||||
}
|
||||
|
||||
const x = cl.x * s
|
||||
const width = cl.width * s
|
||||
const x = cl.x_mm * s
|
||||
const width = cl.width_mm * s
|
||||
|
||||
return {
|
||||
position: 'absolute' as const,
|
||||
@@ -297,20 +290,20 @@ function onDragStart(e: PointerEvent, el: TemplateElement) {
|
||||
return
|
||||
}
|
||||
|
||||
const l = props.layout[el.id]
|
||||
const l = props.layoutMap[el.id]
|
||||
if (!l) return
|
||||
|
||||
const s = ptToPx.value
|
||||
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 * s,
|
||||
y: l.y * s,
|
||||
width: l.width * s,
|
||||
height: l.height * s,
|
||||
x: l.x_mm * s,
|
||||
y: l.y_mm * s,
|
||||
width: l.width_mm * s,
|
||||
height: l.height_mm * s,
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onDragMove)
|
||||
@@ -440,27 +433,26 @@ function onResizeStart(e: PointerEvent, elId: string, handle: string) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
|
||||
const l = props.layout[elId]
|
||||
const l = props.layoutMap[elId]
|
||||
if (!l) return
|
||||
|
||||
resizeElementId.value = elId
|
||||
resizeHandle.value = handle
|
||||
isResizing.value = true
|
||||
|
||||
const s = ptToPx.value
|
||||
const ptToMm = 1 / 2.8346
|
||||
const s = props.scale
|
||||
|
||||
// Barkod elemanları için aspect ratio'yu kaydet
|
||||
// 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' && l.height > 0) ? l.width / l.height : 0
|
||||
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 * s, y: l.y * s,
|
||||
width: l.width * s, height: l.height * s,
|
||||
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 * s, y: l.y * s, width: l.width * s, height: l.height * s }
|
||||
resizeFinalMm.value = { width: l.width * ptToMm, height: l.height * ptToMm }
|
||||
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 }
|
||||
|
||||
window.addEventListener('pointermove', onResizeMove)
|
||||
window.addEventListener('pointerup', onResizeEnd)
|
||||
@@ -511,9 +503,15 @@ function 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)
|
||||
}
|
||||
|
||||
@@ -589,8 +587,8 @@ const isAnyDragActive = computed(() =>
|
||||
|
||||
<!-- Resize handles -->
|
||||
<template v-if="editorStore.selectedElementId === el.id && !isResizing">
|
||||
<template v-if="el.type === 'barcode'">
|
||||
<!-- Barkod: sadece yatay resize (aspect ratio korunur) -->
|
||||
<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>
|
||||
|
||||
262
frontend/src/components/editor/LayoutRenderer.vue
Normal file
262
frontend/src/components/editor/LayoutRenderer.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, watch, nextTick } from 'vue'
|
||||
import type { ElementLayout, LayoutResult } from '../../core/layout-types'
|
||||
|
||||
const props = defineProps<{
|
||||
layout: LayoutResult | null
|
||||
scale: number
|
||||
}>()
|
||||
|
||||
// WASM barcode üretme fonksiyonu (EditorCanvas'tan provide edilir)
|
||||
const generateBarcode = inject<(format: string, value: string, width: number, height: number) => Promise<{ width: number; height: number; rgba: ArrayBuffer } | null>>('generateBarcode')
|
||||
|
||||
const pageElements = computed(() => {
|
||||
if (!props.layout || props.layout.pages.length === 0) return []
|
||||
return props.layout.pages[0].elements
|
||||
})
|
||||
|
||||
function elStyle(el: ElementLayout): Record<string, string> {
|
||||
const s = props.scale
|
||||
return {
|
||||
position: 'absolute',
|
||||
left: `${el.x_mm * s}px`,
|
||||
top: `${el.y_mm * s}px`,
|
||||
width: `${el.width_mm * s}px`,
|
||||
height: `${el.height_mm * s}px`,
|
||||
}
|
||||
}
|
||||
|
||||
function textStyle(el: ElementLayout): Record<string, string> {
|
||||
const s = props.scale
|
||||
const st = el.style
|
||||
const result: Record<string, string> = {}
|
||||
|
||||
// fontSize pt cinsinden → mm'ye çevir (1pt = 0.3528mm), sonra scale ile px'e
|
||||
if (st.fontSize) result.fontSize = `${st.fontSize * 0.3528 * s}px`
|
||||
if (st.fontWeight) result.fontWeight = st.fontWeight
|
||||
if (st.fontFamily) result.fontFamily = st.fontFamily
|
||||
if (st.color) result.color = st.color
|
||||
if (st.textAlign) result.textAlign = st.textAlign
|
||||
|
||||
result.lineHeight = '1.2'
|
||||
result.overflow = 'hidden'
|
||||
result.wordBreak = 'break-word'
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function containerStyle(el: ElementLayout): Record<string, string> {
|
||||
const st = el.style
|
||||
const result: Record<string, string> = {}
|
||||
|
||||
if (st.backgroundColor) result.backgroundColor = st.backgroundColor
|
||||
if (st.borderColor && st.borderWidth) {
|
||||
result.border = `${st.borderWidth * props.scale}px ${st.borderStyle ?? 'solid'} ${st.borderColor}`
|
||||
}
|
||||
if (st.borderRadius) result.borderRadius = `${st.borderRadius * props.scale}px`
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function lineStyle(el: ElementLayout): Record<string, string> {
|
||||
const st = el.style
|
||||
return {
|
||||
borderTop: `${(st.strokeWidth ?? 0.5) * props.scale}px solid ${st.strokeColor ?? '#000'}`,
|
||||
width: '100%',
|
||||
height: '0',
|
||||
}
|
||||
}
|
||||
|
||||
// --- Barcode rendering (WASM ile) ---
|
||||
|
||||
async function renderBarcodeToCanvas(canvas: HTMLCanvasElement, format: string, value: string, includeText: boolean = false) {
|
||||
if (!value || !generateBarcode) return
|
||||
|
||||
try {
|
||||
// WASM'dan yüksek çözünürlüklü pixel verisi al
|
||||
// QR her zaman kare
|
||||
const isQr = format === 'qr'
|
||||
const size = isQr ? 300 : 400
|
||||
const height = isQr ? 300 : 150
|
||||
const result = await generateBarcode(format, value, size, height, isQr ? false : includeText)
|
||||
if (!result) return
|
||||
|
||||
// Canvas boyutlarını WASM çıktısına ayarla (crisp rendering)
|
||||
canvas.width = result.width
|
||||
canvas.height = result.height
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const imageData = new ImageData(
|
||||
new Uint8ClampedArray(result.rgba),
|
||||
result.width,
|
||||
result.height,
|
||||
)
|
||||
ctx.putImageData(imageData, 0, 0)
|
||||
} catch (e) {
|
||||
console.warn(`[dreport] WASM barcode render hatası (${format}):`, e)
|
||||
renderBarcodeFallback(canvas, format)
|
||||
}
|
||||
}
|
||||
|
||||
function renderBarcodeFallback(canvas: HTMLCanvasElement, format: string) {
|
||||
canvas.width = 200
|
||||
canvas.height = 80
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
ctx.fillStyle = '#f3f4f6'
|
||||
ctx.fillRect(0, 0, 200, 80)
|
||||
ctx.fillStyle = '#ef4444'
|
||||
ctx.font = '11px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(`[${format}] hata`, 100, 44)
|
||||
}
|
||||
|
||||
/** Canvas mount olduğunda render et */
|
||||
function onBarcodeCanvasMounted(el: HTMLCanvasElement | null) {
|
||||
if (!el) return
|
||||
const format = el.dataset.format
|
||||
const value = el.dataset.value
|
||||
const includeText = el.dataset.includeText === 'true'
|
||||
if (format && value) {
|
||||
renderBarcodeToCanvas(el, format, value, includeText)
|
||||
}
|
||||
}
|
||||
|
||||
// Layout değiştiğinde tüm barcode canvas'ları yeniden render et
|
||||
watch(
|
||||
() => props.layout,
|
||||
async () => {
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
const canvases = document.querySelectorAll<HTMLCanvasElement>('canvas[data-barcode]')
|
||||
canvases.forEach(canvas => {
|
||||
const format = canvas.dataset.format
|
||||
const value = canvas.dataset.value
|
||||
const includeText = canvas.dataset.includeText === 'true'
|
||||
if (format && value) {
|
||||
renderBarcodeToCanvas(canvas, format, value, includeText)
|
||||
}
|
||||
})
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout-renderer" v-if="layout">
|
||||
<template v-for="el in pageElements" :key="el.id">
|
||||
<!-- Container -->
|
||||
<div
|
||||
v-if="el.element_type === 'container'"
|
||||
class="layout-el layout-el--container"
|
||||
:style="{ ...elStyle(el), ...containerStyle(el) }"
|
||||
/>
|
||||
|
||||
<!-- Static text / Text / Page number -->
|
||||
<div
|
||||
v-else-if="el.element_type === 'static_text' || el.element_type === 'text' || el.element_type === 'page_number'"
|
||||
class="layout-el layout-el--text"
|
||||
:style="{ ...elStyle(el), ...textStyle(el) }"
|
||||
>
|
||||
{{ el.content?.type === 'text' ? el.content.value : '' }}
|
||||
</div>
|
||||
|
||||
<!-- Line -->
|
||||
<div
|
||||
v-else-if="el.element_type === 'line'"
|
||||
class="layout-el layout-el--line"
|
||||
:style="elStyle(el)"
|
||||
>
|
||||
<div :style="lineStyle(el)" />
|
||||
</div>
|
||||
|
||||
<!-- Image -->
|
||||
<div
|
||||
v-else-if="el.element_type === 'image'"
|
||||
class="layout-el layout-el--image"
|
||||
:style="elStyle(el)"
|
||||
>
|
||||
<img
|
||||
v-if="el.content?.type === 'image' && el.content.src"
|
||||
:src="el.content.src"
|
||||
:style="{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'fill',
|
||||
}"
|
||||
/>
|
||||
<div v-else class="layout-el__placeholder">Görsel</div>
|
||||
</div>
|
||||
|
||||
<!-- Barcode -->
|
||||
<div
|
||||
v-else-if="el.element_type === 'barcode'"
|
||||
class="layout-el layout-el--barcode"
|
||||
:style="elStyle(el)"
|
||||
>
|
||||
<canvas
|
||||
v-if="el.content?.type === 'barcode' && el.content.value"
|
||||
:ref="(ref) => onBarcodeCanvasMounted(ref as HTMLCanvasElement)"
|
||||
data-barcode
|
||||
:data-format="el.content.format"
|
||||
:data-value="el.content.value"
|
||||
:data-include-text="el.style.barcodeIncludeText ?? (el.content.format === 'ean13' || el.content.format === 'ean8')"
|
||||
:style="{ width: '100%', height: '100%', display: 'block' }"
|
||||
/>
|
||||
<div v-else class="layout-el__placeholder">
|
||||
{{ el.content?.type === 'barcode' ? `[${el.content.format}]` : '[barcode]' }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="layout-renderer layout-renderer--empty" v-else>
|
||||
<span>Hesaplanıyor...</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layout-renderer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.layout-renderer--empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.layout-el {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.layout-el--text {
|
||||
white-space: pre-wrap;
|
||||
font-family: 'Noto Sans', sans-serif;
|
||||
}
|
||||
|
||||
.layout-el--line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.layout-el__placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f3f4f6;
|
||||
color: #9ca3af;
|
||||
font-size: 11px;
|
||||
border: 1px dashed #d1d5db;
|
||||
border-radius: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -397,8 +397,8 @@ function deleteElement() {
|
||||
<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"
|
||||
<label class="prop-label">Kalınlık (mm)</label>
|
||||
<input class="prop-input" type="number" step="0.1" min="0.1"
|
||||
:value="(selectedElement as LineElement).style.strokeWidth ?? 0.5"
|
||||
@input="(e) => updateStyle('strokeWidth', parseFloat((e.target as HTMLInputElement).value) || 0.5)" />
|
||||
</div>
|
||||
@@ -509,6 +509,12 @@ function deleteElement() {
|
||||
<button v-if="(selectedElement as BarcodeElement).style.color" class="prop-clear" @click="updateStyle('color', undefined)">x</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="(selectedElement as BarcodeElement).format !== 'qr'" class="prop-row">
|
||||
<label class="prop-label">Metin Goster</label>
|
||||
<input type="checkbox"
|
||||
:checked="(selectedElement as BarcodeElement).style.includeText ?? ((selectedElement as BarcodeElement).format === 'ean13' || (selectedElement as BarcodeElement).format === 'ean8')"
|
||||
@change="(e) => updateStyle('includeText', (e.target as HTMLInputElement).checked)" />
|
||||
</div>
|
||||
<div v-if="schemaStore.scalarFields.length > 0" class="prop-row">
|
||||
<label class="prop-label">Veri Baglama</label>
|
||||
<select class="prop-input prop-select"
|
||||
@@ -613,8 +619,8 @@ function deleteElement() {
|
||||
</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"
|
||||
<label class="prop-label">Kenarlık (mm)</label>
|
||||
<input class="prop-input" type="number" step="0.1" min="0"
|
||||
:value="(selectedElement as ContainerElement).style.borderWidth ?? 0"
|
||||
@input="(e) => updateStyle('borderWidth', parseFloat((e.target as HTMLInputElement).value) || 0)" />
|
||||
</div>
|
||||
@@ -638,8 +644,8 @@ function deleteElement() {
|
||||
</select>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Radius (pt)</label>
|
||||
<input class="prop-input" type="number" step="1" min="0"
|
||||
<label class="prop-label">Radius (mm)</label>
|
||||
<input class="prop-input" type="number" step="0.5" min="0"
|
||||
:value="(selectedElement as ContainerElement).style.borderRadius ?? 0"
|
||||
@input="(e) => updateStyle('borderRadius', parseFloat((e.target as HTMLInputElement).value) || 0)" />
|
||||
</div>
|
||||
@@ -791,8 +797,8 @@ function deleteElement() {
|
||||
</div>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Kenarlık (pt)</label>
|
||||
<input class="prop-input" type="number" step="0.25" min="0"
|
||||
<label class="prop-label">Kenarlık (mm)</label>
|
||||
<input class="prop-input" type="number" step="0.1" min="0"
|
||||
:value="(selectedElement as RepeatingTableElement).style.borderWidth ?? 0.5"
|
||||
@input="(e) => updateTableStyle('borderWidth', parseFloat((e.target as HTMLInputElement).value) || 0)" />
|
||||
</div>
|
||||
|
||||
137
frontend/src/composables/useLayoutEngine.ts
Normal file
137
frontend/src/composables/useLayoutEngine.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { ref, watch, type Ref } from 'vue'
|
||||
import type { Template } from '../core/types'
|
||||
import type { LayoutResult, ElementLayout } from '../core/layout-types'
|
||||
|
||||
export type { ElementLayout }
|
||||
|
||||
export function useLayoutEngine(
|
||||
template: Ref<Template>,
|
||||
data: Ref<Record<string, unknown>>,
|
||||
layoutVersion?: Ref<number>,
|
||||
) {
|
||||
const layout = ref<LayoutResult | null>(null)
|
||||
const error = ref<string | null>(null)
|
||||
const computing = ref(false)
|
||||
|
||||
// Uyumluluk: InteractionOverlay'ın beklediği flat layout map (id → ElementLayout)
|
||||
const layoutMap = ref<Record<string, ElementLayout>>({})
|
||||
|
||||
let worker: Worker | null = null
|
||||
let requestId = 0
|
||||
|
||||
function initWorker() {
|
||||
worker = new Worker(new URL('../workers/layout.worker.ts', import.meta.url), {
|
||||
type: 'module',
|
||||
})
|
||||
|
||||
worker.onmessage = (e: MessageEvent<any>) => {
|
||||
const msg = e.data
|
||||
|
||||
// Barcode yanıtları
|
||||
if (msg.type === 'barcode-result' || msg.type === 'barcode-error') {
|
||||
handleBarcodeResponse(msg)
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.id !== requestId) return
|
||||
|
||||
computing.value = false
|
||||
if (msg.type === 'result' && msg.layout) {
|
||||
layout.value = msg.layout
|
||||
error.value = null
|
||||
|
||||
// Flat map oluştur: id → ElementLayout
|
||||
const map: Record<string, ElementLayout> = {}
|
||||
for (const page of msg.layout.pages) {
|
||||
for (const el of page.elements) {
|
||||
map[el.id] = el
|
||||
}
|
||||
}
|
||||
layoutMap.value = map
|
||||
} else if (msg.type === 'error') {
|
||||
error.value = msg.error ?? 'Bilinmeyen layout hatası'
|
||||
}
|
||||
}
|
||||
|
||||
worker.onerror = () => {
|
||||
computing.value = false
|
||||
error.value = 'Worker hatası — yeniden başlatılıyor'
|
||||
worker?.terminate()
|
||||
worker = null
|
||||
setTimeout(initWorker, 500)
|
||||
}
|
||||
}
|
||||
|
||||
function compute() {
|
||||
if (!worker) initWorker()
|
||||
requestId++
|
||||
computing.value = true
|
||||
worker!.postMessage({
|
||||
type: 'compile',
|
||||
templateJson: JSON.stringify(template.value),
|
||||
dataJson: JSON.stringify(data.value),
|
||||
id: requestId,
|
||||
})
|
||||
}
|
||||
|
||||
// template veya data değiştiğinde yeniden hesapla.
|
||||
// layoutVersion verilmişse sadece onu izle (cheap integer comparison).
|
||||
// Verilmemişse eski davranış: deep watch (geriye uyumluluk).
|
||||
if (layoutVersion) {
|
||||
watch(
|
||||
layoutVersion,
|
||||
() => {
|
||||
compute()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
} else {
|
||||
watch(
|
||||
[template, data],
|
||||
() => {
|
||||
compute()
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
)
|
||||
}
|
||||
|
||||
// --- Barcode üretimi (WASM üzerinden) ---
|
||||
let barcodeReqId = 0
|
||||
const barcodeCallbacks = new Map<number, (result: { width: number; height: number; rgba: ArrayBuffer } | null) => void>()
|
||||
|
||||
function generateBarcode(format: string, value: string, width: number, height: number, includeText: boolean = false): Promise<{ width: number; height: number; rgba: ArrayBuffer } | null> {
|
||||
if (!worker) initWorker()
|
||||
return new Promise(resolve => {
|
||||
barcodeReqId++
|
||||
const id = barcodeReqId + 100000 // compile id'leriyle çakışmasın
|
||||
barcodeCallbacks.set(id, resolve)
|
||||
worker!.postMessage({ type: 'barcode', format, value, width, height, includeText, id })
|
||||
})
|
||||
}
|
||||
|
||||
function handleBarcodeResponse(msg: any) {
|
||||
if (msg.type === 'barcode-result' || msg.type === 'barcode-error') {
|
||||
const cb = barcodeCallbacks.get(msg.id)
|
||||
if (cb) {
|
||||
barcodeCallbacks.delete(msg.id)
|
||||
cb(msg.type === 'barcode-result' ? { width: msg.width, height: msg.height, rgba: msg.rgba } : null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
worker?.terminate()
|
||||
worker = null
|
||||
barcodeCallbacks.clear()
|
||||
}
|
||||
|
||||
return {
|
||||
layout,
|
||||
layoutMap,
|
||||
error,
|
||||
computing,
|
||||
compute,
|
||||
generateBarcode,
|
||||
dispose,
|
||||
}
|
||||
}
|
||||
65
frontend/src/core/layout-types.ts
Normal file
65
frontend/src/core/layout-types.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// Layout engine çıktı tipleri — Rust LayoutResult ile birebir eşleşir
|
||||
|
||||
export interface LayoutResult {
|
||||
pages: PageLayout[]
|
||||
}
|
||||
|
||||
export interface PageLayout {
|
||||
page_index: number
|
||||
width_mm: number
|
||||
height_mm: number
|
||||
elements: ElementLayout[]
|
||||
}
|
||||
|
||||
export interface ElementLayout {
|
||||
id: string
|
||||
x_mm: number
|
||||
y_mm: number
|
||||
width_mm: number
|
||||
height_mm: number
|
||||
element_type: string
|
||||
content: ResolvedContent | null
|
||||
style: ResolvedStyle
|
||||
children: string[]
|
||||
}
|
||||
|
||||
export type ResolvedContent =
|
||||
| { type: 'text'; value: string }
|
||||
| { type: 'image'; src: string }
|
||||
| { type: 'line' }
|
||||
| { type: 'barcode'; format: string; value: string }
|
||||
| { type: 'page_number'; current: number; total: number }
|
||||
| { type: 'table'; headers: TableHeaderCell[]; rows: TableCell[][]; column_widths_mm: number[] }
|
||||
|
||||
export interface TableHeaderCell {
|
||||
text: string
|
||||
align: string
|
||||
}
|
||||
|
||||
export interface TableCell {
|
||||
text: string
|
||||
align: string
|
||||
}
|
||||
|
||||
export interface ResolvedStyle {
|
||||
fontSize?: number
|
||||
fontWeight?: string
|
||||
fontFamily?: string
|
||||
color?: string
|
||||
textAlign?: string
|
||||
strokeColor?: string
|
||||
strokeWidth?: number
|
||||
backgroundColor?: string
|
||||
borderColor?: string
|
||||
borderWidth?: number
|
||||
borderRadius?: number
|
||||
borderStyle?: string
|
||||
headerBg?: string
|
||||
headerColor?: string
|
||||
zebraOdd?: string
|
||||
zebraEven?: string
|
||||
headerFontSize?: number
|
||||
objectFit?: string
|
||||
barcodeColor?: string
|
||||
barcodeIncludeText?: boolean
|
||||
}
|
||||
@@ -109,6 +109,7 @@ export type BarcodeFormat = 'qr' | 'ean13' | 'ean8' | 'code128' | 'code39'
|
||||
|
||||
export interface BarcodeStyle {
|
||||
color?: string // ön plan rengi (varsayılan: siyah)
|
||||
includeText?: boolean // barkod altına değer yazılsın mı (QR hariç)
|
||||
}
|
||||
|
||||
// --- Element tipleri ---
|
||||
|
||||
@@ -110,8 +110,22 @@ function onKeyDown(e: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('keydown', onKeyDown))
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onKeyDown))
|
||||
// Browser'ın native pinch-zoom'unu editör alanında engelle
|
||||
function onGlobalWheel(e: WheelEvent) {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
// passive: false olmadan preventDefault çalışmaz
|
||||
document.addEventListener('wheel', onGlobalWheel, { passive: false })
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
document.removeEventListener('wheel', onGlobalWheel)
|
||||
})
|
||||
|
||||
// --- Exposed API ---
|
||||
|
||||
@@ -179,6 +193,7 @@ defineExpose({
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dreport-editor__sidebar {
|
||||
|
||||
@@ -52,12 +52,36 @@ export const useTemplateStore = defineStore('template', () => {
|
||||
|
||||
const mockData = computed(() => overrideData.value ?? generateMockData(template.value))
|
||||
|
||||
/**
|
||||
* Layout version counter — her template/data mutasyonunda artar.
|
||||
* useLayoutEngine bu counter'ı izler (deep watch yerine).
|
||||
* Vue'nun tüm template ağacını recursive karşılaştırması yerine
|
||||
* tek bir sayı karşılaştırması yapılır.
|
||||
*/
|
||||
const layoutVersion = ref(0)
|
||||
|
||||
/** Layout yeniden hesaplamasını tetikle */
|
||||
function bumpLayoutVersion() {
|
||||
layoutVersion.value++
|
||||
}
|
||||
|
||||
function setOverrideData(data: Record<string, unknown> | null) {
|
||||
overrideData.value = data
|
||||
bumpLayoutVersion()
|
||||
}
|
||||
|
||||
// Undo / Redo
|
||||
const { undo, redo, canUndo, canRedo } = useUndoRedo(template)
|
||||
const { undo: _undo, redo: _redo, canUndo, canRedo } = useUndoRedo(template)
|
||||
|
||||
function undo() {
|
||||
_undo()
|
||||
bumpLayoutVersion()
|
||||
}
|
||||
|
||||
function redo() {
|
||||
_redo()
|
||||
bumpLayoutVersion()
|
||||
}
|
||||
|
||||
// --- Element CRUD ---
|
||||
|
||||
@@ -78,6 +102,7 @@ export const useTemplateStore = defineStore('template', () => {
|
||||
} else {
|
||||
parent.children.push(element)
|
||||
}
|
||||
bumpLayoutVersion()
|
||||
}
|
||||
|
||||
/** Element'i ağaçtan kaldır */
|
||||
@@ -85,13 +110,18 @@ export const useTemplateStore = defineStore('template', () => {
|
||||
const parent = getParent(elementId)
|
||||
if (!parent) return
|
||||
const idx = parent.children.findIndex(c => c.id === elementId)
|
||||
if (idx !== -1) parent.children.splice(idx, 1)
|
||||
if (idx !== -1) {
|
||||
parent.children.splice(idx, 1)
|
||||
bumpLayoutVersion()
|
||||
}
|
||||
}
|
||||
|
||||
/** Element'i başka bir container'a taşı */
|
||||
function moveElement(elementId: string, targetParentId: string, index?: number) {
|
||||
const el = getElementById(elementId)
|
||||
if (!el) return
|
||||
// removeElement bump'lar, addChild de bump'lar — ama tek mantıksal operasyon.
|
||||
// Fazladan 1 bump sorun değil (debounce var), ama istersek optimize edebiliriz.
|
||||
removeElement(elementId)
|
||||
addChild(targetParentId, el, index)
|
||||
}
|
||||
@@ -99,7 +129,10 @@ export const useTemplateStore = defineStore('template', () => {
|
||||
/** Absolute pozisyon güncelle */
|
||||
function updateElementPosition(elementId: string, position: PositionMode) {
|
||||
const el = getElementById(elementId)
|
||||
if (el) el.position = position
|
||||
if (el) {
|
||||
el.position = position
|
||||
bumpLayoutVersion()
|
||||
}
|
||||
}
|
||||
|
||||
/** Boyut güncelle */
|
||||
@@ -107,13 +140,17 @@ export const useTemplateStore = defineStore('template', () => {
|
||||
const el = getElementById(elementId)
|
||||
if (el) {
|
||||
el.size = { ...el.size, ...size }
|
||||
bumpLayoutVersion()
|
||||
}
|
||||
}
|
||||
|
||||
/** Herhangi bir element özelliğini güncelle */
|
||||
function updateElement(elementId: string, updates: Partial<TemplateElement>) {
|
||||
const el = getElementById(elementId)
|
||||
if (el) Object.assign(el, updates)
|
||||
if (el) {
|
||||
Object.assign(el, updates)
|
||||
bumpLayoutVersion()
|
||||
}
|
||||
}
|
||||
|
||||
/** Çocuk sırasını değiştir (aynı parent içinde) */
|
||||
@@ -122,6 +159,7 @@ export const useTemplateStore = defineStore('template', () => {
|
||||
if (!parent || !isContainer(parent)) return
|
||||
const [moved] = parent.children.splice(fromIndex, 1)
|
||||
parent.children.splice(toIndex, 0, moved)
|
||||
bumpLayoutVersion()
|
||||
}
|
||||
|
||||
/** Şablonu JSON olarak dışa aktar */
|
||||
@@ -133,16 +171,20 @@ export const useTemplateStore = defineStore('template', () => {
|
||||
function importTemplate(json: string) {
|
||||
const parsed = JSON.parse(json) as Template
|
||||
template.value = parsed
|
||||
bumpLayoutVersion()
|
||||
}
|
||||
|
||||
/** Yeni boş şablon oluştur */
|
||||
function resetTemplate() {
|
||||
template.value = createDefaultTemplate()
|
||||
bumpLayoutVersion()
|
||||
}
|
||||
|
||||
return {
|
||||
template,
|
||||
mockData,
|
||||
layoutVersion,
|
||||
bumpLayoutVersion,
|
||||
getElementById,
|
||||
getParent,
|
||||
addChild,
|
||||
|
||||
@@ -1,3 +1,43 @@
|
||||
@font-face {
|
||||
font-family: 'Noto Sans';
|
||||
src: url('/fonts/NotoSans-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Noto Sans';
|
||||
src: url('/fonts/NotoSans-Bold.ttf') format('truetype');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Noto Sans';
|
||||
src: url('/fonts/NotoSans-Italic.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Noto Sans';
|
||||
src: url('/fonts/NotoSans-BoldItalic.ttf') format('truetype');
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Noto Sans Mono';
|
||||
src: url('/fonts/NotoSansMono-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
@@ -12,6 +52,8 @@ html, body {
|
||||
color: #1e293b;
|
||||
background: #f1f5f9;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
/* Browser native pinch-zoom'u engelle — editörün kendi zoom'u var */
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
|
||||
#app {
|
||||
|
||||
88
frontend/src/workers/layout.worker.ts
Normal file
88
frontend/src/workers/layout.worker.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/// Layout Engine Web Worker
|
||||
/// Template JSON + Data JSON → Layout WASM → LayoutResult
|
||||
|
||||
import init, { loadFonts, computeLayout, generateBarcode } from '../core/wasm-layout/dreport_layout.js'
|
||||
import type { LayoutResult } from '../core/layout-types'
|
||||
|
||||
let initPromise: Promise<void> | null = null
|
||||
|
||||
const FONT_FILES = [
|
||||
{ path: '/fonts/NotoSans-Regular.ttf', family: 'Noto Sans' },
|
||||
{ path: '/fonts/NotoSans-Bold.ttf', family: 'Noto Sans' },
|
||||
{ path: '/fonts/NotoSans-Italic.ttf', family: 'Noto Sans' },
|
||||
{ path: '/fonts/NotoSans-BoldItalic.ttf', family: 'Noto Sans' },
|
||||
{ path: '/fonts/NotoSansMono-Regular.ttf', family: 'Noto Sans Mono' },
|
||||
]
|
||||
|
||||
async function doInit() {
|
||||
console.log('[layout-worker] WASM başlatılıyor...')
|
||||
await init({ module_or_path: '/wasm/dreport_layout_bg.wasm' })
|
||||
|
||||
console.log('[layout-worker] Fontlar yükleniyor...')
|
||||
const families: string[] = []
|
||||
const buffers: Uint8Array[] = []
|
||||
|
||||
await Promise.all(
|
||||
FONT_FILES.map(async (f) => {
|
||||
const res = await fetch(new URL(f.path, self.location.origin).href)
|
||||
const buf = await res.arrayBuffer()
|
||||
families.push(f.family)
|
||||
buffers.push(new Uint8Array(buf))
|
||||
})
|
||||
)
|
||||
|
||||
loadFonts(JSON.stringify(families), buffers)
|
||||
console.log('[layout-worker] Hazır')
|
||||
}
|
||||
|
||||
function ensureInit(): Promise<void> {
|
||||
if (!initPromise) {
|
||||
initPromise = doInit()
|
||||
}
|
||||
return initPromise
|
||||
}
|
||||
|
||||
type WorkerMessage =
|
||||
| { type: 'compile'; templateJson: string; dataJson: string; id: number }
|
||||
| { type: 'barcode'; format: string; value: string; width: number; height: number; includeText: boolean; id: number }
|
||||
|
||||
self.onmessage = async (e: MessageEvent<WorkerMessage>) => {
|
||||
const msg = e.data
|
||||
|
||||
if (msg.type === 'compile') {
|
||||
try {
|
||||
await ensureInit()
|
||||
|
||||
const t0 = performance.now()
|
||||
const resultJson = computeLayout(msg.templateJson, msg.dataJson)
|
||||
const layout: LayoutResult = JSON.parse(resultJson)
|
||||
console.log(`[layout-worker] render ${(performance.now() - t0).toFixed(1)}ms`)
|
||||
|
||||
self.postMessage({ type: 'result', layout, id: msg.id })
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err)
|
||||
console.error(`[layout-worker] Hata (id: ${msg.id}):`, err)
|
||||
self.postMessage({ type: 'error', error: errorMsg, id: msg.id })
|
||||
}
|
||||
} else if (msg.type === 'barcode') {
|
||||
try {
|
||||
await ensureInit()
|
||||
|
||||
const raw = generateBarcode(msg.format, msg.value, msg.width, msg.height, msg.includeText)
|
||||
// İlk 8 byte header: width (4 byte LE) + height (4 byte LE)
|
||||
const dv = new DataView(raw.buffer, raw.byteOffset, 8)
|
||||
const w = dv.getUint32(0, true)
|
||||
const h = dv.getUint32(4, true)
|
||||
const rgba = raw.slice(8)
|
||||
|
||||
self.postMessage(
|
||||
{ type: 'barcode-result', width: w, height: h, rgba: rgba.buffer, id: msg.id },
|
||||
[rgba.buffer] as any,
|
||||
)
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err)
|
||||
console.error(`[layout-worker] Barcode hatası (id: ${msg.id}):`, err)
|
||||
self.postMessage({ type: 'barcode-error', error: errorMsg, id: msg.id })
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user