add elements

This commit is contained in:
2026-04-03 01:26:54 +03:00
parent 1675d2611c
commit d7abf10dd0
31 changed files with 3600 additions and 177 deletions

View File

@@ -37,14 +37,24 @@ const scale = computed(() => {
return (containerWidth.value / templateStore.template.page.width) * editorStore.zoom
})
// Sayfa boyutu px cinsinden + margin CSS variables
const pageStyle = computed(() => {
// Layout sayfaları
const layoutPages = computed(() => layout.value?.pages ?? [])
// Sayfa yüksekliği px cinsinden
const pageHeightPx = computed(() => templateStore.template.page.height * scale.value)
// Sayfalar container stili — tüm sayfaları kapsayan dış kutu
const pagesContainerStyle = computed(() => {
const w = templateStore.template.page.width * scale.value
const h = templateStore.template.page.height * scale.value
const m = templateStore.template.root.padding
const pageCount = Math.max(1, layoutPages.value.length)
const pageGap = 24
const totalH = pageHeightPx.value * pageCount + pageGap * (pageCount - 1)
return {
width: `${w}px`,
height: `${h}px`,
height: `${totalH}px`,
position: 'relative' as const,
flexShrink: 0,
'--page-margin-top': `${m.top * scale.value}px`,
'--page-margin-right': `${m.right * scale.value}px`,
'--page-margin-bottom': `${m.bottom * scale.value}px`,
@@ -204,10 +214,10 @@ function onPointerUp(e: PointerEvent) {
@pointermove="onPointerMove"
@pointerup="onPointerUp"
>
<!-- Sayfa -->
<div ref="pageRef" class="editor-canvas__page" :style="[pageStyle, panTransform ? { transform: panTransform } : {}]">
<!-- Sayfalar -->
<div ref="pageRef" class="editor-canvas__pages" :style="[pagesContainerStyle, panTransform ? { transform: panTransform } : {}]">
<LayoutRenderer :layout="layout" :scale="scale" />
<InteractionOverlay :scale="scale" :layout-map="layoutMap" />
<InteractionOverlay :scale="scale" :layout-map="layoutMap" :page-count="layoutPages.length" :page-height-px="pageHeightPx" />
</div>
</div>
@@ -244,9 +254,7 @@ function onPointerUp(e: PointerEvent) {
padding: 40px;
}
.editor-canvas__page {
background: white;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
.editor-canvas__pages {
position: relative;
flex-shrink: 0;
}

View File

@@ -2,15 +2,19 @@
import { computed, ref } from 'vue'
import { useTemplateStore } from '../../stores/template'
import { useEditorStore } from '../../stores/editor'
import type { ElementLayout } from '../../core/layout-types'
import type { LayoutMapEntry } from '../../core/layout-types'
import type { TemplateElement, SizeValue, ContainerElement } from '../../core/types'
import { isContainer, sz } from '../../core/types'
import ElementToolbar from './ElementToolbar.vue'
import { useSnapGuides } from '../../composables/useSnapGuides'
const PAGE_GAP_PX = 24
const props = defineProps<{
scale: number
layoutMap: Record<string, ElementLayout>
layoutMap: Record<string, LayoutMapEntry>
pageCount?: number
pageHeightPx?: number
}>()
const templateStore = useTemplateStore()
@@ -28,7 +32,16 @@ const flatElements = computed(() => {
}
}
}
// Header ve footer container'larını ve elemanlarını dahil et
if (templateStore.template.header) {
result.push(templateStore.template.header as unknown as TemplateElement)
walk(templateStore.template.header as unknown as TemplateElement)
}
walk(templateStore.template.root)
if (templateStore.template.footer) {
result.push(templateStore.template.footer as unknown as TemplateElement)
walk(templateStore.template.footer as unknown as TemplateElement)
}
return result
})
@@ -41,10 +54,25 @@ const allContainers = computed(() => {
for (const child of el.children) walk(child)
}
}
if (templateStore.template.header) {
result.push(templateStore.template.header)
for (const child of templateStore.template.header.children) walk(child)
}
for (const child of templateStore.template.root.children) walk(child)
if (templateStore.template.footer) {
result.push(templateStore.template.footer)
for (const child of templateStore.template.footer.children) walk(child)
}
return result
})
/** Sayfa index'ine göre y offset hesapla (sayfalar arası gap dahil) */
function pageYOffset(pageIndex: number): number {
if (pageIndex <= 0) return 0
const pageH = props.pageHeightPx ?? (templateStore.template.page.height * props.scale)
return pageIndex * (pageH + PAGE_GAP_PX)
}
function getElementStyle(el: TemplateElement) {
const l = props.layoutMap[el.id]
if (!l) return { display: 'none' }
@@ -53,12 +81,13 @@ function getElementStyle(el: TemplateElement) {
const h = l.height_mm * s
const minH = 8
const actualH = Math.max(h, minH)
const yOffset = h < minH ? (minH - h) / 2 : 0
const yOff = h < minH ? (minH - h) / 2 : 0
const pYOff = pageYOffset(l.pageIndex)
return {
position: 'absolute' as const,
left: `${l.x_mm * s}px`,
top: `${l.y_mm * s - yOffset}px`,
top: `${l.y_mm * s - yOff + pYOff}px`,
width: `${l.width_mm * s}px`,
height: `${actualH}px`,
}
@@ -113,7 +142,7 @@ function findDeepestContainer(mouseX: number, mouseY: number, excludeId?: string
/** Container içinde drop index hesapla */
function computeDropIndex(container: ContainerElement, mouseX: number, mouseY: number, excludeId?: string) {
const s = props.scale
const flowChildren = container.children.filter(c => c.position.type !== 'absolute' && c.id !== excludeId)
const flowChildren = container.children.filter(c => c.type !== 'page_break' && c.position.type !== 'absolute' && c.id !== excludeId)
const isRow = container.direction === 'row'
let visualIdx = flowChildren.length
@@ -133,7 +162,7 @@ function computeDropIndex(container: ContainerElement, mouseX: number, mouseY: n
// Mantıksal index: excludeId aynı container'daysa offset hesapla
let logicalIdx = visualIdx
if (excludeId) {
const allFlow = container.children.filter(c => c.position.type !== 'absolute')
const allFlow = container.children.filter(c => c.type !== 'page_break' && c.position.type !== 'absolute')
const currentIdx = allFlow.findIndex(c => c.id === excludeId)
if (currentIdx >= 0) {
// visualIdx, excludeId çıkarılmış listede. Gerçek listedeki pozisyona çevir.
@@ -186,7 +215,7 @@ const dropIndicatorStyle = computed(() => {
// Sürüklenen elemanı çıkar
const dragId = dragElementId.value
const flowChildren = container.children.filter(c => c.position.type !== 'absolute' && c.id !== dragId)
const flowChildren = container.children.filter(c => c.type !== 'page_break' && c.position.type !== 'absolute' && c.id !== dragId)
const cl = props.layoutMap[container.id]
if (!cl) return { display: 'none' }
@@ -288,6 +317,7 @@ const dragOffset = ref({ x: 0, y: 0 })
const dragGhost = ref({ x: 0, y: 0, width: 0, height: 0 })
function onDragStart(e: PointerEvent, el: TemplateElement) {
if (el.type === 'page_break') return
if (el.position.type === 'absolute') {
onAbsoluteDragStart(e, el)
return
@@ -617,7 +647,7 @@ const isAnyDragActive = computed(() =>
<div v-if="editorStore.selectedElementId === el.id" class="selection-border" />
<!-- Resize handles -->
<template v-if="editorStore.selectedElementId === el.id && !isResizing">
<template v-if="editorStore.selectedElementId === el.id && !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')" />

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, inject, watch, nextTick } from 'vue'
import type { ElementLayout, LayoutResult } from '../../core/layout-types'
import { inject, watch, nextTick } from 'vue'
import type { ElementLayout, PageLayout, LayoutResult } from '../../core/layout-types'
const props = defineProps<{
layout: LayoutResult | null
@@ -10,10 +10,14 @@ const props = defineProps<{
// WASM barcode üretme fonksiyonu (EditorCanvas'tan provide edilir)
const generateBarcode = inject<(format: string, value: string, width: number, height: number, includeText: boolean) => Promise<{ width: number; height: number; rgba: ArrayBuffer } | null>>('generateBarcode')
const pageElements = computed(() => {
if (!props.layout || props.layout.pages.length === 0) return []
return props.layout.pages[0].elements
})
function pageContainerStyle(page: PageLayout): Record<string, string> {
const s = props.scale
return {
position: 'relative',
width: `${page.width_mm * s}px`,
height: `${page.height_mm * s}px`,
}
}
function elStyle(el: ElementLayout): Record<string, string> {
const s = props.scale
@@ -58,6 +62,25 @@ function containerStyle(el: ElementLayout): Record<string, string> {
return result
}
function shapeStyle(el: ElementLayout): Record<string, string> {
const st = el.style
const result: Record<string, string> = {}
if (st.backgroundColor) result.backgroundColor = st.backgroundColor
if (st.borderColor && st.borderWidth) {
result.border = `${st.borderWidth * props.scale}px ${st.borderStyle ?? 'solid'} ${st.borderColor}`
}
if (st.borderRadius) result.borderRadius = `${st.borderRadius * props.scale}px`
// Ellipse: CSS border-radius 50%
const shapeType = el.content?.type === 'shape' ? el.content.shapeType : 'rectangle'
if (shapeType === 'ellipse') {
result.borderRadius = '50%'
}
return result
}
function lineStyle(el: ElementLayout): Record<string, string> {
const st = el.style
return {
@@ -146,70 +169,140 @@ watch(
<template>
<div class="layout-renderer" v-if="layout">
<template v-for="el in pageElements" :key="el.id">
<!-- Container -->
<div
v-if="el.element_type === 'container'"
class="layout-el layout-el--container"
:style="{ ...elStyle(el), ...containerStyle(el) }"
/>
<!-- Static text / Text / Page number -->
<div
v-else-if="el.element_type === 'static_text' || el.element_type === 'text' || el.element_type === 'page_number'"
class="layout-el layout-el--text"
:style="{ ...elStyle(el), ...textStyle(el) }"
>
{{ el.content?.type === 'text' ? el.content.value : '' }}
</div>
<!-- Line -->
<div
v-else-if="el.element_type === 'line'"
class="layout-el layout-el--line"
:style="elStyle(el)"
>
<div :style="lineStyle(el)" />
</div>
<!-- Image -->
<div
v-else-if="el.element_type === 'image'"
class="layout-el layout-el--image"
:style="elStyle(el)"
>
<img
v-if="el.content?.type === 'image' && el.content.src"
:src="el.content.src"
:style="{
width: '100%',
height: '100%',
objectFit: 'fill',
}"
/>
<div v-else class="layout-el__placeholder">Görsel</div>
</div>
<!-- Barcode -->
<div
v-else-if="el.element_type === 'barcode'"
class="layout-el layout-el--barcode"
:style="elStyle(el)"
>
<canvas
v-if="el.content?.type === 'barcode' && el.content.value"
:ref="(ref) => onBarcodeCanvasMounted(ref as HTMLCanvasElement)"
data-barcode
:data-format="el.content.format"
:data-value="el.content.value"
:data-include-text="el.style.barcodeIncludeText ?? (el.content.format === 'ean13' || el.content.format === 'ean8')"
:style="{ width: '100%', height: '100%', display: 'block' }"
/>
<div v-else class="layout-el__placeholder">
{{ el.content?.type === 'barcode' ? `[${el.content.format}]` : '[barcode]' }}
<div
v-for="(page, pageIdx) in layout.pages"
:key="pageIdx"
class="layout-page"
:style="pageContainerStyle(page)"
>
<template v-for="el in page.elements" :key="el.id">
<!-- Page break: dashed horizontal line -->
<div
v-if="el.element_type === 'page_break'"
class="layout-el layout-el--page-break"
:style="elStyle(el)"
>
<div style="border-top: 1px dashed #9ca3af; width: 100%; height: 0;" />
</div>
</div>
</template>
<!-- Container -->
<div
v-else-if="el.element_type === 'container'"
class="layout-el layout-el--container"
:class="{
'layout-el--header': el.id === 'header' || el.id.startsWith('header_p'),
'layout-el--footer': el.id === 'footer' || el.id.startsWith('footer_p'),
}"
:style="{ ...elStyle(el), ...containerStyle(el) }"
>
<span v-if="el.id === 'header' || el.id.startsWith('header_p')" class="layout-el__section-label">Üst Bilgi</span>
<span v-else-if="el.id === 'footer' || el.id.startsWith('footer_p')" class="layout-el__section-label">Alt Bilgi</span>
</div>
<!-- Static text / Text / Page number -->
<div
v-else-if="el.element_type === 'static_text' || el.element_type === 'text' || el.element_type === 'page_number' || el.element_type === 'current_date' || el.element_type === 'calculated_text'"
class="layout-el layout-el--text"
:style="{ ...elStyle(el), ...textStyle(el) }"
>
{{ el.content?.type === 'text' ? el.content.value : '' }}
</div>
<!-- Line -->
<div
v-else-if="el.element_type === 'line'"
class="layout-el layout-el--line"
:style="elStyle(el)"
>
<div :style="lineStyle(el)" />
</div>
<!-- Image -->
<div
v-else-if="el.element_type === 'image'"
class="layout-el layout-el--image"
:style="elStyle(el)"
>
<img
v-if="el.content?.type === 'image' && el.content.src"
:src="el.content.src"
:style="{
width: '100%',
height: '100%',
objectFit: 'fill',
}"
/>
<div v-else class="layout-el__placeholder">Görsel</div>
</div>
<!-- Barcode -->
<div
v-else-if="el.element_type === 'barcode'"
class="layout-el layout-el--barcode"
:style="elStyle(el)"
>
<canvas
v-if="el.content?.type === 'barcode' && el.content.value"
:ref="(ref) => onBarcodeCanvasMounted(ref as HTMLCanvasElement)"
data-barcode
:data-format="el.content.format"
:data-value="el.content.value"
:data-include-text="el.style.barcodeIncludeText ?? (el.content.format === 'ean13' || el.content.format === 'ean8')"
:style="{ width: '100%', height: '100%', display: 'block' }"
/>
<div v-else class="layout-el__placeholder">
{{ el.content?.type === 'barcode' ? `[${el.content.format}]` : '[barcode]' }}
</div>
</div>
<!-- Checkbox -->
<div
v-else-if="el.element_type === 'checkbox'"
class="layout-el layout-el--checkbox"
:style="elStyle(el)"
>
<svg viewBox="0 0 20 20" :style="{ width: '100%', height: '100%' }">
<rect x="1" y="1" width="18" height="18" fill="none"
:stroke="el.style.borderColor ?? '#333'"
:stroke-width="el.style.borderWidth ? el.style.borderWidth * 3 : 1.5" />
<path v-if="el.content?.type === 'checkbox' && el.content.checked"
d="M4 10 L8 15 L16 5"
fill="none"
:stroke="el.style.color ?? '#000'"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</div>
<!-- Rich Text -->
<div
v-else-if="el.element_type === 'rich_text'"
class="layout-el layout-el--text layout-el--rich-text"
:style="{ ...elStyle(el), ...textStyle(el) }"
>
<template v-if="el.content?.type === 'rich_text'">
<span
v-for="(span, idx) in el.content.spans"
:key="idx"
:style="{
fontSize: span.fontSize ? `${span.fontSize * 0.3528 * scale}px` : undefined,
fontWeight: span.fontWeight || undefined,
fontFamily: span.fontFamily || undefined,
color: span.color || undefined,
}"
>{{ span.text }}</span>
</template>
</div>
<!-- Shape -->
<div
v-else-if="el.element_type === 'shape'"
class="layout-el layout-el--shape"
:style="{ ...elStyle(el), ...shapeStyle(el) }"
/>
</template>
</div>
</div>
<div class="layout-renderer layout-renderer--empty" v-else>
@@ -219,12 +312,20 @@ watch(
<style scoped>
.layout-renderer {
position: absolute;
inset: 0;
pointer-events: none;
user-select: none;
}
.layout-page {
overflow: hidden;
background: white;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
}
.layout-page + .layout-page {
margin-top: 24px;
}
.layout-renderer--empty {
display: flex;
align-items: center;
@@ -247,6 +348,27 @@ watch(
align-items: center;
}
.layout-el--page-break {
display: flex;
align-items: center;
}
.layout-el--header,
.layout-el--footer {
border: 1px dashed #94a3b8;
background: rgba(148, 163, 184, 0.05);
}
.layout-el__section-label {
position: absolute;
top: 2px;
left: 4px;
font-size: 9px;
color: #94a3b8;
pointer-events: none;
user-select: none;
}
.layout-el__placeholder {
width: 100%;
height: 100%;

View File

@@ -10,6 +10,11 @@ import type {
PageNumberElement,
BarcodeElement,
RepeatingTableElement,
CurrentDateElement,
ShapeElement,
CheckboxElement,
CalculatedTextElement,
RichTextElement,
} from '../../core/types'
import PositioningProperties from '../properties/PositioningProperties.vue'
import SizeProperties from '../properties/SizeProperties.vue'
@@ -18,6 +23,11 @@ import LineProperties from '../properties/LineProperties.vue'
import ImageProperties from '../properties/ImageProperties.vue'
import PageNumberProperties from '../properties/PageNumberProperties.vue'
import BarcodeProperties from '../properties/BarcodeProperties.vue'
import CurrentDateProperties from '../properties/CurrentDateProperties.vue'
import ShapeProperties from '../properties/ShapeProperties.vue'
import CheckboxProperties from '../properties/CheckboxProperties.vue'
import CalculatedTextProperties from '../properties/CalculatedTextProperties.vue'
import RichTextProperties from '../properties/RichTextProperties.vue'
import ContainerProperties from '../properties/ContainerProperties.vue'
import RepeatingTableProperties from '../properties/RepeatingTableProperties.vue'
import '../../styles/properties.css'
@@ -35,7 +45,10 @@ const elementTypeLabel = computed(() => {
const el = selectedElement.value
if (!el) return ''
switch (el.type) {
case 'container': return 'Container'
case 'container':
if (el.id === 'header') return 'Üst Bilgi'
if (el.id === 'footer') return 'Alt Bilgi'
return 'Container'
case 'static_text': return 'Metin'
case 'text': return 'Metin'
case 'line': return 'Cizgi'
@@ -43,10 +56,28 @@ const elementTypeLabel = computed(() => {
case 'image': return 'Gorsel'
case 'page_number': return 'Sayfa No'
case 'barcode': return 'Barkod'
case 'checkbox': return 'Onay Kutusu'
case 'shape': return 'Sekil'
case 'current_date': return 'Tarih'
case 'calculated_text': return 'Hesaplanan Metin'
case 'rich_text': return 'Zengin Metin'
case 'page_break': return 'Sayfa Sonu'
default: return 'Eleman'
}
})
function toggleHeader(e: Event) {
const checked = (e.target as HTMLInputElement).checked
if (checked) templateStore.enableHeader()
else templateStore.disableHeader()
}
function toggleFooter(e: Event) {
const checked = (e.target as HTMLInputElement).checked
if (checked) templateStore.enableFooter()
else templateStore.disableFooter()
}
function deleteElement() {
const id = editorStore.selectedElementId
if (!id || id === 'root') return
@@ -70,41 +101,85 @@ function deleteElement() {
</div>
</div>
<PositioningProperties :element="selectedElement" />
<SizeProperties :element="selectedElement" />
<!-- Page break: minimal info, just delete -->
<template v-if="selectedElement.type === 'page_break'">
<div class="prop-section">
<button class="prop-delete-btn" @click="deleteElement">Sil</button>
</div>
</template>
<TextProperties
v-if="selectedElement.type === 'static_text' || selectedElement.type === 'text'"
:element="selectedElement" />
<template v-else>
<PositioningProperties :element="selectedElement" />
<SizeProperties :element="selectedElement" />
<LineProperties
v-if="selectedElement.type === 'line'"
:element="(selectedElement as LineElement)" />
<TextProperties
v-if="selectedElement.type === 'static_text' || selectedElement.type === 'text'"
:element="selectedElement" />
<ImageProperties
v-if="selectedElement.type === 'image'"
:element="(selectedElement as ImageElement)" />
<LineProperties
v-if="selectedElement.type === 'line'"
:element="(selectedElement as LineElement)" />
<PageNumberProperties
v-if="selectedElement.type === 'page_number'"
:element="(selectedElement as PageNumberElement)" />
<ImageProperties
v-if="selectedElement.type === 'image'"
:element="(selectedElement as ImageElement)" />
<BarcodeProperties
v-if="selectedElement.type === 'barcode'"
:element="(selectedElement as BarcodeElement)" />
<PageNumberProperties
v-if="selectedElement.type === 'page_number'"
:element="(selectedElement as PageNumberElement)" />
<ContainerProperties
v-if="isContainer(selectedElement)"
:element="(selectedElement as ContainerElement)" />
<BarcodeProperties
v-if="selectedElement.type === 'barcode'"
:element="(selectedElement as BarcodeElement)" />
<RepeatingTableProperties
v-if="selectedElement.type === 'repeating_table'"
:element="(selectedElement as RepeatingTableElement)" />
<CurrentDateProperties
v-if="selectedElement.type === 'current_date'"
:element="(selectedElement as CurrentDateElement)" />
<!-- Delete -->
<div v-if="selectedElement.id !== 'root'" class="prop-section">
<button class="prop-delete-btn" @click="deleteElement">Sil</button>
</div>
<CheckboxProperties
v-if="selectedElement.type === 'checkbox'"
:element="(selectedElement as CheckboxElement)" />
<CalculatedTextProperties
v-if="selectedElement.type === 'calculated_text'"
:element="(selectedElement as CalculatedTextElement)" />
<RichTextProperties
v-if="selectedElement.type === 'rich_text'"
:element="(selectedElement as RichTextElement)" />
<ShapeProperties
v-if="selectedElement.type === 'shape'"
:element="(selectedElement as ShapeElement)" />
<ContainerProperties
v-if="isContainer(selectedElement)"
:element="(selectedElement as ContainerElement)" />
<RepeatingTableProperties
v-if="selectedElement.type === 'repeating_table'"
:element="(selectedElement as RepeatingTableElement)" />
<!-- Header/Footer toggles for root element -->
<div v-if="selectedElement.id === 'root'" class="prop-section">
<div class="prop-section__title">Sayfa Ust/Alt Bilgi</div>
<div class="prop-row">
<label class="prop-label">Ust Bilgi (Header)</label>
<input type="checkbox" :checked="!!templateStore.template.header"
@change="toggleHeader" />
</div>
<div class="prop-row">
<label class="prop-label">Alt Bilgi (Footer)</label>
<input type="checkbox" :checked="!!templateStore.template.footer"
@change="toggleFooter" />
</div>
</div>
<!-- Delete -->
<div v-if="selectedElement.id !== 'root'" class="prop-section">
<button class="prop-delete-btn" @click="deleteElement">Sil</button>
</div>
</template>
</template>
</div>
</template>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useEditorStore } from '../../stores/editor'
import { useSchemaStore } from '../../stores/schema'
import type { TemplateElement, RepeatingTableElement, TableColumn, ImageElement, PageNumberElement, BarcodeElement } from '../../core/types'
import type { TemplateElement, RepeatingTableElement, TableColumn, ImageElement, PageNumberElement, BarcodeElement, PageBreakElement, CurrentDateElement, ShapeElement, CheckboxElement, CalculatedTextElement, RichTextElement } from '../../core/types'
import { sz } from '../../core/types'
import { schemaFormatToFormatType, defaultAlignForSchema } from '../../core/schema-parser'
@@ -32,6 +32,21 @@ const tools: ToolItem[] = [
content: 'Yeni metin',
}),
},
{
label: 'Zengin Metin',
icon: 'R',
create: (): RichTextElement => ({
id: nextId('rt'),
type: 'rich_text',
position: { type: 'flow' },
size: { width: sz.auto(), height: sz.auto() },
style: { fontSize: 11, color: '#000000' },
content: [
{ text: 'Zengin ', style: {} },
{ text: 'metin', style: { fontWeight: 'bold' } },
],
}),
},
{
label: 'Container',
icon: '▢',
@@ -96,6 +111,7 @@ const tools: ToolItem[] = [
fontSize: 10,
headerFontSize: 10,
},
repeatHeader: true,
}
},
},
@@ -135,6 +151,62 @@ const tools: ToolItem[] = [
style: {},
}),
},
{
label: 'Onay Kutusu',
icon: '☑',
create: (): CheckboxElement => ({
id: nextId('cb'),
type: 'checkbox',
position: { type: 'flow' },
size: { width: sz.auto(), height: sz.auto() },
checked: false,
style: { size: 4, checkColor: '#000000', borderColor: '#333333', borderWidth: 0.3 },
}),
},
{
label: 'Sekil',
icon: '⬜',
create: (): ShapeElement => ({
id: nextId('shp'),
type: 'shape',
position: { type: 'flow' },
size: { width: sz.fr(1), height: sz.fixed(20) },
shapeType: 'rectangle',
style: { backgroundColor: '#f0f0f0', borderColor: '#333333', borderWidth: 0.5 },
}),
},
{
label: 'Hesaplanan',
icon: 'ƒ',
create: (): CalculatedTextElement => ({
id: nextId('calc'),
type: 'calculated_text',
position: { type: 'flow' },
size: { width: sz.auto(), height: sz.auto() },
expression: '0',
style: { fontSize: 11, color: '#000000' },
}),
},
{
label: 'Tarih',
icon: '📅',
create: (): CurrentDateElement => ({
id: nextId('dt'),
type: 'current_date',
position: { type: 'flow' },
size: { width: sz.auto(), height: sz.auto() },
style: { fontSize: 10, color: '#666666' },
format: 'DD.MM.YYYY',
}),
},
{
label: 'Sayfa Sonu',
icon: '⏎',
create: (): PageBreakElement => ({
id: nextId('pb'),
type: 'page_break',
}),
},
]
function onDragStart(e: DragEvent, tool: ToolItem) {

View File

@@ -0,0 +1,75 @@
<script setup lang="ts">
import { useTemplateStore } from '../../stores/template'
import { useEditorStore } from '../../stores/editor'
import type { CalculatedTextElement, TextStyle, TemplateElement } from '../../core/types'
import '../../styles/properties.css'
const props = defineProps<{ element: CalculatedTextElement }>()
const templateStore = useTemplateStore()
const editorStore = useEditorStore()
function update(updates: Partial<TemplateElement>) {
const id = editorStore.selectedElementId
if (!id) return
templateStore.updateElement(id, updates)
}
function updateStyle(key: string, value: unknown) {
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>)
}
</script>
<template>
<div class="prop-section">
<div class="prop-section__title">Hesaplanan Metin</div>
<div class="prop-row">
<label class="prop-label">Ifade</label>
<input class="prop-input" type="text"
:value="element.expression"
@change="(e) => update({ expression: (e.target as HTMLInputElement).value } as any)"
placeholder="toplamlar.kdv + toplamlar.araToplam" />
</div>
<div class="prop-row">
<label class="prop-label">Format</label>
<select class="prop-input prop-select"
:value="element.format ?? ''"
@change="(e) => update({ format: (e.target as HTMLSelectElement).value || undefined } as any)">
<option value="">Yok</option>
<option value="currency">Para Birimi</option>
<option value="number">Sayi</option>
<option value="percentage">Yuzde</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="(element.style as TextStyle).fontSize ?? 11"
@input="(e) => updateStyle('fontSize', parseFloat((e.target as HTMLInputElement).value) || 11)" />
</div>
<div class="prop-row">
<label class="prop-label">Renk</label>
<input class="prop-input prop-color" type="color"
:value="(element.style as TextStyle).color ?? '#000000'"
@input="(e) => updateStyle('color', (e.target as HTMLInputElement).value)" />
</div>
<div class="prop-row">
<label class="prop-label">Kalinlik</label>
<select class="prop-input prop-select"
:value="(element.style as TextStyle).fontWeight ?? 'normal'"
@change="(e) => updateStyle('fontWeight', (e.target as HTMLSelectElement).value)">
<option value="normal">Normal</option>
<option value="bold">Kalin</option>
</select>
</div>
<div class="prop-row">
<label class="prop-label">Hizalama</label>
<select class="prop-input prop-select"
:value="(element.style as TextStyle).align ?? 'left'"
@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>
</template>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import { useTemplateStore } from '../../stores/template'
import { useEditorStore } from '../../stores/editor'
import type { CheckboxElement, TemplateElement } from '../../core/types'
import '../../styles/properties.css'
const props = defineProps<{ element: CheckboxElement }>()
const templateStore = useTemplateStore()
const editorStore = useEditorStore()
function update(updates: Partial<TemplateElement>) {
const id = editorStore.selectedElementId
if (!id) return
templateStore.updateElement(id, updates)
}
function updateStyle(key: string, value: unknown) {
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>)
}
</script>
<template>
<div class="prop-section">
<div class="prop-section__title">Onay Kutusu</div>
<div v-if="!element.binding" class="prop-row">
<label class="prop-label">Isaretli</label>
<input type="checkbox"
:checked="element.checked ?? false"
@change="(e) => update({ checked: (e.target as HTMLInputElement).checked } as any)" />
</div>
<div class="prop-row">
<label class="prop-label">Boyut (mm)</label>
<input class="prop-input" type="number" step="0.5" min="1"
:value="element.style.size ?? 4"
@input="(e) => updateStyle('size', parseFloat((e.target as HTMLInputElement).value) || 4)" />
</div>
<div class="prop-row">
<label class="prop-label">Isaret Rengi</label>
<input class="prop-input prop-color" type="color"
:value="element.style.checkColor ?? '#000000'"
@input="(e) => updateStyle('checkColor', (e.target as HTMLInputElement).value)" />
</div>
<div class="prop-row">
<label class="prop-label">Kenar Rengi</label>
<input class="prop-input prop-color" type="color"
:value="element.style.borderColor ?? '#333333'"
@input="(e) => updateStyle('borderColor', (e.target as HTMLInputElement).value)" />
</div>
</div>
</template>

View File

@@ -70,6 +70,16 @@ function updateStyle(key: string, value: unknown) {
@update="(side, value) => update({ padding: { ...element.padding, [side]: value } } as any)"
/>
<div class="prop-row">
<label class="prop-label">Sayfa Bolme</label>
<select class="prop-input prop-select"
:value="element.breakInside ?? 'auto'"
@change="(e) => update({ breakInside: (e.target as HTMLSelectElement).value } as any)">
<option value="auto">Izin Ver</option>
<option value="avoid">Bolme</option>
</select>
</div>
<div class="prop-section__subtitle">Stil</div>
<div class="prop-row">
<label class="prop-label">Arka plan</label>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import { useTemplateStore } from '../../stores/template'
import { useEditorStore } from '../../stores/editor'
import type { CurrentDateElement, TextStyle, TemplateElement } from '../../core/types'
import '../../styles/properties.css'
const props = defineProps<{ element: CurrentDateElement }>()
const templateStore = useTemplateStore()
const editorStore = useEditorStore()
function update(updates: Partial<TemplateElement>) {
const id = editorStore.selectedElementId
if (!id) return
templateStore.updateElement(id, updates)
}
function updateStyle(key: string, value: unknown) {
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>)
}
</script>
<template>
<div class="prop-section">
<div class="prop-section__title">Tarih</div>
<div class="prop-row">
<label class="prop-label">Format</label>
<select class="prop-input prop-select"
:value="element.format ?? 'DD.MM.YYYY'"
@change="(e) => update({ format: (e.target as HTMLSelectElement).value } as any)">
<option value="DD.MM.YYYY">30.03.2026</option>
<option value="DD/MM/YYYY">30/03/2026</option>
<option value="YYYY-MM-DD">2026-03-30</option>
<option value="DD.MM.YYYY HH:mm">30.03.2026 14:30</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="(element.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="(element.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="(element.style as TextStyle).align ?? 'left'"
@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>
</template>

View File

@@ -193,6 +193,17 @@ const tableItemFields = computed(() => {
</div>
</div>
<!-- Sayfa bölme ayarları -->
<div class="prop-section">
<div class="prop-section__title">Sayfa Bolme</div>
<div class="prop-row">
<label class="prop-label">Header tekrarla</label>
<input type="checkbox"
:checked="element.repeatHeader !== false"
@change="(e) => update({ repeatHeader: (e.target as HTMLInputElement).checked } as any)" />
</div>
</div>
<!-- Table style -->
<div class="prop-section">
<div class="prop-section__title">Tablo Stili</div>

View File

@@ -0,0 +1,182 @@
<script setup lang="ts">
import { useTemplateStore } from '../../stores/template'
import { useEditorStore } from '../../stores/editor'
import type { RichTextElement, RichTextSpan, TextStyle } from '../../core/types'
import '../../styles/properties.css'
const props = defineProps<{ element: RichTextElement }>()
const templateStore = useTemplateStore()
const editorStore = useEditorStore()
function update(updates: Partial<RichTextElement>) {
const id = editorStore.selectedElementId
if (!id) return
templateStore.updateElement(id, updates as any)
}
function updateStyle(key: string, value: unknown) {
update({ style: { ...props.element.style, [key]: value } } as Partial<RichTextElement>)
}
function updateSpan(index: number, updates: Partial<RichTextSpan>) {
const content = [...props.element.content]
content[index] = { ...content[index], ...updates }
update({ content })
}
function updateSpanStyle(index: number, key: string, value: unknown) {
const span = props.element.content[index]
updateSpan(index, { style: { ...span.style, [key]: value } })
}
function addSpan() {
const content = [...props.element.content, { text: 'yeni', style: {} }]
update({ content })
}
function removeSpan(index: number) {
if (props.element.content.length <= 1) return
const content = props.element.content.filter((_, i) => i !== index)
update({ content })
}
</script>
<template>
<div class="prop-section">
<div class="prop-section__title">Varsayilan Stil</div>
<div class="prop-row">
<label class="prop-label">Boyut (pt)</label>
<input class="prop-input" type="number" step="1" min="1"
:value="element.style.fontSize ?? 11"
@input="(e) => updateStyle('fontSize', parseFloat((e.target as HTMLInputElement).value) || 11)" />
</div>
<div class="prop-row">
<label class="prop-label">Renk</label>
<input class="prop-input prop-color" type="color"
:value="element.style.color ?? '#000000'"
@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="element.style.align ?? 'left'"
@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>
<div class="prop-section">
<div class="prop-section__title">
Span'lar
<button class="prop-add-btn" @click="addSpan" title="Span ekle">+</button>
</div>
<div v-for="(span, idx) in element.content" :key="idx" class="prop-span-card">
<div class="prop-span-card__header">
<span class="prop-span-card__label">Span {{ idx + 1 }}</span>
<button
v-if="element.content.length > 1"
class="prop-span-card__remove"
@click="removeSpan(idx)"
title="Sil"
>&times;</button>
</div>
<div class="prop-row">
<label class="prop-label">Metin</label>
<input class="prop-input" type="text"
:value="span.text ?? ''"
@input="(e) => updateSpan(idx, { text: (e.target as HTMLInputElement).value })" />
</div>
<div class="prop-row">
<label class="prop-label">Boyut</label>
<input class="prop-input" type="number" step="1" min="1"
:value="(span.style as TextStyle).fontSize ?? ''"
placeholder="varsayilan"
@input="(e) => {
const v = (e.target as HTMLInputElement).value
updateSpanStyle(idx, 'fontSize', v ? parseFloat(v) : undefined)
}" />
</div>
<div class="prop-row">
<label class="prop-label">Kalinlik</label>
<select class="prop-input prop-select"
:value="(span.style as TextStyle).fontWeight ?? ''"
@change="(e) => {
const v = (e.target as HTMLSelectElement).value
updateSpanStyle(idx, 'fontWeight', v || undefined)
}">
<option value="">Varsayilan</option>
<option value="normal">Normal</option>
<option value="bold">Kalin</option>
</select>
</div>
<div class="prop-row">
<label class="prop-label">Renk</label>
<input class="prop-input prop-color" type="color"
:value="(span.style as TextStyle).color ?? element.style.color ?? '#000000'"
@input="(e) => updateSpanStyle(idx, 'color', (e.target as HTMLInputElement).value)" />
</div>
</div>
</div>
</template>
<style scoped>
.prop-add-btn {
float: right;
background: #3b82f6;
color: white;
border: none;
border-radius: 4px;
width: 22px;
height: 22px;
font-size: 14px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.prop-add-btn:hover {
background: #2563eb;
}
.prop-span-card {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 8px;
margin-bottom: 8px;
}
.prop-span-card__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.prop-span-card__label {
font-size: 11px;
font-weight: 600;
color: #64748b;
}
.prop-span-card__remove {
background: none;
border: none;
color: #ef4444;
font-size: 16px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.prop-span-card__remove:hover {
color: #dc2626;
}
</style>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import { useTemplateStore } from '../../stores/template'
import { useEditorStore } from '../../stores/editor'
import type { ShapeElement, TemplateElement } from '../../core/types'
import '../../styles/properties.css'
const props = defineProps<{ element: ShapeElement }>()
const templateStore = useTemplateStore()
const editorStore = useEditorStore()
function update(updates: Partial<TemplateElement>) {
const id = editorStore.selectedElementId
if (!id) return
templateStore.updateElement(id, updates)
}
function updateStyle(key: string, value: unknown) {
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>)
}
</script>
<template>
<div class="prop-section">
<div class="prop-section__title">Sekil</div>
<div class="prop-row">
<label class="prop-label">Tip</label>
<select class="prop-input prop-select"
:value="element.shapeType"
@change="(e) => update({ shapeType: (e.target as HTMLSelectElement).value } as any)">
<option value="rectangle">Dikdortgen</option>
<option value="rounded_rectangle">Yuvarlak Dikdortgen</option>
<option value="ellipse">Elips</option>
</select>
</div>
<div class="prop-row">
<label class="prop-label">Arka Plan</label>
<input class="prop-input prop-color" type="color"
:value="element.style.backgroundColor ?? '#f0f0f0'"
@input="(e) => updateStyle('backgroundColor', (e.target as HTMLInputElement).value)" />
</div>
<div class="prop-row">
<label class="prop-label">Kenar Rengi</label>
<input class="prop-input prop-color" type="color"
:value="element.style.borderColor ?? '#333333'"
@input="(e) => updateStyle('borderColor', (e.target as HTMLInputElement).value)" />
</div>
<div class="prop-row">
<label class="prop-label">Kenar Kalinligi</label>
<input class="prop-input" type="number" step="0.25" min="0"
:value="element.style.borderWidth ?? 0.5"
@input="(e) => updateStyle('borderWidth', parseFloat((e.target as HTMLInputElement).value) || 0)" />
</div>
<div v-if="element.shapeType === 'rounded_rectangle'" class="prop-row">
<label class="prop-label">Kose Yuvarlakligi</label>
<input class="prop-input" type="number" step="0.5" min="0"
:value="element.style.borderRadius ?? 2"
@input="(e) => updateStyle('borderRadius', parseFloat((e.target as HTMLInputElement).value) || 0)" />
</div>
</div>
</template>

View File

@@ -1,8 +1,8 @@
import { ref, watch, type Ref } from 'vue'
import type { Template } from '../core/types'
import type { LayoutResult, ElementLayout } from '../core/layout-types'
import type { LayoutResult, LayoutMapEntry } from '../core/layout-types'
export type { ElementLayout }
export type { LayoutMapEntry }
export function useLayoutEngine(
template: Ref<Template>,
@@ -14,7 +14,7 @@ export function useLayoutEngine(
const computing = ref(false)
// Uyumluluk: InteractionOverlay'ın beklediği flat layout map (id → ElementLayout)
const layoutMap = ref<Record<string, ElementLayout>>({})
const layoutMap = ref<Record<string, LayoutMapEntry>>({})
let worker: Worker | null = null
let requestId = 0
@@ -40,11 +40,13 @@ export function useLayoutEngine(
layout.value = msg.layout
error.value = null
// Flat map oluştur: id → ElementLayout
const map: Record<string, ElementLayout> = {}
// Flat map oluştur: id → LayoutMapEntry (pageIndex dahil)
const map: Record<string, LayoutMapEntry> = {}
for (const page of msg.layout.pages) {
for (const el of page.elements) {
map[el.id] = el
if (!map[el.id]) {
map[el.id] = { ...el, pageIndex: page.page_index }
}
}
}
layoutMap.value = map

View File

@@ -23,12 +23,27 @@ export interface ElementLayout {
children: string[]
}
export interface LayoutMapEntry extends ElementLayout {
pageIndex: number
}
export interface ResolvedRichSpan {
text: string
fontSize?: number
fontWeight?: string
fontFamily?: string
color?: string
}
export type ResolvedContent =
| { type: 'text'; value: string }
| { type: 'image'; src: string }
| { type: 'line' }
| { type: 'barcode'; format: string; value: string }
| { type: 'page_number'; current: number; total: number }
| { type: 'shape'; shapeType: string }
| { type: 'checkbox'; checked: boolean }
| { type: 'rich_text'; spans: ResolvedRichSpan[] }
| { type: 'table'; headers: TableHeaderCell[]; rows: TableCell[][]; column_widths_mm: number[] }
export interface TableHeaderCell {

View File

@@ -163,6 +163,56 @@ export interface BarcodeElement extends BaseElement {
style: BarcodeStyle
}
export interface CurrentDateElement extends BaseElement {
type: 'current_date'
style: TextStyle
format?: string // ör: "DD.MM.YYYY", "DD MMMM YYYY", "DD.MM.YYYY HH:mm"
}
export interface ShapeElement extends BaseElement {
type: 'shape'
shapeType: 'rectangle' | 'ellipse' | 'rounded_rectangle'
style: ContainerStyle
}
export interface CheckboxStyle {
size?: number // mm — kare boyutu
checkColor?: string // checkmark rengi
borderColor?: string
borderWidth?: number
}
export interface CheckboxElement extends BaseElement {
type: 'checkbox'
checked?: boolean
binding?: ScalarBinding
style: CheckboxStyle
}
export interface CalculatedTextElement extends BaseElement {
type: 'calculated_text'
expression: string
format?: FormatType
style: TextStyle
}
export interface RichTextSpan {
text?: string
binding?: ScalarBinding
style: TextStyle
}
export interface RichTextElement extends BaseElement {
type: 'rich_text'
content: RichTextSpan[]
style: TextStyle // varsayılan stil
}
export interface PageBreakElement {
type: 'page_break'
id: string
}
export interface ContainerElement extends BaseElement {
type: 'container'
direction: 'row' | 'column'
@@ -170,6 +220,7 @@ export interface ContainerElement extends BaseElement {
padding: Padding
align: 'start' | 'center' | 'end' | 'stretch'
justify: 'start' | 'center' | 'end' | 'space-between'
breakInside?: 'auto' | 'avoid'
style: ContainerStyle
children: TemplateElement[]
}
@@ -179,9 +230,10 @@ export interface RepeatingTableElement extends BaseElement {
dataSource: ArrayBinding
columns: TableColumn[]
style: TableStyle
repeatHeader?: boolean
}
export type LeafElement = StaticTextElement | TextElement | LineElement | RepeatingTableElement | ImageElement | PageNumberElement | BarcodeElement
export type LeafElement = StaticTextElement | TextElement | LineElement | RepeatingTableElement | ImageElement | PageNumberElement | BarcodeElement | PageBreakElement | CurrentDateElement | ShapeElement | CheckboxElement | CalculatedTextElement | RichTextElement
export type TemplateElement = LeafElement | ContainerElement
// --- Template ---
@@ -193,6 +245,8 @@ export interface Template {
page: PageSettings
fonts: string[]
root: ContainerElement // kök container = sayfa
header?: ContainerElement
footer?: ContainerElement
}
// --- Editor state ---

View File

@@ -86,11 +86,34 @@ export const useTemplateStore = defineStore('template', () => {
// --- Element CRUD ---
function getElementById(id: string): TemplateElement | undefined {
return findElementById(template.value.root, id)
const inRoot = findElementById(template.value.root, id)
if (inRoot) return inRoot
if (template.value.header) {
const inHeader = findElementById(template.value.header, id)
if (inHeader) return inHeader
}
if (template.value.footer) {
const inFooter = findElementById(template.value.footer, id)
if (inFooter) return inFooter
}
return undefined
}
function getParent(id: string): ContainerElement | undefined {
return findParent(template.value.root, id)
const inRoot = findParent(template.value.root, id)
if (inRoot) return inRoot
if (template.value.header) {
// Check if the header itself is the target element's parent
if (template.value.header.id === id) return undefined
const inHeader = findParent(template.value.header, id)
if (inHeader) return inHeader
}
if (template.value.footer) {
if (template.value.footer.id === id) return undefined
const inFooter = findParent(template.value.footer, id)
if (inFooter) return inFooter
}
return undefined
}
/** Bir container'a çocuk ekle */
@@ -180,6 +203,58 @@ export const useTemplateStore = defineStore('template', () => {
bumpLayoutVersion()
}
/** Header container'ı etkinleştir */
function enableHeader() {
if (template.value.header) return
template.value.header = {
id: 'header',
type: 'container',
position: { type: 'flow' },
size: { width: sz.auto(), height: sz.fixed(10), minHeight: 10 },
direction: 'row',
gap: 0,
padding: { top: 2, right: 5, bottom: 2, left: 5 },
align: 'stretch',
justify: 'start',
style: {},
children: [],
}
bumpLayoutVersion()
}
/** Header container'ı kaldır */
function disableHeader() {
if (!template.value.header) return
template.value.header = undefined
bumpLayoutVersion()
}
/** Footer container'ı etkinleştir */
function enableFooter() {
if (template.value.footer) return
template.value.footer = {
id: 'footer',
type: 'container',
position: { type: 'flow' },
size: { width: sz.auto(), height: sz.fixed(10), minHeight: 10 },
direction: 'row',
gap: 0,
padding: { top: 2, right: 5, bottom: 2, left: 5 },
align: 'stretch',
justify: 'start',
style: {},
children: [],
}
bumpLayoutVersion()
}
/** Footer container'ı kaldır */
function disableFooter() {
if (!template.value.footer) return
template.value.footer = undefined
bumpLayoutVersion()
}
return {
template,
mockData,
@@ -202,5 +277,9 @@ export const useTemplateStore = defineStore('template', () => {
redo,
canUndo,
canRedo,
enableHeader,
disableHeader,
enableFooter,
disableFooter,
}
})