This commit is contained in:
2026-03-29 19:17:09 +03:00
parent 9b17d2aef4
commit 1cbe42ed75
34 changed files with 4690 additions and 3105 deletions

View File

@@ -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;

View File

@@ -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>

View 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>

View File

@@ -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>

View 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,
}
}

View 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
}

View File

@@ -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 ---

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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 {

View 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 })
}
}
}