mirror of
https://github.com/duhanbalci/dreport.git
synced 2026-07-02 02:49:16 +00:00
improvements
This commit is contained in:
@@ -9,13 +9,13 @@ import InteractionOverlay from './InteractionOverlay.vue'
|
||||
|
||||
const templateStore = useTemplateStore()
|
||||
const editorStore = useEditorStore()
|
||||
const { typstMarkup } = storeToRefs(templateStore)
|
||||
const { template, mockData } = storeToRefs(templateStore)
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const containerWidth = ref(800)
|
||||
|
||||
// Typst compiler
|
||||
const { svg, error, compiling, layout, dispose } = useTypstCompiler(typstMarkup)
|
||||
// Typst compiler — template + data'yı worker'a gönderir, WASM ile derlenir
|
||||
const { svg, error, compiling, layout, dispose } = useTypstCompiler(template, mockData)
|
||||
|
||||
// mm → px dönüşüm katsayısı
|
||||
const scale = computed(() => {
|
||||
@@ -37,6 +37,24 @@ const pageStyle = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// Pan transform — sayfa container'ına uygulanacak
|
||||
const panTransform = computed(() => {
|
||||
if (editorStore.panX === 0 && editorStore.panY === 0) return undefined
|
||||
return `translate(${editorStore.panX}px, ${editorStore.panY}px)`
|
||||
})
|
||||
|
||||
// Pan: Space+drag veya orta fare tuşu
|
||||
const isPanning = ref(false)
|
||||
const panStart = ref({ x: 0, y: 0 })
|
||||
const spaceHeld = ref(false)
|
||||
|
||||
// Pan cursor style
|
||||
const canvasCursor = computed(() => {
|
||||
if (isPanning.value) return 'grabbing'
|
||||
if (spaceHeld.value) return 'grab'
|
||||
return 'default'
|
||||
})
|
||||
|
||||
// Container boyutunu izle
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
@@ -48,11 +66,15 @@ onMounted(() => {
|
||||
})
|
||||
resizeObserver.observe(containerRef.value)
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('keyup', onKeyUp)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
dispose()
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
})
|
||||
|
||||
// Zoom
|
||||
@@ -63,27 +85,68 @@ function onWheel(e: WheelEvent) {
|
||||
editorStore.setZoom(editorStore.zoom + delta)
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.code === 'Space' && !e.repeat && !(e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement || e.target instanceof HTMLTextAreaElement)) {
|
||||
e.preventDefault()
|
||||
spaceHeld.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyUp(e: KeyboardEvent) {
|
||||
if (e.code === 'Space') {
|
||||
spaceHeld.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (e.button === 1 || (e.button === 0 && spaceHeld.value)) {
|
||||
e.preventDefault()
|
||||
isPanning.value = true
|
||||
panStart.value = { x: e.clientX - editorStore.panX, y: e.clientY - editorStore.panY }
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!isPanning.value) return
|
||||
editorStore.setPan(e.clientX - panStart.value.x, e.clientY - panStart.value.y)
|
||||
}
|
||||
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
if (isPanning.value) {
|
||||
isPanning.value = false
|
||||
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="editor-canvas" ref="containerRef" @wheel="onWheel">
|
||||
<!-- Hata banner -->
|
||||
<div class="editor-canvas-wrapper">
|
||||
<!-- Scroll alanı -->
|
||||
<div
|
||||
class="editor-canvas"
|
||||
ref="containerRef"
|
||||
:style="{ cursor: canvasCursor }"
|
||||
@wheel="onWheel"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@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>
|
||||
</div>
|
||||
|
||||
<!-- Sabit overlay'ler — scroll dışında -->
|
||||
<div v-if="error" class="editor-canvas__error">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Derleme göstergesi -->
|
||||
<div v-if="compiling" class="editor-canvas__compiling">
|
||||
Derleniyor...
|
||||
</div>
|
||||
|
||||
<!-- Sayfa -->
|
||||
<div class="editor-canvas__page" :style="pageStyle">
|
||||
<TypstSvgLayer :svg="svg" />
|
||||
<InteractionOverlay :scale="scale" :layout="layout" :page-width-pt="templateStore.template.page.width * 2.8346" />
|
||||
</div>
|
||||
|
||||
<!-- Zoom göstergesi -->
|
||||
<div class="editor-canvas__zoom">
|
||||
%{{ editorStore.zoomPercent }}
|
||||
</div>
|
||||
@@ -91,16 +154,21 @@ function onWheel(e: WheelEvent) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.editor-canvas {
|
||||
.editor-canvas-wrapper {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.editor-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
background: #e5e7eb;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.editor-canvas__page {
|
||||
|
||||
@@ -115,19 +115,22 @@ function findDeepestContainer(mouseX: number, mouseY: number, excludeId?: string
|
||||
}
|
||||
|
||||
/** Container içinde drop index hesapla */
|
||||
function computeDropIndex(container: ContainerElement, mouseY: number, excludeId?: string) {
|
||||
function computeDropIndex(container: ContainerElement, mouseX: number, mouseY: number, excludeId?: string) {
|
||||
const s = ptToPx.value
|
||||
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]
|
||||
if (!l) continue
|
||||
const centerY = l.y * s + (l.height * s) / 2
|
||||
if (mouseY < centerY) {
|
||||
visualIdx = i
|
||||
break
|
||||
if (isRow) {
|
||||
const centerX = l.x * s + (l.width * s) / 2
|
||||
if (mouseX < centerX) { visualIdx = i; break }
|
||||
} else {
|
||||
const centerY = l.y * s + (l.height * s) / 2
|
||||
if (mouseY < centerY) { visualIdx = i; break }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,7 +164,7 @@ function updateDropFromMouse(mouseX: number, mouseY: number, excludeId?: string)
|
||||
const container = findDeepestContainer(mouseX, mouseY, excludeId)
|
||||
dropTargetContainerId.value = container.id
|
||||
|
||||
const { visualIdx, logicalIdx } = computeDropIndex(container, mouseY, excludeId)
|
||||
const { visualIdx, logicalIdx } = computeDropIndex(container, mouseX, mouseY, excludeId)
|
||||
dropVisualIndex.value = visualIdx
|
||||
dropLogicalIndex.value = logicalIdx
|
||||
}
|
||||
@@ -183,24 +186,67 @@ const dropIndicatorStyle = computed(() => {
|
||||
|
||||
const s = ptToPx.value
|
||||
const idx = dropVisualIndex.value
|
||||
const isRow = container.direction === 'row'
|
||||
|
||||
// Sürüklenen elemanı çıkar
|
||||
const dragId = dragElementId.value
|
||||
const flowChildren = container.children.filter(c => c.position.type !== 'absolute' && c.id !== dragId)
|
||||
|
||||
// Gap'in ortasına yerleştir: üstteki elemanın alt kenarı ile alttaki elemanın üst kenarı arası
|
||||
const cl = props.layout[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
|
||||
} else if (idx < flowChildren.length && idx > 0) {
|
||||
const left = props.layout[flowChildren[idx - 1].id]
|
||||
const right = props.layout[flowChildren[idx].id]
|
||||
if (left && right) {
|
||||
const leftEnd = (left.x + left.width) * s
|
||||
const rightStart = right.x * s
|
||||
x = (leftEnd + rightStart) / 2
|
||||
}
|
||||
} else if (idx === 0 && flowChildren.length === 0) {
|
||||
x = cl.x * s + 8
|
||||
} else if (flowChildren.length > 0) {
|
||||
const last = flowChildren[flowChildren.length - 1]
|
||||
const l = props.layout[last.id]
|
||||
if (l) {
|
||||
const gapPx = container.gap * props.scale
|
||||
x = (l.x + l.width) * s + gapPx / 2
|
||||
}
|
||||
}
|
||||
|
||||
const top = cl.y * s
|
||||
const height = cl.height * s
|
||||
|
||||
return {
|
||||
position: 'absolute' as const,
|
||||
left: `${x}px`,
|
||||
top: `${top}px`,
|
||||
width: '2px',
|
||||
height: `${height}px`,
|
||||
background: 'rgb(59, 130, 246)',
|
||||
borderRadius: '1px',
|
||||
zIndex: 1000,
|
||||
pointerEvents: 'none' as const,
|
||||
}
|
||||
}
|
||||
|
||||
// Column container: yatay gösterge çizgisi
|
||||
let y = 0
|
||||
if (idx === 0 && flowChildren.length > 0) {
|
||||
// İlk pozisyon: ilk elemanın üst kenarı ile container üst kenarı arası
|
||||
const l = props.layout[flowChildren[0].id]
|
||||
const cl = props.layout[container.id]
|
||||
if (l && cl) {
|
||||
y = (cl.y * s + l.y * s) / 2 // container top ile eleman top arası
|
||||
} else if (l) {
|
||||
y = l.y * s - 4
|
||||
if (l) {
|
||||
y = (cl.y * s + l.y * s) / 2
|
||||
} else {
|
||||
y = cl.y * s - 4
|
||||
}
|
||||
} else if (idx < flowChildren.length && idx > 0) {
|
||||
// Ortada: üstteki elemanın altı ile alttaki elemanın üstü arası
|
||||
const above = props.layout[flowChildren[idx - 1].id]
|
||||
const below = props.layout[flowChildren[idx].id]
|
||||
if (above && below) {
|
||||
@@ -209,11 +255,8 @@ const dropIndicatorStyle = computed(() => {
|
||||
y = (aboveBottom + belowTop) / 2
|
||||
}
|
||||
} else if (idx === 0 && flowChildren.length === 0) {
|
||||
// Boş container
|
||||
const cl = props.layout[container.id]
|
||||
if (cl) y = cl.y * s + 8
|
||||
y = cl.y * s + 8
|
||||
} else if (flowChildren.length > 0) {
|
||||
// Son pozisyon: son elemanın altından gap kadar aşağıda
|
||||
const last = flowChildren[flowChildren.length - 1]
|
||||
const l = props.layout[last.id]
|
||||
if (l) {
|
||||
@@ -222,9 +265,8 @@ const dropIndicatorStyle = computed(() => {
|
||||
}
|
||||
}
|
||||
|
||||
const cl = props.layout[container.id]
|
||||
const x = cl ? cl.x * s : 0
|
||||
const width = cl ? cl.width * s : 100
|
||||
const x = cl.x * s
|
||||
const width = cl.width * s
|
||||
|
||||
return {
|
||||
position: 'absolute' as const,
|
||||
@@ -392,6 +434,7 @@ const resizeHandle = ref('')
|
||||
const resizeStart = ref({ mouseX: 0, mouseY: 0, x: 0, y: 0, width: 0, height: 0 })
|
||||
const resizeGhost = ref({ x: 0, y: 0, width: 0, height: 0 })
|
||||
const resizeFinalMm = ref({ width: 0, height: 0 })
|
||||
const resizeAspectRatio = ref(0) // > 0 ise aspect ratio korunur (width / height)
|
||||
|
||||
function onResizeStart(e: PointerEvent, elId: string, handle: string) {
|
||||
e.stopPropagation()
|
||||
@@ -407,6 +450,10 @@ function onResizeStart(e: PointerEvent, elId: string, handle: string) {
|
||||
const s = ptToPx.value
|
||||
const ptToMm = 1 / 2.8346
|
||||
|
||||
// Barkod 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
|
||||
|
||||
resizeStart.value = {
|
||||
mouseX: e.clientX, mouseY: e.clientY,
|
||||
x: l.x * s, y: l.y * s,
|
||||
@@ -426,6 +473,7 @@ function onResizeMove(e: PointerEvent) {
|
||||
const dy = e.clientY - resizeStart.value.mouseY
|
||||
const handle = resizeHandle.value
|
||||
const pxToMm = 1 / props.scale
|
||||
const ar = resizeAspectRatio.value
|
||||
|
||||
let gx = resizeStart.value.x, gy = resizeStart.value.y
|
||||
let gw = resizeStart.value.width, gh = resizeStart.value.height
|
||||
@@ -435,6 +483,11 @@ function onResizeMove(e: PointerEvent) {
|
||||
if (handle.includes('s')) gh = Math.max(10, resizeStart.value.height + dy)
|
||||
if (handle.includes('n')) { gh = Math.max(10, resizeStart.value.height - dy); gy = resizeStart.value.y + dy }
|
||||
|
||||
// Aspect ratio koruma (barkod)
|
||||
if (ar > 0) {
|
||||
gh = gw / ar
|
||||
}
|
||||
|
||||
resizeGhost.value = { x: gx, y: gy, width: gw, height: gh }
|
||||
|
||||
const startWMm = resizeStart.value.width * pxToMm
|
||||
@@ -445,6 +498,10 @@ function onResizeMove(e: PointerEvent) {
|
||||
if (handle.includes('s')) hMm = Math.max(3, startHMm + dy * pxToMm)
|
||||
if (handle.includes('n')) hMm = Math.max(3, startHMm - dy * pxToMm)
|
||||
|
||||
if (ar > 0) {
|
||||
hMm = wMm / ar
|
||||
}
|
||||
|
||||
resizeFinalMm.value = { width: Math.round(wMm * 10) / 10, height: Math.round(hMm * 10) / 10 }
|
||||
}
|
||||
|
||||
@@ -485,7 +542,7 @@ function onToolboxDragLeave() {
|
||||
clearDropTarget()
|
||||
}
|
||||
|
||||
function onToolboxDrop(e: DragEvent) {
|
||||
function onToolboxDrop(_e: DragEvent) {
|
||||
const newEl = editorStore.draggedNewElement
|
||||
if (!newEl) return
|
||||
|
||||
@@ -532,10 +589,17 @@ const isAnyDragActive = computed(() =>
|
||||
|
||||
<!-- Resize handles -->
|
||||
<template v-if="editorStore.selectedElementId === el.id && !isResizing">
|
||||
<div class="resize-handle resize-handle--se" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'se')" />
|
||||
<div class="resize-handle resize-handle--sw" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'sw')" />
|
||||
<div class="resize-handle resize-handle--ne" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'ne')" />
|
||||
<div class="resize-handle resize-handle--nw" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'nw')" />
|
||||
<template v-if="el.type === 'barcode'">
|
||||
<!-- Barkod: sadece yatay resize (aspect ratio korunur) -->
|
||||
<div class="resize-handle resize-handle--e" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'e')" />
|
||||
<div class="resize-handle resize-handle--w" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'w')" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="resize-handle resize-handle--se" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'se')" />
|
||||
<div class="resize-handle resize-handle--sw" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'sw')" />
|
||||
<div class="resize-handle resize-handle--ne" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'ne')" />
|
||||
<div class="resize-handle resize-handle--nw" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'nw')" />
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -627,6 +691,8 @@ const isAnyDragActive = computed(() =>
|
||||
.resize-handle--sw { left: -3px; bottom: -3px; cursor: sw-resize; }
|
||||
.resize-handle--ne { right: -3px; top: -3px; cursor: ne-resize; }
|
||||
.resize-handle--nw { left: -3px; top: -3px; cursor: nw-resize; }
|
||||
.resize-handle--e { right: -3px; top: calc(50% - 3px); cursor: e-resize; }
|
||||
.resize-handle--w { left: -3px; top: calc(50% - 3px); cursor: w-resize; }
|
||||
|
||||
/* Drag ghost */
|
||||
.drag-ghost {
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useTemplateStore } from '../../stores/template'
|
||||
import { useEditorStore } from '../../stores/editor'
|
||||
import { isContainer } from '../../core/types'
|
||||
import { useSchemaStore } from '../../stores/schema'
|
||||
import { isContainer, sz } from '../../core/types'
|
||||
import { schemaFormatToFormatType, defaultAlignForSchema } from '../../core/schema-parser'
|
||||
import type {
|
||||
TemplateElement,
|
||||
ContainerElement,
|
||||
StaticTextElement,
|
||||
LineElement,
|
||||
RepeatingTableElement,
|
||||
ImageElement,
|
||||
PageNumberElement,
|
||||
BarcodeElement,
|
||||
TableColumn,
|
||||
TextStyle,
|
||||
SizeValue,
|
||||
FormatType,
|
||||
} from '../../core/types'
|
||||
|
||||
const templateStore = useTemplateStore()
|
||||
const editorStore = useEditorStore()
|
||||
const schemaStore = useSchemaStore()
|
||||
|
||||
const selectedElement = computed(() => {
|
||||
const id = editorStore.selectedElementId
|
||||
@@ -21,12 +30,6 @@ const selectedElement = computed(() => {
|
||||
return templateStore.getElementById(id) ?? null
|
||||
})
|
||||
|
||||
const parentElement = computed(() => {
|
||||
const id = editorStore.selectedElementId
|
||||
if (!id) return null
|
||||
return templateStore.getParent(id) ?? null
|
||||
})
|
||||
|
||||
// --- Generic updater ---
|
||||
|
||||
function update(updates: Partial<TemplateElement>) {
|
||||
@@ -59,6 +62,188 @@ function togglePositioning() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Table helpers ---
|
||||
|
||||
let colIdCounter = Date.now()
|
||||
function nextColId() {
|
||||
return `col_${(++colIdCounter).toString(36)}`
|
||||
}
|
||||
|
||||
function updateTableDataSource(path: string) {
|
||||
// Veri kaynağı değişince schema'dan sütunları otomatik doldur
|
||||
const itemFields = schemaStore.getArrayItemFields(path)
|
||||
if (itemFields.length > 0) {
|
||||
const columns: TableColumn[] = itemFields.map(field => ({
|
||||
id: nextColId(),
|
||||
field: field.key,
|
||||
title: field.title,
|
||||
width: sz.auto(),
|
||||
align: defaultAlignForSchema(field),
|
||||
format: schemaFormatToFormatType(field.format),
|
||||
}))
|
||||
update({
|
||||
dataSource: { type: 'array', path },
|
||||
columns,
|
||||
} as Partial<TemplateElement>)
|
||||
} else {
|
||||
update({ dataSource: { type: 'array', path } } as Partial<TemplateElement>)
|
||||
}
|
||||
}
|
||||
|
||||
function updateTableStyle(key: string, value: unknown) {
|
||||
const el = selectedElement.value as RepeatingTableElement
|
||||
if (!el || el.type !== 'repeating_table') return
|
||||
const newStyle = { ...el.style, [key]: value }
|
||||
if (value === undefined || value === '') delete (newStyle as Record<string, unknown>)[key]
|
||||
update({ style: newStyle } as Partial<TemplateElement>)
|
||||
}
|
||||
|
||||
function updateColumn(colId: string, updates: Partial<TableColumn>) {
|
||||
const el = selectedElement.value as RepeatingTableElement
|
||||
if (!el || el.type !== 'repeating_table') return
|
||||
const columns = el.columns.map(c => c.id === colId ? { ...c, ...updates } : c)
|
||||
update({ columns } as Partial<TemplateElement>)
|
||||
}
|
||||
|
||||
function addColumn() {
|
||||
const el = selectedElement.value as RepeatingTableElement
|
||||
if (!el || el.type !== 'repeating_table') return
|
||||
const newCol: TableColumn = {
|
||||
id: nextColId(),
|
||||
field: 'alan',
|
||||
title: 'Yeni Sutun',
|
||||
width: sz.auto(),
|
||||
align: 'left',
|
||||
}
|
||||
update({ columns: [...el.columns, newCol] } as Partial<TemplateElement>)
|
||||
}
|
||||
|
||||
function removeColumn(colId: string) {
|
||||
const el = selectedElement.value as RepeatingTableElement
|
||||
if (!el || el.type !== 'repeating_table') return
|
||||
update({ columns: el.columns.filter(c => c.id !== colId) } as Partial<TemplateElement>)
|
||||
}
|
||||
|
||||
function moveColumn(colId: string, direction: -1 | 1) {
|
||||
const el = selectedElement.value as RepeatingTableElement
|
||||
if (!el || el.type !== 'repeating_table') return
|
||||
const cols = [...el.columns]
|
||||
const idx = cols.findIndex(c => c.id === colId)
|
||||
const newIdx = idx + direction
|
||||
if (newIdx < 0 || newIdx >= cols.length) return
|
||||
;[cols[idx], cols[newIdx]] = [cols[newIdx], cols[idx]]
|
||||
update({ columns: cols } as Partial<TemplateElement>)
|
||||
}
|
||||
|
||||
/** Seçili tablonun veri kaynağının item alanları (sütun field seçimi için) */
|
||||
const tableItemFields = computed(() => {
|
||||
const el = selectedElement.value
|
||||
if (!el || el.type !== 'repeating_table') return []
|
||||
return schemaStore.getArrayItemFields(el.dataSource.path)
|
||||
})
|
||||
|
||||
// --- Image ---
|
||||
|
||||
function onImageFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
update({ src: reader.result as string } as Partial<TemplateElement>)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
// --- Barcode ---
|
||||
|
||||
import type { BarcodeFormat } from '../../core/types'
|
||||
|
||||
const barcodeDefaults: Record<BarcodeFormat, string> = {
|
||||
qr: 'https://example.com',
|
||||
ean13: '5901234123457',
|
||||
ean8: '96385074',
|
||||
code128: 'DREPORT-001',
|
||||
code39: 'DREPORT',
|
||||
}
|
||||
|
||||
/** EAN kontrol basamağı hesapla (12 veya 7 haneli data için) */
|
||||
function eanCheckDigit(data: string): number {
|
||||
let sum = 0
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const d = parseInt(data[i])
|
||||
// EAN ağırlıkları: 1, 3, 1, 3, ... (soldan sağa)
|
||||
sum += d * (i % 2 === 0 ? 1 : 3)
|
||||
}
|
||||
return (10 - (sum % 10)) % 10
|
||||
}
|
||||
|
||||
function validateBarcode(format: BarcodeFormat, value: string): boolean {
|
||||
if (!value) return false
|
||||
switch (format) {
|
||||
case 'ean13':
|
||||
// Tam 13 haneli + geçerli kontrol basamağı
|
||||
if (!/^\d{13}$/.test(value)) return false
|
||||
return eanCheckDigit(value.slice(0, 12)) === parseInt(value[12])
|
||||
case 'ean8':
|
||||
// Tam 8 haneli + geçerli kontrol basamağı
|
||||
if (!/^\d{8}$/.test(value)) return false
|
||||
return eanCheckDigit(value.slice(0, 7)) === parseInt(value[7])
|
||||
case 'code39':
|
||||
return /^[A-Z0-9\-. $/+%]+$/i.test(value)
|
||||
case 'code128':
|
||||
return value.length > 0 && [...value].every(c => c.charCodeAt(0) < 128)
|
||||
case 'qr':
|
||||
return value.length > 0
|
||||
default:
|
||||
return value.length > 0
|
||||
}
|
||||
}
|
||||
|
||||
const barcodeInputValue = ref('')
|
||||
const barcodeInputInvalid = ref(false)
|
||||
|
||||
// Seçili eleman değişince input'u senkronla
|
||||
watch(() => {
|
||||
const el = selectedElement.value
|
||||
if (el?.type === 'barcode') return (el as BarcodeElement).value ?? ''
|
||||
return ''
|
||||
}, (val) => {
|
||||
barcodeInputValue.value = val
|
||||
barcodeInputInvalid.value = false
|
||||
}, { immediate: true })
|
||||
|
||||
function onBarcodeValueInput(e: Event) {
|
||||
const val = (e.target as HTMLInputElement).value
|
||||
barcodeInputValue.value = val
|
||||
const el = selectedElement.value as BarcodeElement
|
||||
if (!el || el.type !== 'barcode') return
|
||||
|
||||
if (validateBarcode(el.format, val)) {
|
||||
barcodeInputInvalid.value = false
|
||||
update({ value: val } as any)
|
||||
} else {
|
||||
barcodeInputInvalid.value = true
|
||||
// Template'i güncelleme — eski değer ile render devam eder
|
||||
}
|
||||
}
|
||||
|
||||
function onBarcodeFormatChange(newFormat: BarcodeFormat) {
|
||||
const el = selectedElement.value as BarcodeElement
|
||||
if (!el || el.type !== 'barcode') return
|
||||
|
||||
const currentValue = el.value ?? ''
|
||||
if (validateBarcode(newFormat, currentValue)) {
|
||||
update({ format: newFormat } as any)
|
||||
} else {
|
||||
// Değer yeni formata uymuyor → default değer ata
|
||||
const defaultVal = barcodeDefaults[newFormat]
|
||||
barcodeInputValue.value = defaultVal
|
||||
barcodeInputInvalid.value = false
|
||||
update({ format: newFormat, value: defaultVal } as any)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Delete ---
|
||||
|
||||
function deleteElement() {
|
||||
@@ -79,7 +264,7 @@ function deleteElement() {
|
||||
<!-- Header -->
|
||||
<div class="prop-section">
|
||||
<div class="prop-section__title">
|
||||
{{ selectedElement.type === 'container' ? 'Container' : selectedElement.type === 'static_text' ? 'Metin' : selectedElement.type === 'line' ? 'Çizgi' : 'Eleman' }}
|
||||
{{ selectedElement.type === 'container' ? 'Container' : selectedElement.type === 'static_text' ? 'Metin' : selectedElement.type === 'line' ? 'Çizgi' : selectedElement.type === 'repeating_table' ? 'Tablo' : selectedElement.type === 'image' ? 'Gorsel' : selectedElement.type === 'page_number' ? 'Sayfa No' : 'Eleman' }}
|
||||
<span class="prop-id">{{ selectedElement.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -225,6 +410,127 @@ function deleteElement() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image properties -->
|
||||
<div v-if="selectedElement.type === 'image'" class="prop-section">
|
||||
<div class="prop-section__title">Gorsel</div>
|
||||
<div class="prop-row">
|
||||
<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="(selectedElement as ImageElement).src" class="prop-row">
|
||||
<label class="prop-label">Onizleme</label>
|
||||
<img :src="(selectedElement as ImageElement).src" class="prop-image-preview" />
|
||||
</div>
|
||||
<div v-if="(selectedElement as ImageElement).src" class="prop-row">
|
||||
<label class="prop-label"></label>
|
||||
<button class="prop-clear" @click="update({ src: undefined } as any)">Gorseli kaldir</button>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Sigdirma</label>
|
||||
<select class="prop-input prop-select"
|
||||
:value="(selectedElement as ImageElement).style.objectFit ?? 'contain'"
|
||||
@change="(e) => updateStyle('objectFit', (e.target as HTMLSelectElement).value)">
|
||||
<option value="contain">Sigdir</option>
|
||||
<option value="cover">Kap</option>
|
||||
<option value="stretch">Esnet</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Page number properties -->
|
||||
<div v-if="selectedElement.type === 'page_number'" class="prop-section">
|
||||
<div class="prop-section__title">Sayfa Numarasi</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Format</label>
|
||||
<select class="prop-input prop-select"
|
||||
:value="(selectedElement as PageNumberElement).format ?? '{current} / {total}'"
|
||||
@change="(e) => update({ format: (e.target as HTMLSelectElement).value } as any)">
|
||||
<option value="{current} / {total}">1 / 5</option>
|
||||
<option value="{current}">1</option>
|
||||
<option value="Sayfa {current}">Sayfa 1</option>
|
||||
<option value="Sayfa {current} / {total}">Sayfa 1 / 5</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Boyut (pt)</label>
|
||||
<input class="prop-input" type="number" step="1" min="1"
|
||||
:value="(selectedElement.style as TextStyle).fontSize ?? 10"
|
||||
@input="(e) => updateStyle('fontSize', parseFloat((e.target as HTMLInputElement).value) || 10)" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Renk</label>
|
||||
<input class="prop-input prop-color" type="color"
|
||||
:value="(selectedElement.style as TextStyle).color ?? '#666666'"
|
||||
@input="(e) => updateStyle('color', (e.target as HTMLInputElement).value)" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Hizalama</label>
|
||||
<select class="prop-input prop-select"
|
||||
:value="(selectedElement.style as TextStyle).align ?? 'center'"
|
||||
@change="(e) => updateStyle('align', (e.target as HTMLSelectElement).value)">
|
||||
<option value="left">Sol</option>
|
||||
<option value="center">Orta</option>
|
||||
<option value="right">Sag</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Barcode properties -->
|
||||
<div v-if="selectedElement.type === 'barcode'" class="prop-section">
|
||||
<div class="prop-section__title">Barkod Ayarları</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Format</label>
|
||||
<select class="prop-input prop-select"
|
||||
:value="(selectedElement as BarcodeElement).format"
|
||||
@change="(e) => onBarcodeFormatChange((e.target as HTMLSelectElement).value as BarcodeFormat)">
|
||||
<option value="qr">QR Kod</option>
|
||||
<option value="ean13">EAN-13</option>
|
||||
<option value="ean8">EAN-8</option>
|
||||
<option value="code128">Code 128</option>
|
||||
<option value="code39">Code 39</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Deger</label>
|
||||
<input class="prop-input" type="text"
|
||||
:class="{ 'prop-input--invalid': barcodeInputInvalid }"
|
||||
:value="barcodeInputValue"
|
||||
@input="onBarcodeValueInput" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Renk</label>
|
||||
<div class="prop-row-inline">
|
||||
<input class="prop-input prop-color" type="color"
|
||||
:value="(selectedElement as BarcodeElement).style.color ?? '#000000'"
|
||||
@input="(e) => updateStyle('color', (e.target as HTMLInputElement).value)" />
|
||||
<button v-if="(selectedElement as BarcodeElement).style.color" class="prop-clear" @click="updateStyle('color', undefined)">x</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="schemaStore.scalarFields.length > 0" class="prop-row">
|
||||
<label class="prop-label">Veri Baglama</label>
|
||||
<select class="prop-input prop-select"
|
||||
:value="(selectedElement as BarcodeElement).binding?.path ?? ''"
|
||||
@change="(e) => {
|
||||
const val = (e.target as HTMLSelectElement).value
|
||||
if (val) {
|
||||
update({ binding: { type: 'scalar', path: val } } as any)
|
||||
} else {
|
||||
update({ binding: undefined } as any)
|
||||
}
|
||||
}">
|
||||
<option value="">Yok (statik deger)</option>
|
||||
<option
|
||||
v-for="field in schemaStore.scalarFields"
|
||||
:key="field.path"
|
||||
:value="field.path"
|
||||
>{{ field.title }} ({{ field.path }})</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Container properties -->
|
||||
<div v-if="isContainer(selectedElement)" class="prop-section">
|
||||
<div class="prop-section__title">Container Ayarları</div>
|
||||
@@ -244,16 +550,27 @@ function deleteElement() {
|
||||
@input="(e) => update({ gap: parseFloat((e.target as HTMLInputElement).value) || 0 } as any)" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Hizalama</label>
|
||||
<label class="prop-label">{{ (selectedElement as ContainerElement).direction === 'column' ? 'Yatay Hizalama' : 'Dikey Hizalama' }}</label>
|
||||
<select class="prop-input prop-select"
|
||||
:value="(selectedElement as ContainerElement).align"
|
||||
@change="(e) => update({ align: (e.target as HTMLSelectElement).value } as any)">
|
||||
<option value="start">Baş</option>
|
||||
<option value="start">{{ (selectedElement as ContainerElement).direction === 'column' ? 'Sol' : 'Üst' }}</option>
|
||||
<option value="center">Orta</option>
|
||||
<option value="end">Son</option>
|
||||
<option value="end">{{ (selectedElement as ContainerElement).direction === 'column' ? 'Sag' : 'Alt' }}</option>
|
||||
<option value="stretch">Esnet</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">{{ (selectedElement as ContainerElement).direction === 'column' ? 'Dikey Dagılım' : 'Yatay Dagılım' }}</label>
|
||||
<select class="prop-input prop-select"
|
||||
:value="(selectedElement as ContainerElement).justify"
|
||||
@change="(e) => update({ justify: (e.target as HTMLSelectElement).value } as any)">
|
||||
<option value="start">{{ (selectedElement as ContainerElement).direction === 'column' ? 'Üst' : 'Sol' }}</option>
|
||||
<option value="center">Orta</option>
|
||||
<option value="end">{{ (selectedElement as ContainerElement).direction === 'column' ? 'Alt' : 'Sag' }}</option>
|
||||
<option value="space-between">Esit Aralık</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Padding -->
|
||||
<div class="prop-section__subtitle">Padding (mm)</div>
|
||||
@@ -295,20 +612,31 @@ function deleteElement() {
|
||||
<button v-if="(selectedElement as ContainerElement).style.backgroundColor" class="prop-clear" @click="updateStyle('backgroundColor', undefined)">x</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Kenarlık rengi</label>
|
||||
<div class="prop-row-inline">
|
||||
<input class="prop-input prop-color" type="color"
|
||||
:value="(selectedElement as ContainerElement).style.borderColor ?? '#000000'"
|
||||
@input="(e) => updateStyle('borderColor', (e.target as HTMLInputElement).value)" />
|
||||
</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"
|
||||
:value="(selectedElement as ContainerElement).style.borderWidth ?? 0"
|
||||
@input="(e) => updateStyle('borderWidth', parseFloat((e.target as HTMLInputElement).value) || 0)" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Kenarlık rengi</label>
|
||||
<div class="prop-row-inline">
|
||||
<input class="prop-input prop-color" type="color"
|
||||
:value="(selectedElement as ContainerElement).style.borderColor ?? '#000000'"
|
||||
@input="(e) => updateStyle('borderColor', (e.target as HTMLInputElement).value)" />
|
||||
<button v-if="(selectedElement as ContainerElement).style.borderColor" class="prop-clear" @click="updateStyle('borderColor', undefined)">x</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Kenarlık stili</label>
|
||||
<select class="prop-input prop-select"
|
||||
:value="(selectedElement as ContainerElement).style.borderStyle ?? 'solid'"
|
||||
@change="(e) => updateStyle('borderStyle', (e.target as HTMLSelectElement).value)">
|
||||
<option value="solid">Düz</option>
|
||||
<option value="dashed">Kesikli</option>
|
||||
<option value="dotted">Noktalı</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Radius (pt)</label>
|
||||
<input class="prop-input" type="number" step="1" min="0"
|
||||
@@ -317,6 +645,159 @@ function deleteElement() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Repeating Table properties -->
|
||||
<div v-if="selectedElement.type === 'repeating_table'" class="prop-section">
|
||||
<div class="prop-section__title">Veri Kaynagi</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Kaynak</label>
|
||||
<select class="prop-input prop-select"
|
||||
:value="(selectedElement as RepeatingTableElement).dataSource.path"
|
||||
@change="(e) => updateTableDataSource((e.target as HTMLSelectElement).value)">
|
||||
<option value="" disabled>Secin...</option>
|
||||
<option
|
||||
v-for="arr in schemaStore.arrayFields"
|
||||
:key="arr.path"
|
||||
:value="arr.path"
|
||||
>{{ arr.title }} ({{ arr.path }})</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedElement.type === 'repeating_table'" class="prop-section">
|
||||
<div class="prop-section__title">
|
||||
Sutunlar
|
||||
<button class="prop-add-btn" @click="addColumn">+</button>
|
||||
</div>
|
||||
<div
|
||||
v-for="col in (selectedElement as RepeatingTableElement).columns"
|
||||
:key="col.id"
|
||||
class="prop-column-card"
|
||||
>
|
||||
<div class="prop-column-header">
|
||||
<span class="prop-column-title">{{ col.title || col.field }}</span>
|
||||
<div class="prop-column-actions">
|
||||
<button class="prop-icon-btn" @click="moveColumn(col.id, -1)" title="Yukari">↑</button>
|
||||
<button class="prop-icon-btn" @click="moveColumn(col.id, 1)" title="Asagi">↓</button>
|
||||
<button class="prop-icon-btn prop-icon-btn--danger" @click="removeColumn(col.id)" title="Sil">x</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Baslik</label>
|
||||
<input class="prop-input" type="text" :value="col.title"
|
||||
@change="(e) => updateColumn(col.id, { title: (e.target as HTMLInputElement).value })" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Alan</label>
|
||||
<select v-if="tableItemFields.length > 0" class="prop-input prop-select" :value="col.field"
|
||||
@change="(e) => {
|
||||
const field = (e.target as HTMLSelectElement).value
|
||||
const node = tableItemFields.find(f => f.key === field)
|
||||
if (node) {
|
||||
updateColumn(col.id, {
|
||||
field,
|
||||
title: node.title,
|
||||
align: defaultAlignForSchema(node),
|
||||
format: schemaFormatToFormatType(node.format),
|
||||
})
|
||||
} else {
|
||||
updateColumn(col.id, { field })
|
||||
}
|
||||
}">
|
||||
<option v-for="f in tableItemFields" :key="f.key" :value="f.key">{{ f.title }} ({{ f.key }})</option>
|
||||
</select>
|
||||
<input v-else class="prop-input" type="text" :value="col.field"
|
||||
@change="(e) => updateColumn(col.id, { field: (e.target as HTMLInputElement).value })" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Hizalama</label>
|
||||
<select class="prop-input prop-select" :value="col.align"
|
||||
@change="(e) => updateColumn(col.id, { align: (e.target as HTMLSelectElement).value as 'left'|'center'|'right' })">
|
||||
<option value="left">Sol</option>
|
||||
<option value="center">Orta</option>
|
||||
<option value="right">Sag</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Format</label>
|
||||
<select class="prop-input prop-select" :value="col.format ?? ''"
|
||||
@change="(e) => updateColumn(col.id, { format: ((e.target as HTMLSelectElement).value || undefined) as FormatType | undefined })">
|
||||
<option value="">Yok</option>
|
||||
<option value="currency">Para birimi</option>
|
||||
<option value="number">Sayi</option>
|
||||
<option value="date">Tarih</option>
|
||||
<option value="percentage">Yuzde</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Genislik</label>
|
||||
<select class="prop-input prop-select"
|
||||
:value="col.width.type"
|
||||
@change="(e) => {
|
||||
const t = (e.target as HTMLSelectElement).value
|
||||
if (t === 'auto') updateColumn(col.id, { width: { type: 'auto' } })
|
||||
else if (t === 'fr') updateColumn(col.id, { width: { type: 'fr', value: 1 } })
|
||||
else updateColumn(col.id, { width: { type: 'fixed', value: 30 } })
|
||||
}">
|
||||
<option value="auto">Otomatik</option>
|
||||
<option value="fixed">Sabit (mm)</option>
|
||||
<option value="fr">Oran (fr)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="col.width.type === 'fixed'" class="prop-row">
|
||||
<label class="prop-label">mm</label>
|
||||
<input class="prop-input" type="number" step="1" min="5"
|
||||
:value="(col.width as any).value"
|
||||
@change="(e) => updateColumn(col.id, { width: { type: 'fixed', value: parseFloat((e.target as HTMLInputElement).value) || 30 } })" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedElement.type === 'repeating_table'" class="prop-section">
|
||||
<div class="prop-section__title">Tablo Stili</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Yazi boyutu</label>
|
||||
<input class="prop-input" type="number" step="1" min="6"
|
||||
:value="(selectedElement as RepeatingTableElement).style.fontSize ?? 10"
|
||||
@input="(e) => updateTableStyle('fontSize', parseFloat((e.target as HTMLInputElement).value) || 10)" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Header bg</label>
|
||||
<input class="prop-input prop-color" type="color"
|
||||
:value="(selectedElement as RepeatingTableElement).style.headerBg ?? '#f0f0f0'"
|
||||
@input="(e) => updateTableStyle('headerBg', (e.target as HTMLInputElement).value)" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Header renk</label>
|
||||
<input class="prop-input prop-color" type="color"
|
||||
:value="(selectedElement as RepeatingTableElement).style.headerColor ?? '#000000'"
|
||||
@input="(e) => updateTableStyle('headerColor', (e.target as HTMLInputElement).value)" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Zebra tek</label>
|
||||
<div class="prop-row-inline">
|
||||
<input class="prop-input prop-color" type="color"
|
||||
:value="(selectedElement as RepeatingTableElement).style.zebraOdd ?? '#fafafa'"
|
||||
@input="(e) => updateTableStyle('zebraOdd', (e.target as HTMLInputElement).value)" />
|
||||
<button v-if="(selectedElement as RepeatingTableElement).style.zebraOdd" class="prop-clear" @click="updateTableStyle('zebraOdd', undefined)">x</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Kenarlık rengi</label>
|
||||
<div class="prop-row-inline">
|
||||
<input class="prop-input prop-color" type="color"
|
||||
:value="(selectedElement as RepeatingTableElement).style.borderColor ?? '#cccccc'"
|
||||
@input="(e) => updateTableStyle('borderColor', (e.target as HTMLInputElement).value)" />
|
||||
<button v-if="(selectedElement as RepeatingTableElement).style.borderColor" class="prop-clear" @click="updateTableStyle('borderColor', undefined)">x</button>
|
||||
</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"
|
||||
:value="(selectedElement as RepeatingTableElement).style.borderWidth ?? 0.5"
|
||||
@input="(e) => updateTableStyle('borderWidth', parseFloat((e.target as HTMLInputElement).value) || 0)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete -->
|
||||
<div v-if="selectedElement.id !== 'root'" class="prop-section">
|
||||
<button class="prop-delete-btn" @click="deleteElement">Sil</button>
|
||||
@@ -407,6 +888,16 @@ function deleteElement() {
|
||||
border-color: #93c5fd;
|
||||
}
|
||||
|
||||
.prop-input--invalid {
|
||||
border-color: #ef4444;
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.prop-input--invalid:focus {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.prop-select {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -428,6 +919,29 @@ function deleteElement() {
|
||||
padding: 2px 5px;
|
||||
}
|
||||
|
||||
.prop-file-btn {
|
||||
padding: 4px 10px;
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.prop-file-btn:hover {
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.prop-image-preview {
|
||||
max-width: 80px;
|
||||
max-height: 60px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.prop-delete-btn {
|
||||
width: 100%;
|
||||
padding: 6px;
|
||||
@@ -443,4 +957,72 @@ function deleteElement() {
|
||||
.prop-delete-btn:hover {
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.prop-add-btn {
|
||||
float: right;
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
width: 22px;
|
||||
height: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.prop-add-btn:hover {
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.prop-column-card {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.prop-column-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.prop-column-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.prop-column-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.prop-icon-btn {
|
||||
background: none;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
padding: 1px 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.prop-icon-btn:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.prop-icon-btn--danger:hover {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { useEditorStore } from '../../stores/editor'
|
||||
import type { TemplateElement } from '../../core/types'
|
||||
import { useSchemaStore } from '../../stores/schema'
|
||||
import type { TemplateElement, RepeatingTableElement, TableColumn, ImageElement, PageNumberElement, BarcodeElement } from '../../core/types'
|
||||
import { sz } from '../../core/types'
|
||||
import { schemaFormatToFormatType, defaultAlignForSchema } from '../../core/schema-parser'
|
||||
|
||||
const editorStore = useEditorStore()
|
||||
const schemaStore = useSchemaStore()
|
||||
|
||||
let idCounter = Date.now()
|
||||
function nextId(prefix: string) {
|
||||
@@ -57,6 +60,81 @@ const tools: ToolItem[] = [
|
||||
style: { strokeColor: '#000000', strokeWidth: 0.5 },
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Tablo',
|
||||
icon: '▤',
|
||||
create: (): RepeatingTableElement => {
|
||||
// Schema'daki ilk array alanını bul ve sütunları otomatik doldur
|
||||
const arrays = schemaStore.arrayFields
|
||||
const firstArray = arrays[0]
|
||||
let dataPath = ''
|
||||
let columns: TableColumn[] = []
|
||||
|
||||
if (firstArray) {
|
||||
dataPath = firstArray.path
|
||||
const itemFields = schemaStore.getArrayItemFields(firstArray.path)
|
||||
columns = itemFields.map(field => ({
|
||||
id: nextId('col'),
|
||||
field: field.key,
|
||||
title: field.title,
|
||||
width: sz.auto(),
|
||||
align: defaultAlignForSchema(field),
|
||||
format: schemaFormatToFormatType(field.format),
|
||||
}))
|
||||
}
|
||||
|
||||
return {
|
||||
id: nextId('tbl'),
|
||||
type: 'repeating_table',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.fr(1), height: sz.auto() },
|
||||
dataSource: { type: 'array', path: dataPath },
|
||||
columns,
|
||||
style: {
|
||||
headerBg: '#f0f0f0',
|
||||
headerColor: '#000000',
|
||||
fontSize: 10,
|
||||
headerFontSize: 10,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Gorsel',
|
||||
icon: '🖼',
|
||||
create: (): ImageElement => ({
|
||||
id: nextId('img'),
|
||||
type: 'image',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.fixed(40), height: sz.fixed(30) },
|
||||
style: { objectFit: 'contain' },
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Sayfa No',
|
||||
icon: '#',
|
||||
create: (): PageNumberElement => ({
|
||||
id: nextId('pgn'),
|
||||
type: 'page_number',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.auto(), height: sz.auto() },
|
||||
style: { fontSize: 10, color: '#666666', align: 'center' },
|
||||
format: '{current} / {total}',
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Barkod',
|
||||
icon: '⣿',
|
||||
create: (): BarcodeElement => ({
|
||||
id: nextId('bc'),
|
||||
type: 'barcode',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.fixed(30), height: sz.auto() },
|
||||
format: 'qr',
|
||||
value: 'https://example.com',
|
||||
style: {},
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
function onDragStart(e: DragEvent, tool: ToolItem) {
|
||||
|
||||
Reference in New Issue
Block a user