mirror of
https://github.com/duhanbalci/dreport.git
synced 2026-07-01 18:39:16 +00:00
bug fixes & improvements & missing features & font loader
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.41.0",
|
||||
"@duhanbalci/codemirror-lang-dexpr": "0.1.0",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@lezer/lr": "^1.4.8",
|
||||
"codemirror": "^6.0.2",
|
||||
@@ -56,6 +57,8 @@
|
||||
|
||||
"@codemirror/view": ["@codemirror/view@6.41.0", "", { "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-6H/qadXsVuDY219Yljhohglve8xf4B8xJkVOEWfA5uiYKiTFppjqsvsfR5iPA0RbvRBoOyTZpbLIxe9+0UR8xA=="],
|
||||
|
||||
"@duhanbalci/codemirror-lang-dexpr": ["@duhanbalci/codemirror-lang-dexpr@0.1.0", "https://gitea.duhanbalci.com/api/packages/duhanbalci/npm/%40duhanbalci%2Fcodemirror-lang-dexpr/-/0.1.0/codemirror-lang-dexpr-0.1.0.tgz", { "peerDependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-cR8SGbtW3Dq1/w7RyWG0yJeBfl3sqQGtJtPZE/d7hziIy75tvt1zEtYxODyuzlDkteX9XzUnaLrS3/TK9dZj8Q=="],
|
||||
|
||||
"@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="],
|
||||
|
||||
@@ -17,10 +17,10 @@
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.41.0",
|
||||
"@duhanbalci/codemirror-lang-dexpr": "0.1.0",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@lezer/lr": "^1.4.8",
|
||||
"codemirror": "^6.0.2",
|
||||
"codemirror-lang-dexpr": "file:../../rust-expr/editor",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.31"
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useEditorStore } from '../../stores/editor'
|
||||
import { useLayoutEngine } from '../../composables/useLayoutEngine'
|
||||
import LayoutRenderer from './LayoutRenderer.vue'
|
||||
import InteractionOverlay from './InteractionOverlay.vue'
|
||||
import RulerBar from './RulerBar.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
handleErrors?: boolean
|
||||
@@ -204,6 +205,15 @@ function onPointerUp(e: PointerEvent) {
|
||||
|
||||
<template>
|
||||
<div class="editor-canvas-wrapper">
|
||||
<!-- Cetvel -->
|
||||
<RulerBar
|
||||
:page-width="templateStore.template.page.width"
|
||||
:page-height="templateStore.template.page.height"
|
||||
:scale="scale"
|
||||
:pan-x="editorStore.panX"
|
||||
:pan-y="editorStore.panY"
|
||||
/>
|
||||
|
||||
<!-- Scroll alanı -->
|
||||
<div
|
||||
class="editor-canvas"
|
||||
@@ -252,6 +262,8 @@ function onPointerUp(e: PointerEvent) {
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
padding-top: 60px; /* cetvel için üstten ek boşluk */
|
||||
padding-left: 60px; /* cetvel için soldan ek boşluk */
|
||||
}
|
||||
|
||||
.editor-canvas__pages {
|
||||
|
||||
@@ -13,7 +13,7 @@ const props = defineProps<{
|
||||
const editorStore = useEditorStore()
|
||||
const templateStore = useTemplateStore()
|
||||
|
||||
const isSelected = computed(() => editorStore.selectedElementId === props.element.id)
|
||||
const isSelected = computed(() => editorStore.isSelected(props.element.id))
|
||||
const isContainerEl = computed(() => isContainer(props.element))
|
||||
const isAbsolute = computed(() => props.element.position.type === 'absolute')
|
||||
|
||||
|
||||
@@ -96,6 +96,12 @@ function updateChartStyle(key: string, value: unknown) {
|
||||
if (!selected.value) return
|
||||
update({ style: { ...selected.value.style, [key]: value } })
|
||||
}
|
||||
|
||||
// Z-order
|
||||
function bringForward() { if (selected.value) templateStore.bringForward(selected.value.id) }
|
||||
function sendBackward() { if (selected.value) templateStore.sendBackward(selected.value.id) }
|
||||
function bringToFront() { if (selected.value) templateStore.bringToFront(selected.value.id) }
|
||||
function sendToBack() { if (selected.value) templateStore.sendToBack(selected.value.id) }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -409,6 +415,37 @@ function updateChartStyle(key: string, value: unknown) {
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ===== Z-Order (tüm elemanlar) ===== -->
|
||||
<template v-if="selected">
|
||||
<div class="et__sep" />
|
||||
<div class="et__group">
|
||||
<button class="et__btn" data-tip="Arkaya Gonder" @click="sendToBack">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<rect x="5" y="5" width="7" height="7" rx="1" fill="currentColor" opacity="0.3"/>
|
||||
<rect x="2" y="2" width="7" height="7" rx="1" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="et__btn" data-tip="Bir Geri" @click="sendBackward">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<rect x="5" y="5" width="7" height="7" rx="1" fill="currentColor" opacity="0.3"/>
|
||||
<rect x="2" y="2" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.2" fill="none"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="et__btn" data-tip="Bir Ileri" @click="bringForward">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<rect x="2" y="2" width="7" height="7" rx="1" fill="currentColor" opacity="0.3"/>
|
||||
<rect x="5" y="5" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.2" fill="none"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="et__btn" data-tip="One Getir" @click="bringToFront">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<rect x="2" y="2" width="7" height="7" rx="1" fill="currentColor" opacity="0.3"/>
|
||||
<rect x="5" y="5" width="7" height="7" rx="1" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -98,7 +98,11 @@ function getElementStyle(el: TemplateElement) {
|
||||
function onElementClick(e: PointerEvent, id: string) {
|
||||
e.stopPropagation()
|
||||
if (didDrag.value) return
|
||||
editorStore.selectElement(id)
|
||||
if (e.shiftKey) {
|
||||
editorStore.toggleSelection(id)
|
||||
} else {
|
||||
editorStore.selectElement(id)
|
||||
}
|
||||
}
|
||||
|
||||
function onCanvasClick() {
|
||||
@@ -637,7 +641,7 @@ const isAnyDragActive = computed(() =>
|
||||
:key="el.id"
|
||||
class="element-handle"
|
||||
:class="{
|
||||
'element-handle--selected': editorStore.selectedElementId === el.id,
|
||||
'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,
|
||||
@@ -646,10 +650,10 @@ const isAnyDragActive = computed(() =>
|
||||
@pointerdown="(e: PointerEvent) => { onElementClick(e, el.id); onDragStart(e, el) }"
|
||||
>
|
||||
<!-- Selection border -->
|
||||
<div v-if="editorStore.selectedElementId === el.id" class="selection-border" />
|
||||
<div v-if="editorStore.isSelected(el.id)" class="selection-border" />
|
||||
|
||||
<!-- Resize handles -->
|
||||
<template v-if="editorStore.selectedElementId === el.id && !isResizing && el.type !== 'page_break'">
|
||||
<!-- 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')" />
|
||||
|
||||
@@ -234,7 +234,7 @@ watch(
|
||||
:style="{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'fill',
|
||||
objectFit: el.style.objectFit || 'fill',
|
||||
}"
|
||||
/>
|
||||
<div v-else class="layout-el__placeholder">Görsel</div>
|
||||
|
||||
231
frontend/src/components/editor/RulerBar.vue
Normal file
231
frontend/src/components/editor/RulerBar.vue
Normal file
@@ -0,0 +1,231 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
/** Sayfa genişliği mm */
|
||||
pageWidth: number
|
||||
/** Sayfa yüksekliği mm */
|
||||
pageHeight: number
|
||||
/** mm → px dönüşüm katsayısı (scale * zoom) */
|
||||
scale: number
|
||||
/** Pan offset X (px) */
|
||||
panX: number
|
||||
/** Pan offset Y (px) */
|
||||
panY: number
|
||||
/** Cetvel kalınlığı px */
|
||||
rulerSize?: number
|
||||
}>()
|
||||
|
||||
const RULER_SIZE = computed(() => props.rulerSize ?? 20)
|
||||
|
||||
const hCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const vCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
function drawRuler(
|
||||
canvas: HTMLCanvasElement | null,
|
||||
direction: 'horizontal' | 'vertical',
|
||||
) {
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const size = RULER_SIZE.value
|
||||
|
||||
if (direction === 'horizontal') {
|
||||
const w = canvas.clientWidth
|
||||
canvas.width = w * dpr
|
||||
canvas.height = size * dpr
|
||||
ctx.scale(dpr, dpr)
|
||||
ctx.clearRect(0, 0, w, size)
|
||||
ctx.fillStyle = '#f1f5f9'
|
||||
ctx.fillRect(0, 0, w, size)
|
||||
ctx.strokeStyle = '#e2e8f0'
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, size - 0.5)
|
||||
ctx.lineTo(w, size - 0.5)
|
||||
ctx.stroke()
|
||||
drawTicks(ctx, direction, w, size)
|
||||
} else {
|
||||
const h = canvas.clientHeight
|
||||
canvas.width = size * dpr
|
||||
canvas.height = h * dpr
|
||||
ctx.scale(dpr, dpr)
|
||||
ctx.clearRect(0, 0, size, h)
|
||||
ctx.fillStyle = '#f1f5f9'
|
||||
ctx.fillRect(0, 0, size, h)
|
||||
ctx.strokeStyle = '#e2e8f0'
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(size - 0.5, 0)
|
||||
ctx.lineTo(size - 0.5, h)
|
||||
ctx.stroke()
|
||||
drawTicks(ctx, direction, h, size)
|
||||
}
|
||||
}
|
||||
|
||||
function drawTicks(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
direction: 'horizontal' | 'vertical',
|
||||
length: number,
|
||||
size: number,
|
||||
) {
|
||||
const s = props.scale
|
||||
const pageMm = direction === 'horizontal' ? props.pageWidth : props.pageHeight
|
||||
const pan = direction === 'horizontal' ? props.panX : props.panY
|
||||
|
||||
// Sayfa başlangıcı: ortaya hizalı + pan
|
||||
// EditorCanvas sayfayı ortalar, ruler da buna uymalı
|
||||
// Yatay: canvas ortası - sayfa genişliği/2
|
||||
// Sayfanın canvas üzerindeki orijin px'i
|
||||
const canvasCenter = direction === 'horizontal'
|
||||
? (length / 2) // flex centering approximation
|
||||
: 40 // EditorCanvas padding-top: 40px
|
||||
|
||||
const pageStartPx = canvasCenter - (pageMm * s) / 2 + pan
|
||||
|
||||
// Tick aralığı belirleme (zoom'a göre)
|
||||
const mmPerPx = 1 / s
|
||||
let tickMm = 1
|
||||
if (mmPerPx > 2) tickMm = 50
|
||||
else if (mmPerPx > 1) tickMm = 20
|
||||
else if (mmPerPx > 0.5) tickMm = 10
|
||||
else if (mmPerPx > 0.2) tickMm = 5
|
||||
else tickMm = 1
|
||||
|
||||
ctx.fillStyle = '#94a3b8'
|
||||
ctx.strokeStyle = '#94a3b8'
|
||||
ctx.lineWidth = 0.5
|
||||
ctx.font = '9px system-ui, sans-serif'
|
||||
ctx.textBaseline = 'top'
|
||||
|
||||
// Sayfanın mm aralığını çiz
|
||||
const startMm = 0
|
||||
const endMm = pageMm
|
||||
|
||||
for (let mm = startMm; mm <= endMm; mm += tickMm) {
|
||||
const px = pageStartPx + mm * s
|
||||
|
||||
if (px < -10 || px > length + 10) continue
|
||||
|
||||
const isMajor = mm % 10 === 0
|
||||
const isMedium = mm % 5 === 0
|
||||
|
||||
let tickLen = 4
|
||||
if (isMajor) tickLen = size * 0.6
|
||||
else if (isMedium) tickLen = size * 0.35
|
||||
|
||||
ctx.beginPath()
|
||||
if (direction === 'horizontal') {
|
||||
ctx.moveTo(px, size)
|
||||
ctx.lineTo(px, size - tickLen)
|
||||
} else {
|
||||
ctx.moveTo(size, px)
|
||||
ctx.lineTo(size - tickLen, px)
|
||||
}
|
||||
ctx.stroke()
|
||||
|
||||
// Sayı etiketi (her 10mm'de bir)
|
||||
if (isMajor && mm > 0) {
|
||||
const label = String(mm)
|
||||
if (direction === 'horizontal') {
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(label, px, 2)
|
||||
} else {
|
||||
ctx.save()
|
||||
ctx.translate(2, px)
|
||||
ctx.rotate(-Math.PI / 2)
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(label, 0, 0)
|
||||
ctx.restore()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sayfa kenar çizgileri (margin göstergesi)
|
||||
ctx.strokeStyle = 'rgba(59, 130, 246, 0.3)'
|
||||
ctx.lineWidth = 1
|
||||
const startPx = pageStartPx
|
||||
const endPx = pageStartPx + pageMm * s
|
||||
ctx.beginPath()
|
||||
if (direction === 'horizontal') {
|
||||
ctx.moveTo(startPx, 0)
|
||||
ctx.lineTo(startPx, size)
|
||||
ctx.moveTo(endPx, 0)
|
||||
ctx.lineTo(endPx, size)
|
||||
} else {
|
||||
ctx.moveTo(0, startPx)
|
||||
ctx.lineTo(size, startPx)
|
||||
ctx.moveTo(0, endPx)
|
||||
ctx.lineTo(size, endPx)
|
||||
}
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
function redraw() {
|
||||
drawRuler(hCanvas.value, 'horizontal')
|
||||
drawRuler(vCanvas.value, 'vertical')
|
||||
}
|
||||
|
||||
watch(() => [props.scale, props.panX, props.panY, props.pageWidth, props.pageHeight], redraw)
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
onMounted(() => {
|
||||
redraw()
|
||||
const parent = hCanvas.value?.parentElement?.parentElement
|
||||
if (parent) {
|
||||
resizeObserver = new ResizeObserver(() => redraw())
|
||||
resizeObserver.observe(parent)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ruler-corner" :style="{ width: `${RULER_SIZE}px`, height: `${RULER_SIZE}px` }" />
|
||||
<canvas
|
||||
ref="hCanvas"
|
||||
class="ruler-h"
|
||||
:style="{ height: `${RULER_SIZE}px` }"
|
||||
/>
|
||||
<canvas
|
||||
ref="vCanvas"
|
||||
class="ruler-v"
|
||||
:style="{ width: `${RULER_SIZE}px` }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ruler-corner {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background: #f1f5f9;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.ruler-h {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 20px;
|
||||
right: 0;
|
||||
z-index: 50;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ruler-v {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 50;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -38,11 +38,15 @@ const templateStore = useTemplateStore()
|
||||
const editorStore = useEditorStore()
|
||||
|
||||
const selectedElement = computed(() => {
|
||||
const id = editorStore.selectedElementId
|
||||
const ids = editorStore.selectedElementIds
|
||||
if (ids.size !== 1) return null
|
||||
const id = ids.values().next().value
|
||||
if (!id) return null
|
||||
return templateStore.getElementById(id) ?? null
|
||||
})
|
||||
|
||||
const multipleSelected = computed(() => editorStore.selectedElementIds.size > 1)
|
||||
|
||||
const elementTypeLabel = computed(() => {
|
||||
const el = selectedElement.value
|
||||
if (!el) return ''
|
||||
@@ -87,11 +91,24 @@ function deleteElement() {
|
||||
editorStore.clearSelection()
|
||||
templateStore.removeElement(id)
|
||||
}
|
||||
|
||||
function deleteSelected() {
|
||||
const ids = [...editorStore.selectedElementIds]
|
||||
editorStore.clearSelection()
|
||||
for (const id of ids) {
|
||||
if (id !== 'root') templateStore.removeElement(id)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="properties-panel">
|
||||
<div v-if="!selectedElement" class="properties-panel__empty">
|
||||
<div v-if="multipleSelected" class="properties-panel__empty">
|
||||
{{ editorStore.selectedElementIds.size }} eleman secili
|
||||
<button class="prop-delete-btn" style="margin-top: 12px" @click="deleteSelected">Secilenleri Sil</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!selectedElement" class="properties-panel__empty">
|
||||
Bir eleman secin
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useTemplateStore } from '../../stores/template'
|
||||
import { useEditorStore } from '../../stores/editor'
|
||||
import { useSchemaStore } from '../../stores/schema'
|
||||
import type { ImageElement, TemplateElement } from '../../core/types'
|
||||
import '../../styles/properties.css'
|
||||
|
||||
const props = defineProps<{ element: ImageElement }>()
|
||||
const templateStore = useTemplateStore()
|
||||
const editorStore = useEditorStore()
|
||||
const schemaStore = useSchemaStore()
|
||||
|
||||
/** Statik mi dinamik mi? */
|
||||
const isDynamic = computed(() => !!props.element.binding)
|
||||
|
||||
function update(updates: Partial<TemplateElement>) {
|
||||
const id = editorStore.selectedElementId
|
||||
@@ -24,30 +30,86 @@ function onImageFileSelect(e: Event) {
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
update({ src: reader.result as string } as Partial<TemplateElement>)
|
||||
update({ src: reader.result as string, binding: undefined } as Partial<TemplateElement>)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
function setMode(mode: 'static' | 'dynamic') {
|
||||
if (mode === 'static') {
|
||||
update({ binding: undefined } as Partial<TemplateElement>)
|
||||
} else {
|
||||
// Dinamik moda geç — ilk uygun alanı seç veya boş bırak
|
||||
const imageFields = schemaStore.scalarFields.filter(f => f.format === 'image' || f.type === 'string')
|
||||
const path = imageFields.length > 0 ? imageFields[0].path : ''
|
||||
update({ src: undefined, binding: { type: 'scalar', path } } as Partial<TemplateElement>)
|
||||
}
|
||||
}
|
||||
|
||||
function setBindingPath(path: string) {
|
||||
update({ binding: { type: 'scalar', path } } as Partial<TemplateElement>)
|
||||
}
|
||||
|
||||
/** Schema'dan görsel olabilecek alanlar (format: image veya string) */
|
||||
const imageScalarFields = computed(() => {
|
||||
return schemaStore.scalarFields.filter(f => f.format === 'image' || f.type === 'string')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prop-section">
|
||||
<div class="prop-section__title">Gorsel</div>
|
||||
<div class="prop-row" data-tip="Gorsel dosyasi secin (PNG, JPG, SVG)">
|
||||
<label class="prop-label">Kaynak</label>
|
||||
<label class="prop-file-btn">
|
||||
Dosya Sec
|
||||
<input type="file" accept="image/*" style="display: none" @change="onImageFileSelect" />
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="element.src" class="prop-row" data-tip="Yuklenen gorsel onizlemesi">
|
||||
<label class="prop-label">Onizleme</label>
|
||||
<img :src="element.src" class="prop-image-preview" />
|
||||
</div>
|
||||
<div v-if="element.src" class="prop-row" data-tip="Gorseli kaldirmak icin tiklayin">
|
||||
<label class="prop-label"></label>
|
||||
<button class="prop-clear" @click="update({ src: undefined } as any)">Gorseli kaldir</button>
|
||||
|
||||
<!-- Statik / Dinamik toggle -->
|
||||
<div class="prop-row" data-tip="Gorsel kaynagi: dosya veya veri alanından">
|
||||
<label class="prop-label">Mod</label>
|
||||
<div class="prop-toggle-group">
|
||||
<button class="prop-toggle-btn" :class="{ 'prop-toggle-btn--active': !isDynamic }" @click="setMode('static')">Statik</button>
|
||||
<button class="prop-toggle-btn" :class="{ 'prop-toggle-btn--active': isDynamic }" @click="setMode('dynamic')">Dinamik</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statik: dosya seçimi -->
|
||||
<template v-if="!isDynamic">
|
||||
<div class="prop-row" data-tip="Gorsel dosyasi secin (PNG, JPG, SVG)">
|
||||
<label class="prop-label">Kaynak</label>
|
||||
<label class="prop-file-btn">
|
||||
Dosya Sec
|
||||
<input type="file" accept="image/*" style="display: none" @change="onImageFileSelect" />
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="element.src" class="prop-row" data-tip="Yuklenen gorsel onizlemesi">
|
||||
<label class="prop-label">Onizleme</label>
|
||||
<img :src="element.src" class="prop-image-preview" />
|
||||
</div>
|
||||
<div v-if="element.src" class="prop-row" data-tip="Gorseli kaldirmak icin tiklayin">
|
||||
<label class="prop-label"></label>
|
||||
<button class="prop-clear" @click="update({ src: undefined } as any)">Gorseli kaldir</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Dinamik: schema alan seçimi -->
|
||||
<template v-else>
|
||||
<div class="prop-row" data-tip="Gorsel URL'sinin gelecegi veri alani">
|
||||
<label class="prop-label">Veri Alani</label>
|
||||
<select class="prop-input prop-select"
|
||||
:value="element.binding?.path ?? ''"
|
||||
@change="(e) => setBindingPath((e.target as HTMLSelectElement).value)">
|
||||
<option value="" disabled>Secin...</option>
|
||||
<option
|
||||
v-for="field in imageScalarFields"
|
||||
:key="field.path"
|
||||
:value="field.path"
|
||||
>{{ field.title }} ({{ field.path }})</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="element.binding?.path" class="prop-row">
|
||||
<label class="prop-label">Path</label>
|
||||
<span class="prop-info">{{ element.binding.path }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Sığdırma modu (ortak) -->
|
||||
<div class="prop-row" data-tip="Gorselin alana sigdirma modu">
|
||||
<label class="prop-label">Sigdirma</label>
|
||||
<select class="prop-input prop-select"
|
||||
@@ -60,3 +122,42 @@ function onImageFileSelect(e: Event) {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.prop-toggle-group {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.prop-toggle-btn {
|
||||
flex: 1;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: white;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
|
||||
.prop-toggle-btn:first-child {
|
||||
border-radius: 4px 0 0 4px;
|
||||
}
|
||||
|
||||
.prop-toggle-btn:last-child {
|
||||
border-radius: 0 4px 4px 0;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.prop-toggle-btn--active {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.prop-info {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,10 +4,16 @@ import type { LayoutResult, LayoutMapEntry } from '../core/layout-types'
|
||||
|
||||
export type { LayoutMapEntry }
|
||||
|
||||
export interface LayoutEngineOptions {
|
||||
/** Font API base URL. Default: '/api/fonts' */
|
||||
fontApiBase?: string
|
||||
}
|
||||
|
||||
export function useLayoutEngine(
|
||||
template: Ref<Template>,
|
||||
data: Ref<Record<string, unknown>>,
|
||||
layoutVersion?: Ref<number>,
|
||||
options?: LayoutEngineOptions,
|
||||
) {
|
||||
const layout = ref<LayoutResult | null>(null)
|
||||
const error = ref<string | null>(null)
|
||||
@@ -24,6 +30,11 @@ export function useLayoutEngine(
|
||||
type: 'module',
|
||||
})
|
||||
|
||||
// Configure font API base if provided
|
||||
if (options?.fontApiBase) {
|
||||
worker.postMessage({ type: 'configure', fontApiBase: options.fontApiBase })
|
||||
}
|
||||
|
||||
worker.onmessage = (e: MessageEvent<any>) => {
|
||||
const msg = e.data
|
||||
|
||||
@@ -105,8 +116,15 @@ export function useLayoutEngine(
|
||||
if (!worker) initWorker()
|
||||
return new Promise(resolve => {
|
||||
barcodeReqId++
|
||||
const id = barcodeReqId + 100000 // compile id'leriyle çakışmasın
|
||||
barcodeCallbacks.set(id, resolve)
|
||||
const id = barcodeReqId
|
||||
const timeout = setTimeout(() => {
|
||||
barcodeCallbacks.delete(id)
|
||||
resolve(null)
|
||||
}, 5000)
|
||||
barcodeCallbacks.set(id, (result) => {
|
||||
clearTimeout(timeout)
|
||||
resolve(result)
|
||||
})
|
||||
worker!.postMessage({ type: 'barcode', format, value, width, height, includeText, id })
|
||||
})
|
||||
}
|
||||
@@ -124,6 +142,10 @@ export function useLayoutEngine(
|
||||
function dispose() {
|
||||
worker?.terminate()
|
||||
worker = null
|
||||
// Bekleyen barcode promise'lerini null ile resolve et
|
||||
for (const cb of barcodeCallbacks.values()) {
|
||||
cb(null)
|
||||
}
|
||||
barcodeCallbacks.clear()
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ export function useUndoRedo<T>(source: Ref<T>, maxHistory = 50) {
|
||||
|
||||
function applySnapshot(snap: string) {
|
||||
skipWatch = true
|
||||
Object.assign(source.value as object, JSON.parse(snap))
|
||||
source.value = JSON.parse(snap)
|
||||
skipWatch = false
|
||||
}
|
||||
|
||||
|
||||
@@ -312,7 +312,7 @@ export interface Template {
|
||||
// --- Editor state ---
|
||||
|
||||
export interface EditorState {
|
||||
selectedElementId: string | null
|
||||
selectedElementIds: Set<string>
|
||||
zoom: number // 0.25 - 4.0
|
||||
panX: number
|
||||
panY: number
|
||||
|
||||
@@ -87,14 +87,14 @@ function onKeyDown(e: KeyboardEvent) {
|
||||
const tag = target?.tagName
|
||||
const isInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target?.isContentEditable
|
||||
|
||||
// Delete / Backspace
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace') && editorStore.selectedElementId) {
|
||||
// Delete / Backspace — çoklu seçim desteği
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace') && editorStore.selectedElementIds.size > 0) {
|
||||
if (isInput) return
|
||||
e.preventDefault()
|
||||
const id = editorStore.selectedElementId
|
||||
if (id && id !== 'root') {
|
||||
editorStore.clearSelection()
|
||||
templateStore.removeElement(id)
|
||||
const ids = [...editorStore.selectedElementIds]
|
||||
editorStore.clearSelection()
|
||||
for (const id of ids) {
|
||||
if (id !== 'root') templateStore.removeElement(id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,23 @@ function onKeyDown(e: KeyboardEvent) {
|
||||
e.preventDefault()
|
||||
templateStore.redo()
|
||||
}
|
||||
|
||||
// Z-Order kısayolları
|
||||
if ((e.ctrlKey || e.metaKey) && editorStore.selectedElementId && editorStore.selectedElementId !== 'root') {
|
||||
if (e.key === ']' && e.shiftKey) {
|
||||
e.preventDefault()
|
||||
templateStore.bringToFront(editorStore.selectedElementId)
|
||||
} else if (e.key === ']') {
|
||||
e.preventDefault()
|
||||
templateStore.bringForward(editorStore.selectedElementId)
|
||||
} else if (e.key === '[' && e.shiftKey) {
|
||||
e.preventDefault()
|
||||
templateStore.sendToBack(editorStore.selectedElementId)
|
||||
} else if (e.key === '[') {
|
||||
e.preventDefault()
|
||||
templateStore.sendBackward(editorStore.selectedElementId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Browser'ın native pinch-zoom'unu editör alanında engelle
|
||||
|
||||
523
frontend/src/stores/__tests__/improvements.test.ts
Normal file
523
frontend/src/stores/__tests__/improvements.test.ts
Normal file
@@ -0,0 +1,523 @@
|
||||
/**
|
||||
* IMPROVEMENTS.md bölüm 1, 2, 3 implementasyonlarının testleri.
|
||||
*
|
||||
* Bölüm 1: Kritik Buglar (1.1–1.4)
|
||||
* Bölüm 2: Önemli Teknik Sorunlar (2.9, 2.11)
|
||||
* Bölüm 3: Eksik Özellikler (3.1, 3.2)
|
||||
*
|
||||
* Not: Rust tarafı testleri layout-engine/tests/improvements_test.rs dosyasındadır.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useTemplateStore } from '../template'
|
||||
import { useEditorStore } from '../editor'
|
||||
import type { Template, StaticTextElement, ContainerElement, ImageElement, TemplateElement } from '../../core/types'
|
||||
import { sz } from '../../core/types'
|
||||
|
||||
function createTestTemplate(): Template {
|
||||
return {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
page: { width: 210, height: 297 },
|
||||
fonts: ['Noto Sans'],
|
||||
root: {
|
||||
id: 'root',
|
||||
type: 'container' as const,
|
||||
position: { type: 'flow' as const },
|
||||
size: { width: sz.auto(), height: sz.auto() },
|
||||
direction: 'column' as const,
|
||||
gap: 5,
|
||||
padding: { top: 10, right: 10, bottom: 10, left: 10 },
|
||||
align: 'stretch' as const,
|
||||
justify: 'start' as const,
|
||||
style: {},
|
||||
children: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createTextElement(id: string, content: string): StaticTextElement {
|
||||
return {
|
||||
id,
|
||||
type: 'static_text',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.auto(), height: sz.auto() },
|
||||
style: { fontSize: 12 },
|
||||
content,
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 1.1 Undo/Redo — Object.assign yerine reference replacement
|
||||
// =============================================================================
|
||||
|
||||
describe('1.1 Undo/Redo reference replacement', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('undo properly removes keys that were added after snapshot', async () => {
|
||||
vi.useFakeTimers()
|
||||
const store = useTemplateStore()
|
||||
store.template = createTestTemplate()
|
||||
|
||||
// Snapshot al (debounce beklenmeli)
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
|
||||
// Header ekle
|
||||
store.enableHeader()
|
||||
expect(store.template.header).toBeDefined()
|
||||
|
||||
// Snapshot al
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
|
||||
// Undo: header eklenmeden önceki state'e dön
|
||||
store.undo()
|
||||
expect(store.template.header).toBeUndefined()
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('undo properly removes footer key', async () => {
|
||||
vi.useFakeTimers()
|
||||
const store = useTemplateStore()
|
||||
store.template = createTestTemplate()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
|
||||
store.enableFooter()
|
||||
expect(store.template.footer).toBeDefined()
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
|
||||
store.undo()
|
||||
expect(store.template.footer).toBeUndefined()
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('redo restores the removed key after undo', async () => {
|
||||
vi.useFakeTimers()
|
||||
const store = useTemplateStore()
|
||||
store.template = createTestTemplate()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
|
||||
store.enableHeader()
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
|
||||
store.undo()
|
||||
expect(store.template.header).toBeUndefined()
|
||||
|
||||
store.redo()
|
||||
expect(store.template.header).toBeDefined()
|
||||
expect(store.template.header!.id).toBe('header')
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// 1.3 Image objectFit — LayoutRenderer'da style.objectFit okunmalı
|
||||
// (Birim test olarak ImageElement tipi üzerinden doğrulanır)
|
||||
// =============================================================================
|
||||
|
||||
describe('1.3 Image objectFit', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('ImageElement stores objectFit in style', () => {
|
||||
const store = useTemplateStore()
|
||||
store.template = createTestTemplate()
|
||||
|
||||
const img: ImageElement = {
|
||||
id: 'img_1',
|
||||
type: 'image',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.fixed(50), height: sz.fixed(30) },
|
||||
src: 'data:image/png;base64,abc',
|
||||
style: { objectFit: 'contain' },
|
||||
}
|
||||
|
||||
store.addChild('root', img as unknown as TemplateElement)
|
||||
|
||||
const el = store.getElementById('img_1') as ImageElement
|
||||
expect(el.style.objectFit).toBe('contain')
|
||||
})
|
||||
|
||||
it('updateElement changes objectFit', () => {
|
||||
const store = useTemplateStore()
|
||||
store.template = createTestTemplate()
|
||||
|
||||
const img: ImageElement = {
|
||||
id: 'img_2',
|
||||
type: 'image',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.fixed(50), height: sz.fixed(30) },
|
||||
src: 'data:image/png;base64,abc',
|
||||
style: { objectFit: 'contain' },
|
||||
}
|
||||
|
||||
store.addChild('root', img as unknown as TemplateElement)
|
||||
store.updateElement('img_2', { style: { objectFit: 'cover' } } as Partial<TemplateElement>)
|
||||
|
||||
const el = store.getElementById('img_2') as ImageElement
|
||||
expect(el.style.objectFit).toBe('cover')
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// 2.9 importTemplate validasyon
|
||||
// =============================================================================
|
||||
|
||||
describe('2.9 importTemplate validation', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('throws on invalid JSON', () => {
|
||||
const store = useTemplateStore()
|
||||
expect(() => store.importTemplate('not json')).toThrow('Geçersiz JSON')
|
||||
})
|
||||
|
||||
it('throws on missing root', () => {
|
||||
const store = useTemplateStore()
|
||||
const bad = JSON.stringify({ page: { width: 210, height: 297 } })
|
||||
expect(() => store.importTemplate(bad)).toThrow('root')
|
||||
})
|
||||
|
||||
it('throws on root that is not container', () => {
|
||||
const store = useTemplateStore()
|
||||
const bad = JSON.stringify({
|
||||
root: { type: 'text', id: 'r' },
|
||||
page: { width: 210, height: 297 },
|
||||
})
|
||||
expect(() => store.importTemplate(bad)).toThrow('container')
|
||||
})
|
||||
|
||||
it('throws on missing page', () => {
|
||||
const store = useTemplateStore()
|
||||
const bad = JSON.stringify({
|
||||
root: { type: 'container', id: 'root', children: [] },
|
||||
})
|
||||
expect(() => store.importTemplate(bad)).toThrow('page')
|
||||
})
|
||||
|
||||
it('throws on invalid page dimensions', () => {
|
||||
const store = useTemplateStore()
|
||||
const bad = JSON.stringify({
|
||||
root: { type: 'container', id: 'root', children: [] },
|
||||
page: { width: 'abc', height: 297 },
|
||||
})
|
||||
expect(() => store.importTemplate(bad)).toThrow('page')
|
||||
})
|
||||
|
||||
it('preserves previous state on failed import', () => {
|
||||
const store = useTemplateStore()
|
||||
store.template = createTestTemplate()
|
||||
store.addChild('root', createTextElement('keep_me', 'Keep'))
|
||||
|
||||
try {
|
||||
store.importTemplate('invalid json')
|
||||
} catch {
|
||||
// beklenen
|
||||
}
|
||||
|
||||
// Önceki state korunmuş olmalı
|
||||
expect(store.getElementById('keep_me')).toBeDefined()
|
||||
})
|
||||
|
||||
it('accepts valid template JSON', () => {
|
||||
const store = useTemplateStore()
|
||||
const tpl = createTestTemplate()
|
||||
tpl.name = 'Valid Import'
|
||||
const json = JSON.stringify(tpl)
|
||||
|
||||
store.importTemplate(json)
|
||||
expect(store.template.name).toBe('Valid Import')
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// 2.11 moveElement — tek layoutVersion bump
|
||||
// =============================================================================
|
||||
|
||||
describe('2.11 moveElement single layoutVersion bump', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('moveElement increments layoutVersion exactly once', () => {
|
||||
const store = useTemplateStore()
|
||||
store.template = createTestTemplate()
|
||||
|
||||
// İç içe container yapısı oluştur
|
||||
const child: ContainerElement = {
|
||||
id: 'child_container',
|
||||
type: 'container',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.auto(), height: sz.auto() },
|
||||
direction: 'column',
|
||||
gap: 0,
|
||||
padding: { top: 0, right: 0, bottom: 0, left: 0 },
|
||||
align: 'stretch',
|
||||
justify: 'start',
|
||||
style: {},
|
||||
children: [],
|
||||
}
|
||||
store.addChild('root', child as unknown as TemplateElement)
|
||||
store.addChild('root', createTextElement('el_move', 'Move me'))
|
||||
|
||||
const versionBefore = store.layoutVersion
|
||||
|
||||
store.moveElement('el_move', 'child_container')
|
||||
|
||||
// Tek bump: tam olarak 1 artmalı
|
||||
expect(store.layoutVersion).toBe(versionBefore + 1)
|
||||
|
||||
// Eleman taşınmış olmalı
|
||||
const moved = store.getElementById('el_move')
|
||||
expect(moved).toBeDefined()
|
||||
const parent = store.getParent('el_move')
|
||||
expect(parent?.id).toBe('child_container')
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// 3.1 Çoklu Seçim (Multi-Selection)
|
||||
// =============================================================================
|
||||
|
||||
describe('3.1 Multi-Selection', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('selectedElementIds starts empty', () => {
|
||||
const store = useEditorStore()
|
||||
expect(store.selectedElementIds.size).toBe(0)
|
||||
expect(store.selectedElementId).toBeNull()
|
||||
})
|
||||
|
||||
it('selectElement sets single selection', () => {
|
||||
const store = useEditorStore()
|
||||
store.selectElement('el_1')
|
||||
expect(store.selectedElementIds.size).toBe(1)
|
||||
expect(store.selectedElementId).toBe('el_1')
|
||||
})
|
||||
|
||||
it('selectElement clears previous selection', () => {
|
||||
const store = useEditorStore()
|
||||
store.selectElement('el_1')
|
||||
store.selectElement('el_2')
|
||||
expect(store.selectedElementIds.size).toBe(1)
|
||||
expect(store.selectedElementId).toBe('el_2')
|
||||
expect(store.isSelected('el_1')).toBe(false)
|
||||
})
|
||||
|
||||
it('toggleSelection adds to selection', () => {
|
||||
const store = useEditorStore()
|
||||
store.selectElement('el_1')
|
||||
store.toggleSelection('el_2')
|
||||
expect(store.selectedElementIds.size).toBe(2)
|
||||
expect(store.isSelected('el_1')).toBe(true)
|
||||
expect(store.isSelected('el_2')).toBe(true)
|
||||
})
|
||||
|
||||
it('toggleSelection removes from selection', () => {
|
||||
const store = useEditorStore()
|
||||
store.selectElement('el_1')
|
||||
store.toggleSelection('el_2')
|
||||
store.toggleSelection('el_1')
|
||||
expect(store.selectedElementIds.size).toBe(1)
|
||||
expect(store.isSelected('el_1')).toBe(false)
|
||||
expect(store.isSelected('el_2')).toBe(true)
|
||||
})
|
||||
|
||||
it('clearSelection clears all', () => {
|
||||
const store = useEditorStore()
|
||||
store.selectElement('el_1')
|
||||
store.toggleSelection('el_2')
|
||||
store.toggleSelection('el_3')
|
||||
expect(store.selectedElementIds.size).toBe(3)
|
||||
|
||||
store.clearSelection()
|
||||
expect(store.selectedElementIds.size).toBe(0)
|
||||
expect(store.selectedElementId).toBeNull()
|
||||
})
|
||||
|
||||
it('isSelected returns correct state', () => {
|
||||
const store = useEditorStore()
|
||||
expect(store.isSelected('el_1')).toBe(false)
|
||||
store.selectElement('el_1')
|
||||
expect(store.isSelected('el_1')).toBe(true)
|
||||
expect(store.isSelected('el_2')).toBe(false)
|
||||
})
|
||||
|
||||
it('selectedElementId returns first selected (backward compat)', () => {
|
||||
const store = useEditorStore()
|
||||
store.selectElement('el_1')
|
||||
store.toggleSelection('el_2')
|
||||
// İlk eklenen eleman
|
||||
expect(store.selectedElementId).toBe('el_1')
|
||||
})
|
||||
|
||||
it('selectElement(null) clears selection', () => {
|
||||
const store = useEditorStore()
|
||||
store.selectElement('el_1')
|
||||
store.selectElement(null)
|
||||
expect(store.selectedElementIds.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// 3.2 Z-Order Kontrolleri
|
||||
// =============================================================================
|
||||
|
||||
describe('3.2 Z-Order controls', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
function setupThreeElements() {
|
||||
const store = useTemplateStore()
|
||||
store.template = createTestTemplate()
|
||||
store.addChild('root', createTextElement('a', 'A'))
|
||||
store.addChild('root', createTextElement('b', 'B'))
|
||||
store.addChild('root', createTextElement('c', 'C'))
|
||||
return store
|
||||
}
|
||||
|
||||
it('bringForward moves element one step up', () => {
|
||||
const store = setupThreeElements()
|
||||
// Sıra: [a, b, c] → bringForward(a) → [b, a, c]
|
||||
store.bringForward('a')
|
||||
expect(store.template.root.children.map(c => c.id)).toEqual(['b', 'a', 'c'])
|
||||
})
|
||||
|
||||
it('sendBackward moves element one step down', () => {
|
||||
const store = setupThreeElements()
|
||||
// Sıra: [a, b, c] → sendBackward(c) → [a, c, b]
|
||||
store.sendBackward('c')
|
||||
expect(store.template.root.children.map(c => c.id)).toEqual(['a', 'c', 'b'])
|
||||
})
|
||||
|
||||
it('bringToFront moves element to end', () => {
|
||||
const store = setupThreeElements()
|
||||
// Sıra: [a, b, c] → bringToFront(a) → [b, c, a]
|
||||
store.bringToFront('a')
|
||||
expect(store.template.root.children.map(c => c.id)).toEqual(['b', 'c', 'a'])
|
||||
})
|
||||
|
||||
it('sendToBack moves element to beginning', () => {
|
||||
const store = setupThreeElements()
|
||||
// Sıra: [a, b, c] → sendToBack(c) → [c, a, b]
|
||||
store.sendToBack('c')
|
||||
expect(store.template.root.children.map(c => c.id)).toEqual(['c', 'a', 'b'])
|
||||
})
|
||||
|
||||
it('bringForward on last element is no-op', () => {
|
||||
const store = setupThreeElements()
|
||||
store.bringForward('c')
|
||||
expect(store.template.root.children.map(c => c.id)).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('sendBackward on first element is no-op', () => {
|
||||
const store = setupThreeElements()
|
||||
store.sendBackward('a')
|
||||
expect(store.template.root.children.map(c => c.id)).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('bringToFront on last element is no-op', () => {
|
||||
const store = setupThreeElements()
|
||||
store.bringToFront('c')
|
||||
expect(store.template.root.children.map(c => c.id)).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('sendToBack on first element is no-op', () => {
|
||||
const store = setupThreeElements()
|
||||
store.sendToBack('a')
|
||||
expect(store.template.root.children.map(c => c.id)).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// 3.3 Dinamik Image Binding
|
||||
// =============================================================================
|
||||
|
||||
describe('3.3 Dynamic Image Binding', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('ImageElement supports binding field', () => {
|
||||
const store = useTemplateStore()
|
||||
store.template = createTestTemplate()
|
||||
|
||||
const img: ImageElement = {
|
||||
id: 'img_dyn',
|
||||
type: 'image',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.fixed(40), height: sz.fixed(40) },
|
||||
binding: { type: 'scalar', path: 'firma.logo' },
|
||||
style: { objectFit: 'contain' },
|
||||
}
|
||||
|
||||
store.addChild('root', img as unknown as TemplateElement)
|
||||
|
||||
const el = store.getElementById('img_dyn') as ImageElement
|
||||
expect(el.binding).toBeDefined()
|
||||
expect(el.binding!.path).toBe('firma.logo')
|
||||
expect(el.src).toBeUndefined()
|
||||
})
|
||||
|
||||
it('can switch from static to dynamic mode', () => {
|
||||
const store = useTemplateStore()
|
||||
store.template = createTestTemplate()
|
||||
|
||||
const img: ImageElement = {
|
||||
id: 'img_switch',
|
||||
type: 'image',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.fixed(40), height: sz.fixed(40) },
|
||||
src: 'data:image/png;base64,abc',
|
||||
style: {},
|
||||
}
|
||||
|
||||
store.addChild('root', img as unknown as TemplateElement)
|
||||
|
||||
// Dinamik moda geç
|
||||
store.updateElement('img_switch', {
|
||||
src: undefined,
|
||||
binding: { type: 'scalar', path: 'firma.logo' },
|
||||
} as Partial<TemplateElement>)
|
||||
|
||||
const el = store.getElementById('img_switch') as ImageElement
|
||||
expect(el.binding).toBeDefined()
|
||||
expect(el.binding!.path).toBe('firma.logo')
|
||||
})
|
||||
|
||||
it('can switch from dynamic to static mode', () => {
|
||||
const store = useTemplateStore()
|
||||
store.template = createTestTemplate()
|
||||
|
||||
const img: ImageElement = {
|
||||
id: 'img_back',
|
||||
type: 'image',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.fixed(40), height: sz.fixed(40) },
|
||||
binding: { type: 'scalar', path: 'firma.logo' },
|
||||
style: {},
|
||||
}
|
||||
|
||||
store.addChild('root', img as unknown as TemplateElement)
|
||||
|
||||
store.updateElement('img_back', {
|
||||
binding: undefined,
|
||||
src: 'data:image/png;base64,xyz',
|
||||
} as Partial<TemplateElement>)
|
||||
|
||||
const el = store.getElementById('img_back') as ImageElement
|
||||
expect(el.src).toBe('data:image/png;base64,xyz')
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,8 @@ import { ref, computed } from 'vue'
|
||||
import type { TemplateElement } from '../core/types'
|
||||
|
||||
export const useEditorStore = defineStore('editor', () => {
|
||||
const selectedElementId = ref<string | null>(null)
|
||||
/** Seçili eleman ID'leri — çoklu seçim desteği */
|
||||
const selectedElementIds = ref<Set<string>>(new Set())
|
||||
const zoom = ref(1)
|
||||
const panX = ref(0)
|
||||
const panY = ref(0)
|
||||
@@ -15,12 +16,36 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
|
||||
const zoomPercent = computed(() => Math.round(zoom.value * 100))
|
||||
|
||||
/** Geriye uyumluluk: tek seçili eleman ID'si (ilk seçili veya null) */
|
||||
const selectedElementId = computed<string | null>(() => {
|
||||
const ids = selectedElementIds.value
|
||||
if (ids.size === 0) return null
|
||||
return ids.values().next().value ?? null
|
||||
})
|
||||
|
||||
/** Tek eleman seç (önceki seçimi temizler) */
|
||||
function selectElement(id: string | null) {
|
||||
selectedElementId.value = id
|
||||
selectedElementIds.value = id ? new Set([id]) : new Set()
|
||||
}
|
||||
|
||||
/** Shift+click: seçime ekle/çıkar (toggle) */
|
||||
function toggleSelection(id: string) {
|
||||
const next = new Set(selectedElementIds.value)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.add(id)
|
||||
}
|
||||
selectedElementIds.value = next
|
||||
}
|
||||
|
||||
/** Eleman seçili mi? */
|
||||
function isSelected(id: string): boolean {
|
||||
return selectedElementIds.value.has(id)
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedElementId.value = null
|
||||
selectedElementIds.value = new Set()
|
||||
}
|
||||
|
||||
function setZoom(value: number) {
|
||||
@@ -56,6 +81,7 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
}
|
||||
|
||||
return {
|
||||
selectedElementIds,
|
||||
selectedElementId,
|
||||
zoom,
|
||||
panX,
|
||||
@@ -65,6 +91,8 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
dropTargetContainerId,
|
||||
zoomPercent,
|
||||
selectElement,
|
||||
toggleSelection,
|
||||
isSelected,
|
||||
clearSelection,
|
||||
setZoom,
|
||||
setPan,
|
||||
|
||||
@@ -139,14 +139,27 @@ export const useTemplateStore = defineStore('template', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** Element'i başka bir container'a taşı */
|
||||
/** Element'i başka bir container'a taşı (tek layoutVersion bump) */
|
||||
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)
|
||||
// Ağaçtan kaldır (bump'sız)
|
||||
const parent = getParent(elementId)
|
||||
if (parent) {
|
||||
const idx = parent.children.findIndex(c => c.id === elementId)
|
||||
if (idx !== -1) parent.children.splice(idx, 1)
|
||||
}
|
||||
// Hedef container'a ekle (bump'sız)
|
||||
const target = getElementById(targetParentId)
|
||||
if (target && isContainer(target)) {
|
||||
if (index !== undefined) {
|
||||
target.children.splice(index, 0, el)
|
||||
} else {
|
||||
target.children.push(el)
|
||||
}
|
||||
}
|
||||
// Tek bump
|
||||
bumpLayoutVersion()
|
||||
}
|
||||
|
||||
/** Absolute pozisyon güncelle */
|
||||
@@ -185,14 +198,62 @@ export const useTemplateStore = defineStore('template', () => {
|
||||
bumpLayoutVersion()
|
||||
}
|
||||
|
||||
/** Bir adım öne getir */
|
||||
function bringForward(elementId: string) {
|
||||
const parent = getParent(elementId)
|
||||
if (!parent) return
|
||||
const idx = parent.children.findIndex(c => c.id === elementId)
|
||||
if (idx < 0 || idx >= parent.children.length - 1) return
|
||||
reorderChild(parent.id, idx, idx + 1)
|
||||
}
|
||||
|
||||
/** Bir adım arkaya gönder */
|
||||
function sendBackward(elementId: string) {
|
||||
const parent = getParent(elementId)
|
||||
if (!parent) return
|
||||
const idx = parent.children.findIndex(c => c.id === elementId)
|
||||
if (idx <= 0) return
|
||||
reorderChild(parent.id, idx, idx - 1)
|
||||
}
|
||||
|
||||
/** En öne getir */
|
||||
function bringToFront(elementId: string) {
|
||||
const parent = getParent(elementId)
|
||||
if (!parent) return
|
||||
const idx = parent.children.findIndex(c => c.id === elementId)
|
||||
if (idx < 0 || idx >= parent.children.length - 1) return
|
||||
reorderChild(parent.id, idx, parent.children.length - 1)
|
||||
}
|
||||
|
||||
/** En arkaya gönder */
|
||||
function sendToBack(elementId: string) {
|
||||
const parent = getParent(elementId)
|
||||
if (!parent) return
|
||||
const idx = parent.children.findIndex(c => c.id === elementId)
|
||||
if (idx <= 0) return
|
||||
reorderChild(parent.id, idx, 0)
|
||||
}
|
||||
|
||||
/** Şablonu JSON olarak dışa aktar */
|
||||
function exportTemplate(): string {
|
||||
return JSON.stringify(template.value, null, 2)
|
||||
}
|
||||
|
||||
/** JSON'dan şablon yükle */
|
||||
/** JSON'dan şablon yükle (validasyonlu) */
|
||||
function importTemplate(json: string) {
|
||||
const parsed = JSON.parse(json) as Template
|
||||
let parsed: Template
|
||||
try {
|
||||
parsed = JSON.parse(json) as Template
|
||||
} catch (e) {
|
||||
throw new Error(`Geçersiz JSON: ${e instanceof Error ? e.message : String(e)}`)
|
||||
}
|
||||
// Minimum schema doğrulaması
|
||||
if (!parsed.root || parsed.root.type !== 'container') {
|
||||
throw new Error('Geçersiz şablon: root alanı eksik veya container değil')
|
||||
}
|
||||
if (!parsed.page || typeof parsed.page.width !== 'number' || typeof parsed.page.height !== 'number') {
|
||||
throw new Error('Geçersiz şablon: page alanı eksik veya geçersiz')
|
||||
}
|
||||
template.value = parsed
|
||||
bumpLayoutVersion()
|
||||
}
|
||||
@@ -269,6 +330,10 @@ export const useTemplateStore = defineStore('template', () => {
|
||||
updateElementSize,
|
||||
updateElement,
|
||||
reorderChild,
|
||||
bringForward,
|
||||
sendBackward,
|
||||
bringToFront,
|
||||
sendToBack,
|
||||
exportTemplate,
|
||||
importTemplate,
|
||||
resetTemplate,
|
||||
|
||||
@@ -1,38 +1,126 @@
|
||||
/// Layout Engine Web Worker
|
||||
/// Template JSON + Data JSON → Layout WASM → LayoutResult
|
||||
/// Font loading is dynamic — fetches from backend API based on template needs.
|
||||
|
||||
import init, { loadFonts, computeLayout, generateBarcode } from '../core/wasm-layout/dreport_layout.js'
|
||||
import init, { loadFonts, addFonts, 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' },
|
||||
]
|
||||
/** Configurable font API base URL. Default: same origin /api/fonts */
|
||||
let fontApiBase = '/api/fonts'
|
||||
|
||||
/** Font catalog from backend API */
|
||||
interface FontVariantInfo {
|
||||
weight: number
|
||||
italic: boolean
|
||||
}
|
||||
interface FontFamilyInfo {
|
||||
family: string
|
||||
variants: FontVariantInfo[]
|
||||
}
|
||||
let fontCatalog: FontFamilyInfo[] = []
|
||||
|
||||
/** Track which font families are already loaded into WASM */
|
||||
const loadedFamilies = new Set<string>()
|
||||
|
||||
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[] = []
|
||||
// Fetch font catalog from backend
|
||||
try {
|
||||
const res = await fetch(fontApiBase)
|
||||
if (res.ok) {
|
||||
fontCatalog = await res.json()
|
||||
console.log(`[layout-worker] Font kataloğu yüklendi (${fontCatalog.length} aile)`)
|
||||
} else {
|
||||
console.warn(`[layout-worker] Font kataloğu alınamadı (HTTP ${res.status}), static fallback deneniyor`)
|
||||
await loadStaticFallback()
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
console.warn('[layout-worker] Font API erişilemedi, static fallback deneniyor')
|
||||
await loadStaticFallback()
|
||||
return
|
||||
}
|
||||
|
||||
// Load default fonts (Noto Sans + Noto Sans Mono)
|
||||
await ensureFamiliesLoaded(['Noto Sans', 'Noto Sans Mono'])
|
||||
console.log('[layout-worker] Hazır')
|
||||
}
|
||||
|
||||
/** Fallback: load fonts from static /fonts/ directory (backwards compat) */
|
||||
async function loadStaticFallback() {
|
||||
const STATIC_FONTS = [
|
||||
'/fonts/NotoSans-Regular.ttf',
|
||||
'/fonts/NotoSans-Bold.ttf',
|
||||
'/fonts/NotoSans-Italic.ttf',
|
||||
'/fonts/NotoSans-BoldItalic.ttf',
|
||||
'/fonts/NotoSansMono-Regular.ttf',
|
||||
]
|
||||
|
||||
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))
|
||||
STATIC_FONTS.map(async (path) => {
|
||||
const url = new URL(path, self.location.origin).href
|
||||
const res = await fetch(url)
|
||||
if (res.ok) {
|
||||
buffers.push(new Uint8Array(await res.arrayBuffer()))
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
loadFonts(JSON.stringify(families), buffers)
|
||||
console.log('[layout-worker] Hazır')
|
||||
if (buffers.length > 0) {
|
||||
loadFonts(buffers)
|
||||
loadedFamilies.add('noto sans')
|
||||
loadedFamilies.add('noto sans mono')
|
||||
console.log(`[layout-worker] Static fallback: ${buffers.length} font yüklendi`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Load all variants of given families from the API into WASM */
|
||||
async function ensureFamiliesLoaded(families: string[]): Promise<void> {
|
||||
const toLoad = families.filter(f => !loadedFamilies.has(f.toLowerCase()))
|
||||
if (toLoad.length === 0) return
|
||||
|
||||
const buffers: Uint8Array[] = []
|
||||
|
||||
for (const family of toLoad) {
|
||||
const info = fontCatalog.find(f => f.family.toLowerCase() === family.toLowerCase())
|
||||
if (!info) {
|
||||
console.warn(`[layout-worker] Font ailesi bulunamadı: ${family}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const fetches = info.variants.map(async (v) => {
|
||||
const url = `${fontApiBase}/${encodeURIComponent(info.family)}/${v.weight}/${v.italic}`
|
||||
const res = await fetch(url)
|
||||
if (res.ok) {
|
||||
return new Uint8Array(await res.arrayBuffer())
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const results = await Promise.all(fetches)
|
||||
for (const buf of results) {
|
||||
if (buf && buf.byteLength > 0) {
|
||||
buffers.push(buf)
|
||||
}
|
||||
}
|
||||
loadedFamilies.add(family.toLowerCase())
|
||||
}
|
||||
|
||||
if (buffers.length > 0) {
|
||||
if (loadedFamilies.size <= toLoad.length) {
|
||||
// First load — use loadFonts
|
||||
loadFonts(buffers)
|
||||
} else {
|
||||
// Subsequent loads — use addFonts
|
||||
addFonts(buffers)
|
||||
}
|
||||
console.log(`[layout-worker] ${toLoad.join(', ')} yüklendi (${buffers.length} variant)`)
|
||||
}
|
||||
}
|
||||
|
||||
function ensureInit(): Promise<void> {
|
||||
@@ -45,14 +133,32 @@ function ensureInit(): Promise<void> {
|
||||
type WorkerMessage =
|
||||
| { type: 'compile'; templateJson: string; dataJson: string; id: number }
|
||||
| { type: 'barcode'; format: string; value: string; width: number; height: number; includeText: boolean; id: number }
|
||||
| { type: 'configure'; fontApiBase?: string }
|
||||
|
||||
self.onmessage = async (e: MessageEvent<WorkerMessage>) => {
|
||||
const msg = e.data
|
||||
|
||||
if (msg.type === 'configure') {
|
||||
if (msg.fontApiBase) {
|
||||
fontApiBase = msg.fontApiBase
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.type === 'compile') {
|
||||
try {
|
||||
await ensureInit()
|
||||
|
||||
// Extract font families from template and ensure they're loaded
|
||||
try {
|
||||
const tpl = JSON.parse(msg.templateJson)
|
||||
if (Array.isArray(tpl.fonts) && tpl.fonts.length > 0) {
|
||||
await ensureFamiliesLoaded(tpl.fonts)
|
||||
}
|
||||
} catch {
|
||||
// Template parse failure will be caught by computeLayout below
|
||||
}
|
||||
|
||||
const t0 = performance.now()
|
||||
const resultJson = computeLayout(msg.templateJson, msg.dataJson)
|
||||
const layout: LayoutResult = JSON.parse(resultJson)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@@ -5,5 +5,10 @@ export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
exclude: [
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'tests/visual/**',
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user