commit 07869f03c2873bc97161999875e739137306d1b7 Author: Duhan BALCI Date: Sun Mar 29 03:48:46 2026 +0300 faz 1 & 2 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/.vscode/extensions.json b/frontend/.vscode/extensions.json new file mode 100644 index 0000000..a7cea0b --- /dev/null +++ b/frontend/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["Vue.volar"] +} diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..33895ab --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,5 @@ +# Vue 3 + TypeScript + Vite + +This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 ` + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..c2918b1 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,26 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@myriaddreamin/typst-ts-renderer": "^0.7.0-rc2", + "@myriaddreamin/typst-ts-web-compiler": "^0.7.0-rc2", + "@myriaddreamin/typst.ts": "^0.7.0-rc2", + "pinia": "^3.0.4", + "vue": "^3.5.30" + }, + "devDependencies": { + "@types/node": "^24.12.0", + "@vitejs/plugin-vue": "^6.0.5", + "@vue/tsconfig": "^0.9.0", + "typescript": "~5.9.3", + "vite": "^8.0.1", + "vue-tsc": "^3.2.5" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/fonts/NotoSans-Bold.ttf b/frontend/public/fonts/NotoSans-Bold.ttf new file mode 100644 index 0000000..713a768 Binary files /dev/null and b/frontend/public/fonts/NotoSans-Bold.ttf differ diff --git a/frontend/public/fonts/NotoSans-BoldItalic.ttf b/frontend/public/fonts/NotoSans-BoldItalic.ttf new file mode 100644 index 0000000..13cce27 Binary files /dev/null and b/frontend/public/fonts/NotoSans-BoldItalic.ttf differ diff --git a/frontend/public/fonts/NotoSans-Italic.ttf b/frontend/public/fonts/NotoSans-Italic.ttf new file mode 100644 index 0000000..67d948d Binary files /dev/null and b/frontend/public/fonts/NotoSans-Italic.ttf differ diff --git a/frontend/public/fonts/NotoSans-Regular.ttf b/frontend/public/fonts/NotoSans-Regular.ttf new file mode 100644 index 0000000..5de3fa7 Binary files /dev/null and b/frontend/public/fonts/NotoSans-Regular.ttf differ diff --git a/frontend/public/fonts/NotoSansMono-Regular.ttf b/frontend/public/fonts/NotoSansMono-Regular.ttf new file mode 100644 index 0000000..ba8fe00 Binary files /dev/null and b/frontend/public/fonts/NotoSansMono-Regular.ttf differ diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..8f92b27 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000..cc51a3d Binary files /dev/null and b/frontend/src/assets/hero.png differ diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/components/editor/EditorCanvas.vue b/frontend/src/components/editor/EditorCanvas.vue new file mode 100644 index 0000000..761e2e1 --- /dev/null +++ b/frontend/src/components/editor/EditorCanvas.vue @@ -0,0 +1,150 @@ + + + + + diff --git a/frontend/src/components/editor/ElementHandle.vue b/frontend/src/components/editor/ElementHandle.vue new file mode 100644 index 0000000..34045c3 --- /dev/null +++ b/frontend/src/components/editor/ElementHandle.vue @@ -0,0 +1,211 @@ + + + + + diff --git a/frontend/src/components/editor/InteractionOverlay.vue b/frontend/src/components/editor/InteractionOverlay.vue new file mode 100644 index 0000000..3c5053b --- /dev/null +++ b/frontend/src/components/editor/InteractionOverlay.vue @@ -0,0 +1,670 @@ + + + + + diff --git a/frontend/src/components/editor/TypstSvgLayer.vue b/frontend/src/components/editor/TypstSvgLayer.vue new file mode 100644 index 0000000..f7b4ee3 --- /dev/null +++ b/frontend/src/components/editor/TypstSvgLayer.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/frontend/src/components/panels/PropertiesPanel.vue b/frontend/src/components/panels/PropertiesPanel.vue new file mode 100644 index 0000000..ee5d0ab --- /dev/null +++ b/frontend/src/components/panels/PropertiesPanel.vue @@ -0,0 +1,446 @@ + + + + + diff --git a/frontend/src/components/panels/ToolboxPanel.vue b/frontend/src/components/panels/ToolboxPanel.vue new file mode 100644 index 0000000..a645698 --- /dev/null +++ b/frontend/src/components/panels/ToolboxPanel.vue @@ -0,0 +1,156 @@ + + + + + diff --git a/frontend/src/composables/useTypstCompiler.ts b/frontend/src/composables/useTypstCompiler.ts new file mode 100644 index 0000000..f475c19 --- /dev/null +++ b/frontend/src/composables/useTypstCompiler.ts @@ -0,0 +1,80 @@ +import { ref, watch, type Ref } from 'vue' +import type { ElementLayout } from '../core/template-to-typst' + +export function useTypstCompiler(markup: Ref) { + const svg = ref(null) + const error = ref(null) + const compiling = ref(false) + const layout = ref>({}) + + let worker: Worker | null = null + let requestId = 0 + let debounceTimer: ReturnType | null = null + + function initWorker() { + worker = new Worker(new URL('../workers/typst.worker.ts', import.meta.url), { + type: 'module', + }) + + worker.onmessage = (e: MessageEvent<{ + type: string + svg?: string + layout?: Record + error?: string + id: number + }>) => { + const data = e.data + if (data.id !== requestId) return + + compiling.value = false + if (data.type === 'result') { + svg.value = data.svg ?? null + layout.value = data.layout ?? {} + error.value = null + } else if (data.type === 'error') { + error.value = data.error ?? 'Bilinmeyen derleme hatası' + } + } + + worker.onerror = () => { + compiling.value = false + error.value = 'Worker hatası — yeniden başlatılıyor' + worker?.terminate() + worker = null + setTimeout(initWorker, 500) + } + } + + function compile(typstMarkup: string) { + if (!worker) initWorker() + requestId++ + compiling.value = true + worker!.postMessage({ type: 'compile', markup: typstMarkup, id: requestId }) + } + + watch( + markup, + (newMarkup) => { + if (debounceTimer) clearTimeout(debounceTimer) + debounceTimer = setTimeout(() => { + compile(newMarkup) + }, 200) + }, + { immediate: true } + ) + + function dispose() { + worker?.terminate() + worker = null + if (debounceTimer) clearTimeout(debounceTimer) + } + + return { + svg, + error, + compiling, + layout, + compile: () => compile(markup.value), + dispose, + } +} diff --git a/frontend/src/composables/useUndoRedo.ts b/frontend/src/composables/useUndoRedo.ts new file mode 100644 index 0000000..dde2db1 --- /dev/null +++ b/frontend/src/composables/useUndoRedo.ts @@ -0,0 +1,60 @@ +import { ref, watch, type Ref } from 'vue' + +export function useUndoRedo(source: Ref, maxHistory = 50) { + const undoStack = ref([]) as Ref + const redoStack = ref([]) as Ref + + let skipWatch = false + let debounceTimer: ReturnType | null = null + + // Başlangıç snapshot'ı + undoStack.value.push(JSON.stringify(source.value)) + + watch( + source, + () => { + if (skipWatch) return + + // Debounce: hızlı ardışık değişiklikleri birleştir + if (debounceTimer) clearTimeout(debounceTimer) + debounceTimer = setTimeout(() => { + const snap = JSON.stringify(source.value) + const last = undoStack.value[undoStack.value.length - 1] + if (snap === last) return + + undoStack.value.push(snap) + if (undoStack.value.length > maxHistory) { + undoStack.value.shift() + } + redoStack.value = [] + }, 300) + }, + { deep: true } + ) + + function undo() { + if (undoStack.value.length <= 1) return + const current = undoStack.value.pop()! + redoStack.value.push(current) + const prev = undoStack.value[undoStack.value.length - 1] + applySnapshot(prev) + } + + function redo() { + if (redoStack.value.length === 0) return + const next = redoStack.value.pop()! + undoStack.value.push(next) + applySnapshot(next) + } + + function applySnapshot(snap: string) { + skipWatch = true + Object.assign(source.value as object, JSON.parse(snap)) + skipWatch = false + } + + const canUndo = () => undoStack.value.length > 1 + const canRedo = () => redoStack.value.length > 0 + + return { undo, redo, canUndo, canRedo } +} diff --git a/frontend/src/core/template-to-typst.ts b/frontend/src/core/template-to-typst.ts new file mode 100644 index 0000000..8a7d143 --- /dev/null +++ b/frontend/src/core/template-to-typst.ts @@ -0,0 +1,396 @@ +import type { + Template, + TemplateElement, + ContainerElement, + StaticTextElement, + TextElement, + LineElement, + TextStyle, + SizeValue, + SizeConstraint, +} from './types' +import { isContainer } from './types' + +/** + * Template JSON → Typst markup dönüşümü. + * Container-based layout + layout query (her element için pozisyon/boyut bilgisi). + */ +export function templateToTypst(template: Template, data?: Record): string { + const lines: string[] = [] + + const { page, root } = template + const p = root.padding + lines.push( + `#set page(width: ${page.width}mm, height: ${page.height}mm, margin: (top: ${p.top}mm, right: ${p.right}mm, bottom: ${p.bottom}mm, left: ${p.left}mm))` + ) + lines.push('') + + if (data) { + lines.push(`#let data = ${jsonToTypstDict(data)}`) + } else { + lines.push(`#let data = (:)`) + } + lines.push('') + + // Tüm elemanları topla — topological order: leaf'ler önce, container'lar sonra + const allElements = collectTopological(root) + + // Her element'in content'ini #let ile tanımla + label ata + for (const el of allElements) { + const v = idToVar(el.id) + // Root container: sayfa margin'leri zaten padding'i karşılıyor, inset ekleme + const content = el === root + ? renderContainerContent(el, true) + : renderElementContent(el) + lines.push(`#let ${v} = ${content}`) + } + lines.push('') + + // Kök container'ı renderla — her eleman label'lı olmalı + lines.push(renderRootWithLabels(root)) + lines.push('') + + // Layout query — her eleman parent'ının available width'i ile ölçülür + lines.push(generateLayoutQuery(allElements, root, page.width)) + + return lines.join('\n') +} + +// --- Topological sort: leaf'ler önce --- + +function collectTopological(root: ContainerElement): TemplateElement[] { + const result: TemplateElement[] = [] + function walk(el: TemplateElement) { + if (isContainer(el)) { + for (const child of el.children) walk(child) + } + result.push(el) + } + walk(root) + return result +} + +// --- Element content rendering --- + +function renderElementContent(el: TemplateElement): string { + switch (el.type) { + case 'container': + return renderContainerContent(el) + case 'static_text': + return renderStaticTextContent(el) + case 'text': + return renderTextContent(el) + case 'line': + return renderLineContent(el) + } +} + +function renderContainerContent(el: ContainerElement, skipPadding = false): string { + const boxParams = buildBoxParams(el, skipPadding) + + const flowChildren = el.children.filter(c => c.position.type !== 'absolute') + const absoluteChildren = el.children.filter(c => c.position.type === 'absolute') + + const innerParts: string[] = [] + + if (flowChildren.length > 0) { + const dir = el.direction === 'row' ? 'ltr' : 'ttb' + const gap = el.gap > 0 ? `, spacing: ${el.gap}mm` : '' + + if (flowChildren.length === 1) { + // Label'lı referans + innerParts.push(`#[#${idToVar(flowChildren[0].id)} <${flowChildren[0].id}>]`) + } else { + const items = flowChildren.map(c => + ` [#${idToVar(c.id)} <${c.id}>]` + ).join(',\n') + innerParts.push(`#stack(dir: ${dir}${gap},\n${items}\n )`) + } + } + + for (const child of absoluteChildren) { + if (child.position.type === 'absolute') { + innerParts.push( + `#place(top + left, dx: ${child.position.x}mm, dy: ${child.position.y}mm)[#${idToVar(child.id)} <${child.id}>]` + ) + } + } + + // Boş container'a minimum yükseklik ver + if (innerParts.length === 0) { + innerParts.push('#v(5mm)') + } + + const inner = innerParts.join('\n ') + return `box(${boxParams})[\n ${inner}\n]` +} + +function renderStaticTextContent(el: StaticTextElement): string { + const sizeParams = buildBoxSizeParams(el.size, false) + const textCmd = buildTextCommand(el.style, escapeTypstContent(el.content)) + + if (sizeParams) { + return `box(${sizeParams})[${textCmd}]` + } + return `[${textCmd}]` +} + +function renderTextContent(el: TextElement): string { + const sizeParams = buildBoxSizeParams(el.size, false) + const dataAccess = `#data.${el.binding.path}` + const content = el.content ? escapeTypstContent(el.content) + dataAccess : dataAccess + const textCmd = buildTextCommand(el.style, content) + + if (sizeParams) { + return `box(${sizeParams})[${textCmd}]` + } + return `[${textCmd}]` +} + +function renderLineContent(el: LineElement): string { + const stroke = el.style.strokeWidth ?? 0.5 + const color = el.style.strokeColor ?? '#000000' + // line() fr kabul etmez; measure() göreceli birimleri çözemez + // Bu yüzden line'ı box(width: 100%) ile sarıyoruz + if (el.size.width.type === 'fr' || el.size.width.type === 'auto') { + return `box(width: 100%)[#line(length: 100%, stroke: ${stroke}pt + rgb("${color}"))]` + } + const widthStr = sizeValueToTypst(el.size.width) + return `line(length: ${widthStr}, stroke: ${stroke}pt + rgb("${color}"))` +} + +// --- Root rendering with labels --- + +function renderRootWithLabels(root: ContainerElement): string { + return `#[#${idToVar(root.id)} <${root.id}>]` +} + +// --- Layout query --- + +function generateLayoutQuery( + elements: TemplateElement[], + root: ContainerElement, + pageWidth: number, +): string { + // Her eleman için parent'ın available width'ini hesapla + const parentMap = buildParentMap(root) + const widthMap = computeAvailableWidths(root, pageWidth, parentMap) + + const varLines = elements.map(el => { + const v = idToVar(el.id) + const availW = widthMap.get(el.id) ?? pageWidth + return ` let ${v}p = locate(<${el.id}>).position() + let ${v}s = measure(${v}, width: ${Math.round(availW * 100) / 100}mm) + result += "${el.id}:" + repr(${v}p.x) + "," + repr(${v}p.y) + "," + repr(${v}s.width) + "," + repr(${v}s.height) + "|"` + }).join('\n') + + return `#context { + let result = "" +${varLines} + place(bottom + right, text(size: 0.1pt, fill: white)[#result]) +}` +} + +/** Her elemanın parent'ını tutan map */ +function buildParentMap(root: ContainerElement): Map { + const map = new Map() + function walk(parent: ContainerElement) { + for (const child of parent.children) { + map.set(child.id, parent) + if (isContainer(child)) walk(child) + } + } + walk(root) + return map +} + +/** Her eleman için measure'a verilecek available width (mm) hesapla */ +function computeAvailableWidths( + root: ContainerElement, + pageWidth: number, + parentMap: Map, +): Map { + const map = new Map() + + // Root: sayfa margin'leri root.padding'den geliyor, root box'ta inset yok + // Root'un content area genişliği = sayfa - margin sol - margin sağ + const rootContentWidth = pageWidth - root.padding.left - root.padding.right + map.set(root.id, rootContentWidth) + + function getContainerInnerWidth(c: ContainerElement): number { + const ownWidth = map.get(c.id) ?? rootContentWidth + // Root'un padding'i zaten sayfa margin olarak uygulandı, tekrar çıkarma + if (c.id === root.id) return ownWidth + return ownWidth - c.padding.left - c.padding.right + } + + function walk(container: ContainerElement) { + const innerW = getContainerInnerWidth(container) + + // row container ise çocuklar genişliği paylaşır + // column container ise her çocuk full genişlik alır + if (container.direction === 'column') { + for (const child of container.children) { + // Fixed genişlikli çocuk kendi genişliğini alır, diğerleri parent inner width + const childW = child.size.width.type === 'fixed' ? child.size.width.value : innerW + map.set(child.id, childW) + if (isContainer(child)) walk(child) + } + } else { + // row: fixed genişlikli çocukları çıkar, kalanı fr'lara dağıt + let usedWidth = 0 + let totalFr = 0 + const gap = container.gap * Math.max(0, container.children.length - 1) + + for (const child of container.children) { + if (child.size.width.type === 'fixed') { + usedWidth += child.size.width.value + } else if (child.size.width.type === 'fr') { + totalFr += child.size.width.value + } + } + + const remainingW = Math.max(0, innerW - usedWidth - gap) + + for (const child of container.children) { + let childW: number + if (child.size.width.type === 'fixed') { + childW = child.size.width.value + } else if (child.size.width.type === 'fr') { + childW = totalFr > 0 ? (child.size.width.value / totalFr) * remainingW : remainingW + } else { + childW = innerW // auto + } + map.set(child.id, childW) + if (isContainer(child)) walk(child) + } + } + } + + walk(root) + return map +} + +// --- Yardımcılar --- + +function idToVar(id: string): string { + return 'v_' + id.replace(/[^a-zA-Z0-9]/g, '_') +} + +function buildBoxParams(el: ContainerElement, skipPadding = false): string { + const parts: string[] = [] + + // box() fr kabul etmez, fr → 100% olarak çevir + const sizeParams = buildBoxSizeParams(el.size, false) + if (sizeParams) parts.push(sizeParams) + + if (!skipPadding) { + const hasPadding = el.padding.top > 0 || el.padding.right > 0 || el.padding.bottom > 0 || el.padding.left > 0 + if (hasPadding) { + parts.push(`inset: (top: ${el.padding.top}mm, right: ${el.padding.right}mm, bottom: ${el.padding.bottom}mm, left: ${el.padding.left}mm)`) + } + } + + const styleParams = buildContainerStyleParams(el) + if (styleParams) parts.push(styleParams) + + return parts.join(', ') +} + +function buildBoxSizeParams(size: SizeConstraint, allowFr = true): string { + const parts: string[] = [] + const w = sizeValueToTypst(size.width, allowFr) + if (w !== 'auto') parts.push(`width: ${w}`) + const h = sizeValueToTypst(size.height, allowFr) + if (h !== 'auto') parts.push(`height: ${h}`) + return parts.join(', ') +} + +function sizeValueToTypst(sv: SizeValue, allowFr = true): string { + switch (sv.type) { + case 'fixed': return `${sv.value}mm` + case 'auto': return 'auto' + case 'fr': return allowFr ? `${sv.value}fr` : '100%' + } +} + +function buildContainerStyleParams(el: ContainerElement): string { + const parts: string[] = [] + if (el.style.backgroundColor) parts.push(`fill: rgb("${el.style.backgroundColor}")`) + if (el.style.borderColor && (el.style.borderWidth ?? 0) > 0) { + parts.push(`stroke: ${el.style.borderWidth ?? 1}pt + rgb("${el.style.borderColor}")`) + } + if (el.style.borderRadius && el.style.borderRadius > 0) { + parts.push(`radius: ${el.style.borderRadius}pt`) + } + return parts.join(', ') +} + +function buildTextCommand(style: TextStyle, content: string): string { + const parts: string[] = [] + if (style.fontSize) parts.push(`size: ${style.fontSize}pt`) + if (style.fontWeight === 'bold') parts.push(`weight: "bold"`) + if (style.fontFamily) parts.push(`font: "${style.fontFamily}"`) + if (style.color) parts.push(`fill: rgb("${style.color}")`) + + const params = parts.join(', ') + let result = `#text(${params})[${content}]` + + if (style.align && style.align !== 'left') { + result = `#align(${style.align})[${result}]` + } + return result +} + +function escapeTypstContent(text: string): string { + return text + .replace(/\\/g, '\\\\') + .replace(/\[/g, '\\[') + .replace(/\]/g, '\\]') + .replace(/#/g, '\\#') + .replace(/\$/g, '\\$') + .replace(/@/g, '\\@') + .replace(//g, '\\>') +} + +function jsonToTypstDict(obj: unknown): string { + if (obj === null || obj === undefined) return 'none' + if (typeof obj === 'string') return `"${obj.replace(/"/g, '\\"')}"` + if (typeof obj === 'number') return String(obj) + if (typeof obj === 'boolean') return obj ? 'true' : 'false' + if (Array.isArray(obj)) { + const items = obj.map(item => jsonToTypstDict(item)).join(', ') + return `(${items},)` + } + if (typeof obj === 'object') { + const entries = Object.entries(obj as Record) + .map(([key, val]) => `${key}: ${jsonToTypstDict(val)}`) + .join(', ') + return `(${entries})` + } + return 'none' +} + +// --- Layout data parsing --- + +export interface ElementLayout { + x: number // pt + y: number // pt + width: number // pt + height: number // pt +} + +export function parseLayoutFromSvg(svgString: string): Record { + const result: Record = {} + const matches = svgString.matchAll(/([a-zA-Z0-9_-]+):([\d.]+)pt,([\d.]+)pt,([\d.]+)pt,([\d.]+)pt\|/g) + for (const m of matches) { + result[m[1]] = { + x: parseFloat(m[2]), + y: parseFloat(m[3]), + width: parseFloat(m[4]), + height: parseFloat(m[5]), + } + } + return result +} diff --git a/frontend/src/core/types.ts b/frontend/src/core/types.ts new file mode 100644 index 0000000..0d4144b --- /dev/null +++ b/frontend/src/core/types.ts @@ -0,0 +1,176 @@ +// Template JSON veri modeli tip tanımları + +// --- Boyut sistemi --- + +/** Sabit mm, içeriğe göre (auto), veya kalan alanı doldur (fr) */ +export type SizeValue = + | { type: 'fixed'; value: number } // mm + | { type: 'auto' } + | { type: 'fr'; value: number } // ör: 1fr, 2fr + +export interface SizeConstraint { + width: SizeValue + height: SizeValue + minWidth?: number // mm + minHeight?: number // mm + maxWidth?: number // mm + maxHeight?: number // mm +} + +// Kısayol oluşturucular +export const sz = { + fixed: (value: number): SizeValue => ({ type: 'fixed', value }), + auto: (): SizeValue => ({ type: 'auto' }), + fr: (value = 1): SizeValue => ({ type: 'fr', value }), +} + +export interface PageSettings { + width: number // mm + height: number // mm +} + +export interface Padding { + top: number + right: number + bottom: number + left: number +} + +// --- Positioning --- + +export type PositionMode = + | { type: 'flow' } // Container flow'una katıl (varsayılan) + | { type: 'absolute'; x: number; y: number } // Container içinde absolute (mm) + +// --- Stil --- + +export interface TextStyle { + fontSize?: number // pt + fontWeight?: 'normal' | 'bold' + fontFamily?: string + color?: string // hex + align?: 'left' | 'center' | 'right' +} + +export interface LineStyle { + strokeColor?: string + strokeWidth?: number // pt +} + +export interface ContainerStyle { + backgroundColor?: string + borderColor?: string + borderWidth?: number // pt + borderRadius?: number // pt +} + +// --- Binding --- + +export interface ScalarBinding { + type: 'scalar' + path: string // ör: "firma.unvan" +} + +export type ElementBinding = ScalarBinding + +// --- Element tipleri --- + +interface BaseElement { + id: string + position: PositionMode + size: SizeConstraint +} + +export interface StaticTextElement extends BaseElement { + type: 'static_text' + content: string + style: TextStyle +} + +export interface TextElement extends BaseElement { + type: 'text' + content?: string // opsiyonel prefix + binding: ScalarBinding + style: TextStyle +} + +export interface LineElement extends BaseElement { + type: 'line' + style: LineStyle +} + +export interface ContainerElement extends BaseElement { + type: 'container' + direction: 'row' | 'column' + gap: number // mm — çocuklar arası boşluk + padding: Padding + align: 'start' | 'center' | 'end' | 'stretch' + justify: 'start' | 'center' | 'end' | 'space-between' + style: ContainerStyle + children: TemplateElement[] +} + +export type LeafElement = StaticTextElement | TextElement | LineElement +export type TemplateElement = LeafElement | ContainerElement + +// --- Template --- + +/** Sayfa kök container gibi davranır */ +export interface Template { + id: string + name: string + page: PageSettings + fonts: string[] + root: ContainerElement // kök container = sayfa +} + +// --- Editor state --- + +export interface EditorState { + selectedElementId: string | null + zoom: number // 0.25 - 4.0 + panX: number + panY: number + isDragging: boolean +} + +// --- Yardımcılar --- + +export function isContainer(el: TemplateElement): el is ContainerElement { + return el.type === 'container' +} + +export function isLeaf(el: TemplateElement): el is LeafElement { + return el.type !== 'container' +} + +/** Ağaçta bir element'i ID ile bulur */ +export function findElementById( + root: ContainerElement, + id: string +): TemplateElement | undefined { + if (root.id === id) return root + for (const child of root.children) { + if (child.id === id) return child + if (isContainer(child)) { + const found = findElementById(child, id) + if (found) return found + } + } + return undefined +} + +/** Bir element'in parent container'ını bulur */ +export function findParent( + root: ContainerElement, + id: string +): ContainerElement | undefined { + for (const child of root.children) { + if (child.id === id) return root + if (isContainer(child)) { + const found = findParent(child, id) + if (found) return found + } + } + return undefined +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..86a4c74 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,8 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import App from './App.vue' +import './styles/editor.css' + +const app = createApp(App) +app.use(createPinia()) +app.mount('#app') diff --git a/frontend/src/stores/editor.ts b/frontend/src/stores/editor.ts new file mode 100644 index 0000000..a2e5e08 --- /dev/null +++ b/frontend/src/stores/editor.ts @@ -0,0 +1,65 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import type { TemplateElement } from '../core/types' + +export const useEditorStore = defineStore('editor', () => { + const selectedElementId = ref(null) + const zoom = ref(1) + const panX = ref(0) + const panY = ref(0) + const isDragging = ref(false) + + // Toolbox'tan sürüklenen eleman (henüz eklenmedi) + const draggedNewElement = ref(null) + const dropTargetContainerId = ref(null) + + const zoomPercent = computed(() => Math.round(zoom.value * 100)) + + function selectElement(id: string | null) { + selectedElementId.value = id + } + + function clearSelection() { + selectedElementId.value = null + } + + function setZoom(value: number) { + zoom.value = Math.max(0.25, Math.min(4, value)) + } + + function setDragging(value: boolean) { + isDragging.value = value + } + + // Toolbox drag + function startDragNewElement(el: TemplateElement) { + draggedNewElement.value = el + } + + function setDropTargetContainer(id: string | null) { + dropTargetContainerId.value = id + } + + function endDragNewElement() { + draggedNewElement.value = null + dropTargetContainerId.value = null + } + + return { + selectedElementId, + zoom, + panX, + panY, + isDragging, + draggedNewElement, + dropTargetContainerId, + zoomPercent, + selectElement, + clearSelection, + setZoom, + setDragging, + startDragNewElement, + setDropTargetContainer, + endDragNewElement, + } +}) diff --git a/frontend/src/stores/template.ts b/frontend/src/stores/template.ts new file mode 100644 index 0000000..7b947ae --- /dev/null +++ b/frontend/src/stores/template.ts @@ -0,0 +1,137 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import type { Template, TemplateElement, ContainerElement, SizeConstraint, PositionMode } from '../core/types' +import { findElementById, findParent, isContainer, sz } from '../core/types' +import { templateToTypst } from '../core/template-to-typst' +import { useUndoRedo } from '../composables/useUndoRedo' + +function createDefaultTemplate(): Template { + return { + id: 'tpl_default', + name: 'Yeni Şablon', + page: { width: 210, height: 297 }, + fonts: ['Noto Sans'], + root: { + id: 'root', + type: 'container', + position: { type: 'flow' }, + size: { width: sz.auto(), height: sz.auto() }, + direction: 'column', + gap: 5, + padding: { top: 15, right: 15, bottom: 15, left: 15 }, + align: 'stretch', + justify: 'start', + style: {}, + children: [ + { + id: 'el_001', + type: 'static_text', + position: { type: 'flow' }, + size: { width: sz.auto(), height: sz.auto() }, + style: { fontSize: 18, fontWeight: 'bold', color: '#1a1a1a' }, + content: 'dreport', + }, + { + id: 'el_002', + type: 'static_text', + position: { type: 'flow' }, + size: { width: sz.auto(), height: sz.auto() }, + style: { fontSize: 11, color: '#666666' }, + content: 'Belge tasarım aracı — sürükle ve bırak', + }, + ], + }, + } +} + +export const useTemplateStore = defineStore('template', () => { + const template = ref