mirror of
https://github.com/duhanbalci/dreport.git
synced 2026-07-01 18:39:16 +00:00
add elements
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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')" />
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
50
frontend/src/components/properties/CheckboxProperties.vue
Normal file
50
frontend/src/components/properties/CheckboxProperties.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
59
frontend/src/components/properties/CurrentDateProperties.vue
Normal file
59
frontend/src/components/properties/CurrentDateProperties.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
182
frontend/src/components/properties/RichTextProperties.vue
Normal file
182
frontend/src/components/properties/RichTextProperties.vue
Normal 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"
|
||||
>×</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>
|
||||
60
frontend/src/components/properties/ShapeProperties.vue
Normal file
60
frontend/src/components/properties/ShapeProperties.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user