faz 1 & 2

This commit is contained in:
2026-03-29 03:48:46 +03:00
commit 07869f03c2
36 changed files with 3186 additions and 0 deletions

115
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import { onMounted, onBeforeUnmount } from 'vue'
import EditorCanvas from './components/editor/EditorCanvas.vue'
import ToolboxPanel from './components/panels/ToolboxPanel.vue'
import PropertiesPanel from './components/panels/PropertiesPanel.vue'
import { useTemplateStore } from './stores/template'
import { useEditorStore } from './stores/editor'
const templateStore = useTemplateStore()
const editorStore = useEditorStore()
function onKeyDown(e: KeyboardEvent) {
// Delete / Backspace — seçili elemanı sil
if ((e.key === 'Delete' || e.key === 'Backspace') && editorStore.selectedElementId) {
// Input/textarea içindeyse yoksay
const tag = (e.target as HTMLElement)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
e.preventDefault()
const id = editorStore.selectedElementId
if (id && id !== 'root') {
editorStore.clearSelection()
templateStore.removeElement(id)
}
}
// Escape — seçimi temizle
if (e.key === 'Escape') {
editorStore.clearSelection()
}
// Ctrl+Z — undo
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
e.preventDefault()
templateStore.undo()
}
// Ctrl+Shift+Z — redo
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && e.shiftKey) {
e.preventDefault()
templateStore.redo()
}
}
onMounted(() => window.addEventListener('keydown', onKeyDown))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeyDown))
</script>
<template>
<div class="app-layout">
<header class="app-header">
<h1>dreport</h1>
<span class="app-header__subtitle">Belge Tasarim Araci</span>
</header>
<main class="app-main">
<aside class="app-sidebar app-sidebar--left">
<ToolboxPanel />
</aside>
<EditorCanvas />
<aside class="app-sidebar app-sidebar--right">
<PropertiesPanel />
</aside>
</main>
</div>
</template>
<style scoped>
.app-layout {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.app-header {
display: flex;
align-items: baseline;
gap: 12px;
padding: 8px 16px;
background: #1e293b;
color: white;
flex-shrink: 0;
}
.app-header h1 {
margin: 0;
font-size: 18px;
font-weight: 700;
letter-spacing: -0.5px;
}
.app-header__subtitle {
font-size: 13px;
color: #94a3b8;
}
.app-main {
display: flex;
flex: 1;
min-height: 0;
}
.app-sidebar {
width: 260px;
background: #f8fafc;
border-right: 1px solid #e2e8f0;
flex-shrink: 0;
overflow-y: auto;
}
.app-sidebar--right {
border-right: none;
border-left: 1px solid #e2e8f0;
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,150 @@
<script setup lang="ts">
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import { storeToRefs } from 'pinia'
import { useTemplateStore } from '../../stores/template'
import { useEditorStore } from '../../stores/editor'
import { useTypstCompiler } from '../../composables/useTypstCompiler'
import TypstSvgLayer from './TypstSvgLayer.vue'
import InteractionOverlay from './InteractionOverlay.vue'
const templateStore = useTemplateStore()
const editorStore = useEditorStore()
const { typstMarkup } = storeToRefs(templateStore)
const containerRef = ref<HTMLElement | null>(null)
const containerWidth = ref(800)
// Typst compiler
const { svg, error, compiling, layout, dispose } = useTypstCompiler(typstMarkup)
// mm → px dönüşüm katsayısı
const scale = computed(() => {
return (containerWidth.value / templateStore.template.page.width) * editorStore.zoom
})
// Sayfa boyutu px cinsinden + margin CSS variables
const pageStyle = computed(() => {
const w = templateStore.template.page.width * scale.value
const h = templateStore.template.page.height * scale.value
const m = templateStore.template.root.padding
return {
width: `${w}px`,
height: `${h}px`,
'--page-margin-top': `${m.top * scale.value}px`,
'--page-margin-right': `${m.right * scale.value}px`,
'--page-margin-bottom': `${m.bottom * scale.value}px`,
'--page-margin-left': `${m.left * scale.value}px`,
}
})
// Container boyutunu izle
let resizeObserver: ResizeObserver | null = null
onMounted(() => {
if (containerRef.value) {
resizeObserver = new ResizeObserver(entries => {
const entry = entries[0]
if (entry) containerWidth.value = entry.contentRect.width
})
resizeObserver.observe(containerRef.value)
}
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
dispose()
})
// Zoom
function onWheel(e: WheelEvent) {
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
const delta = e.deltaY > 0 ? -0.1 : 0.1
editorStore.setZoom(editorStore.zoom + delta)
}
}
</script>
<template>
<div class="editor-canvas" ref="containerRef" @wheel="onWheel">
<!-- Hata banner -->
<div v-if="error" class="editor-canvas__error">
{{ error }}
</div>
<!-- Derleme göstergesi -->
<div v-if="compiling" class="editor-canvas__compiling">
Derleniyor...
</div>
<!-- Sayfa -->
<div class="editor-canvas__page" :style="pageStyle">
<TypstSvgLayer :svg="svg" />
<InteractionOverlay :scale="scale" :layout="layout" :page-width-pt="templateStore.template.page.width * 2.8346" />
</div>
<!-- Zoom göstergesi -->
<div class="editor-canvas__zoom">
%{{ editorStore.zoomPercent }}
</div>
</div>
</template>
<style scoped>
.editor-canvas {
flex: 1;
overflow: auto;
background: #e5e7eb;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 40px;
position: relative;
min-height: 0;
}
.editor-canvas__page {
background: white;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
position: relative;
flex-shrink: 0;
}
.editor-canvas__error {
position: absolute;
top: 8px;
left: 50%;
transform: translateX(-50%);
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
border-radius: 6px;
padding: 6px 16px;
font-size: 13px;
z-index: 100;
}
.editor-canvas__compiling {
position: absolute;
top: 8px;
right: 16px;
background: #eff6ff;
color: #2563eb;
border-radius: 6px;
padding: 4px 12px;
font-size: 12px;
z-index: 100;
}
.editor-canvas__zoom {
position: absolute;
bottom: 12px;
right: 16px;
background: rgba(0, 0, 0, 0.6);
color: white;
border-radius: 4px;
padding: 2px 8px;
font-size: 12px;
z-index: 100;
}
</style>

View File

@@ -0,0 +1,211 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { TemplateElement, ContainerElement } from '../../core/types'
import { isContainer } from '../../core/types'
import { useEditorStore } from '../../stores/editor'
import { useTemplateStore } from '../../stores/template'
const props = defineProps<{
element: TemplateElement
scale: number
}>()
const editorStore = useEditorStore()
const templateStore = useTemplateStore()
const isSelected = computed(() => editorStore.selectedElementId === props.element.id)
const isContainerEl = computed(() => isContainer(props.element))
const isAbsolute = computed(() => props.element.position.type === 'absolute')
// --- CSS style: layout'u Typst ile eşleştir ---
const layoutStyle = computed(() => {
const el = props.element
const s = props.scale
const style: Record<string, string> = {}
// Absolute positioning
if (el.position.type === 'absolute') {
style.position = 'absolute'
style.left = `${el.position.x * s}px`
style.top = `${el.position.y * s}px`
}
// Boyut
const w = el.size.width
const h = el.size.height
if (w.type === 'fixed') style.width = `${w.value * s}px`
else if (w.type === 'fr') style.flex = `${w.value} 1 0%`
// auto → doğal boyut, CSS default
if (h.type === 'fixed') style.height = `${h.value * s}px`
// auto/fr height → CSS default
// Container ise flexbox
if (isContainer(el)) {
const c = el as ContainerElement
style.display = 'flex'
style.flexDirection = c.direction === 'row' ? 'row' : 'column'
if (c.gap > 0) style.gap = `${c.gap * s}px`
if (c.padding.top || c.padding.right || c.padding.bottom || c.padding.left) {
style.padding = `${c.padding.top * s}px ${c.padding.right * s}px ${c.padding.bottom * s}px ${c.padding.left * s}px`
}
// align (cross-axis)
const alignMap = { start: 'flex-start', center: 'center', end: 'flex-end', stretch: 'stretch' }
if (c.direction === 'column') {
style.alignItems = alignMap[c.align] || 'stretch'
} else {
style.alignItems = alignMap[c.align] || 'flex-start'
}
// justify (main-axis)
const justifyMap = { start: 'flex-start', center: 'center', end: 'flex-end', 'space-between': 'space-between' }
style.justifyContent = justifyMap[c.justify] || 'flex-start'
}
return style
})
// --- Drag state (sadece absolute elemanlar) ---
const pointerStart = ref({ x: 0, y: 0 })
const isDragging = ref(false)
const dragTransform = ref({ x: 0, y: 0 })
const isInteracting = computed(() => isDragging.value)
function onPointerDown(e: PointerEvent) {
e.stopPropagation()
editorStore.selectElement(props.element.id)
if (!isAbsolute.value) return
const target = e.currentTarget as HTMLElement
target.setPointerCapture(e.pointerId)
pointerStart.value = { x: e.clientX, y: e.clientY }
dragTransform.value = { x: 0, y: 0 }
isDragging.value = true
editorStore.setDragging(true)
}
function onPointerMove(e: PointerEvent) {
if (!isDragging.value) return
dragTransform.value = {
x: e.clientX - pointerStart.value.x,
y: e.clientY - pointerStart.value.y,
}
}
function onPointerUp() {
if (!isDragging.value) return
isDragging.value = false
editorStore.setDragging(false)
if (props.element.position.type !== 'absolute') return
const dxMm = dragTransform.value.x / props.scale
const dyMm = dragTransform.value.y / props.scale
if (Math.abs(dxMm) > 0.1 || Math.abs(dyMm) > 0.1) {
templateStore.updateElementPosition(props.element.id, {
type: 'absolute',
x: Math.round((props.element.position.x + dxMm) * 10) / 10,
y: Math.round((props.element.position.y + dyMm) * 10) / 10,
})
}
dragTransform.value = { x: 0, y: 0 }
}
const dragStyle = computed(() => {
if (!isDragging.value) return {}
return { transform: `translate(${dragTransform.value.x}px, ${dragTransform.value.y}px)` }
})
</script>
<template>
<div
class="element-handle"
:class="{
'element-handle--selected': isSelected,
'element-handle--interacting': isInteracting,
'element-handle--container': isContainerEl,
'element-handle--absolute': isAbsolute,
'element-handle--leaf': !isContainerEl,
}"
:style="{ ...layoutStyle, ...dragStyle }"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
>
<!-- Container çocuklarını recursive render et -->
<template v-if="isContainerEl && 'children' in element">
<ElementHandle
v-for="child in (element as ContainerElement).children"
:key="child.id"
:element="child"
:scale="scale"
/>
</template>
<!-- Seçim göstergesi -->
<div v-if="isSelected" class="selection-border" />
</div>
</template>
<style scoped>
.element-handle {
position: relative;
box-sizing: border-box;
min-height: 2px;
}
.element-handle--absolute {
position: absolute;
cursor: move;
}
.element-handle--leaf {
/* Leaf elemanlar tıklanabilir alan */
min-height: 4px;
}
/* Hover efekti */
.element-handle:hover > .selection-border,
.element-handle--selected > .selection-border {
display: block;
}
.selection-border {
display: none;
position: absolute;
inset: -1px;
border: 1.5px solid rgb(59, 130, 246);
pointer-events: none;
z-index: 10;
}
.element-handle--container > .selection-border {
border-color: rgb(139, 92, 246);
border-style: dashed;
}
.element-handle:hover > .selection-border {
border-color: rgba(59, 130, 246, 0.4);
}
.element-handle--container:hover > .selection-border {
border-color: rgba(139, 92, 246, 0.3);
}
.element-handle--selected > .selection-border {
border-color: rgb(59, 130, 246);
}
.element-handle--selected.element-handle--container > .selection-border {
border-color: rgb(139, 92, 246);
}
.element-handle--interacting {
opacity: 0.7;
}
</style>

View File

@@ -0,0 +1,670 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useTemplateStore } from '../../stores/template'
import { useEditorStore } from '../../stores/editor'
import type { ElementLayout } from '../../core/template-to-typst'
import type { TemplateElement, SizeValue, ContainerElement } from '../../core/types'
import { isContainer, sz } from '../../core/types'
const props = defineProps<{
scale: number
layout: Record<string, ElementLayout>
pageWidthPt: number
}>()
const templateStore = useTemplateStore()
const editorStore = useEditorStore()
// pt→px dönüşüm katsayısı
const ptToPx = computed(() => {
const pageWidthPx = templateStore.template.page.width * props.scale
return props.pageWidthPt > 0 ? pageWidthPx / props.pageWidthPt : 1
})
// Tüm elemanları flat olarak topla (root hariç)
const flatElements = computed(() => {
const result: TemplateElement[] = []
function walk(el: TemplateElement) {
if (isContainer(el)) {
for (const child of el.children) {
result.push(child)
walk(child)
}
}
}
walk(templateStore.template.root)
return result
})
// Tüm container'lar (root dahil) — drop target tespiti için
const allContainers = computed(() => {
const result: ContainerElement[] = [templateStore.template.root]
function walk(el: TemplateElement) {
if (isContainer(el)) {
result.push(el)
for (const child of el.children) walk(child)
}
}
for (const child of templateStore.template.root.children) walk(child)
return result
})
function getElementStyle(el: TemplateElement) {
const l = props.layout[el.id]
if (!l) return { display: 'none' }
const s = ptToPx.value
const h = l.height * s
const minH = 8
const actualH = Math.max(h, minH)
const yOffset = h < minH ? (minH - h) / 2 : 0
return {
position: 'absolute' as const,
left: `${l.x * s}px`,
top: `${l.y * s - yOffset}px`,
width: `${l.width * s}px`,
height: `${actualH}px`,
}
}
// --- Seçim ---
function onElementClick(e: PointerEvent, id: string) {
e.stopPropagation()
if (didDrag.value) return
editorStore.selectElement(id)
}
function onCanvasClick() {
editorStore.selectElement('root')
}
// ============================================================
// Ortak drop target sistemi
// ============================================================
const dropTargetContainerId = ref<string | null>(null)
const dropVisualIndex = ref<number | null>(null)
const dropLogicalIndex = ref<number | null>(null)
/** Mouse pozisyonuna göre en derin container'ı bul */
function findDeepestContainer(mouseX: number, mouseY: number, excludeId?: string): ContainerElement {
const s = ptToPx.value
let best: ContainerElement = templateStore.template.root
for (const c of allContainers.value) {
if (c.id === excludeId) continue
const l = props.layout[c.id]
if (!l) continue
const cx = l.x * s
const cy = l.y * s
const cw = l.width * s
const ch = l.height * s
if (mouseX >= cx && mouseX <= cx + cw && mouseY >= cy && mouseY <= cy + ch) {
// Daha küçük (daha derin) container'ı tercih et
const bestL = props.layout[best.id]
if (!bestL || (cw * ch < bestL.width * s * bestL.height * s)) {
best = c
}
}
}
return best
}
/** Container içinde drop index hesapla */
function computeDropIndex(container: ContainerElement, mouseY: number, excludeId?: string) {
const s = ptToPx.value
const flowChildren = container.children.filter(c => c.position.type !== 'absolute' && c.id !== excludeId)
let visualIdx = flowChildren.length
for (let i = 0; i < flowChildren.length; i++) {
const l = props.layout[flowChildren[i].id]
if (!l) continue
const centerY = l.y * s + (l.height * s) / 2
if (mouseY < centerY) {
visualIdx = i
break
}
}
// 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 currentIdx = allFlow.findIndex(c => c.id === excludeId)
if (currentIdx >= 0) {
// visualIdx, excludeId çıkarılmış listede. Gerçek listedeki pozisyona çevir.
// flowChildren zaten excludeId hariç, dolayısıyla visualIdx doğrudan gerçek insert indexi
// Ama reorderChild fromIndex/toIndex aynı liste üzerinde çalışır
// Gerçek listedeki index'e çevir
let realIdx = 0
let count = 0
for (let i = 0; i < allFlow.length; i++) {
if (allFlow[i].id === excludeId) continue
if (count === visualIdx) { realIdx = i; break }
count++
realIdx = i + 1
}
logicalIdx = realIdx
if (realIdx > currentIdx) logicalIdx--
}
}
return { visualIdx, logicalIdx }
}
function updateDropFromMouse(mouseX: number, mouseY: number, excludeId?: string) {
const container = findDeepestContainer(mouseX, mouseY, excludeId)
dropTargetContainerId.value = container.id
const { visualIdx, logicalIdx } = computeDropIndex(container, mouseY, excludeId)
dropVisualIndex.value = visualIdx
dropLogicalIndex.value = logicalIdx
}
function clearDropTarget() {
dropTargetContainerId.value = null
dropVisualIndex.value = null
dropLogicalIndex.value = null
}
// Drop indicator pozisyonu (ortak)
const dropIndicatorStyle = computed(() => {
if (dropTargetContainerId.value === null || dropVisualIndex.value === null) {
return { display: 'none' }
}
const container = templateStore.getElementById(dropTargetContainerId.value)
if (!container || !isContainer(container)) return { display: 'none' }
const s = ptToPx.value
const idx = dropVisualIndex.value
// Sürüklenen elemanı çıkar
const dragId = dragElementId.value
const flowChildren = container.children.filter(c => c.position.type !== 'absolute' && c.id !== dragId)
// Gap'in ortasına yerleştir: üstteki elemanın alt kenarı ile alttaki elemanın üst kenarı arası
let y = 0
if (idx === 0 && flowChildren.length > 0) {
// İlk pozisyon: ilk elemanın üst kenarı ile container üst kenarı arası
const l = props.layout[flowChildren[0].id]
const cl = props.layout[container.id]
if (l && cl) {
y = (cl.y * s + l.y * s) / 2 // container top ile eleman top arası
} else if (l) {
y = l.y * s - 4
}
} else if (idx < flowChildren.length && idx > 0) {
// Ortada: üstteki elemanın altı ile alttaki elemanın üstü arası
const above = props.layout[flowChildren[idx - 1].id]
const below = props.layout[flowChildren[idx].id]
if (above && below) {
const aboveBottom = (above.y + above.height) * s
const belowTop = below.y * s
y = (aboveBottom + belowTop) / 2
}
} else if (idx === 0 && flowChildren.length === 0) {
// Boş container
const cl = props.layout[container.id]
if (cl) y = cl.y * s + 8
} else if (flowChildren.length > 0) {
// Son pozisyon: son elemanın altından gap kadar aşağıda
const last = flowChildren[flowChildren.length - 1]
const l = props.layout[last.id]
if (l) {
const gapPx = container.gap * props.scale
y = (l.y + l.height) * s + gapPx / 2
}
}
const cl = props.layout[container.id]
const x = cl ? cl.x * s : 0
const width = cl ? cl.width * s : 100
return {
position: 'absolute' as const,
left: `${x}px`,
top: `${y}px`,
width: `${width}px`,
height: '2px',
background: 'rgb(59, 130, 246)',
borderRadius: '1px',
zIndex: 1000,
pointerEvents: 'none' as const,
}
})
// ============================================================
// Mevcut eleman sürükleme (reorder + cross-container move)
// ============================================================
const isDragging = ref(false)
const didDrag = ref(false)
const dragElementId = ref<string | null>(null)
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.position.type === 'absolute') {
onAbsoluteDragStart(e, el)
return
}
const l = props.layout[el.id]
if (!l) return
const s = ptToPx.value
dragElementId.value = el.id
didDrag.value = false
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
dragOffset.value = { x: e.clientX - rect.left, y: e.clientY - rect.top }
dragGhost.value = {
x: l.x * s,
y: l.y * s,
width: l.width * s,
height: l.height * s,
}
window.addEventListener('pointermove', onDragMove)
window.addEventListener('pointerup', onDragEnd)
}
function onDragMove(e: PointerEvent) {
if (!dragElementId.value) return
const overlayEl = document.querySelector('.interaction-overlay')
if (!overlayEl) return
const overlayRect = overlayEl.getBoundingClientRect()
const x = e.clientX - overlayRect.left - dragOffset.value.x
const y = e.clientY - overlayRect.top - dragOffset.value.y
const mouseX = e.clientX - overlayRect.left
const mouseY = e.clientY - overlayRect.top
if (!isDragging.value) {
const dx = Math.abs(x - dragGhost.value.x)
const dy = Math.abs(y - dragGhost.value.y)
if (dx < 4 && dy < 4) return
isDragging.value = true
didDrag.value = true
editorStore.setDragging(true)
}
dragGhost.value.x = x
dragGhost.value.y = y
updateDropFromMouse(mouseX, mouseY, dragElementId.value)
}
function onDragEnd() {
window.removeEventListener('pointermove', onDragMove)
window.removeEventListener('pointerup', onDragEnd)
if (isDragging.value && dragElementId.value && dropTargetContainerId.value !== null && dropLogicalIndex.value !== null) {
const currentParent = templateStore.getParent(dragElementId.value)
const targetContainerId = dropTargetContainerId.value
if (currentParent && currentParent.id === targetContainerId) {
// Aynı container içinde reorder
const currentIdx = currentParent.children.findIndex(c => c.id === dragElementId.value)
if (currentIdx !== -1 && currentIdx !== dropLogicalIndex.value) {
templateStore.reorderChild(currentParent.id, currentIdx, dropLogicalIndex.value)
}
} else {
// Farklı container'a taşı
templateStore.moveElement(dragElementId.value, targetContainerId, dropLogicalIndex.value)
}
}
isDragging.value = false
dragElementId.value = null
editorStore.setDragging(false)
clearDropTarget()
setTimeout(() => { didDrag.value = false }, 50)
}
// --- Absolute eleman drag ---
const absoluteDragId = ref<string | null>(null)
const absoluteDragStart = ref({ mouseX: 0, mouseY: 0, elX: 0, elY: 0 })
function onAbsoluteDragStart(e: PointerEvent, el: TemplateElement) {
if (el.position.type !== 'absolute') return
absoluteDragId.value = el.id
didDrag.value = false
absoluteDragStart.value = {
mouseX: e.clientX,
mouseY: e.clientY,
elX: el.position.x,
elY: el.position.y,
}
window.addEventListener('pointermove', onAbsoluteDragMove)
window.addEventListener('pointerup', onAbsoluteDragEnd)
}
function onAbsoluteDragMove(e: PointerEvent) {
if (!absoluteDragId.value) return
const dx = e.clientX - absoluteDragStart.value.mouseX
const dy = e.clientY - absoluteDragStart.value.mouseY
if (!isDragging.value) {
if (Math.abs(dx) < 4 && Math.abs(dy) < 4) return
isDragging.value = true
didDrag.value = true
editorStore.setDragging(true)
}
const pxToMm = 1 / props.scale
const newX = Math.max(0, absoluteDragStart.value.elX + dx * pxToMm)
const newY = Math.max(0, absoluteDragStart.value.elY + dy * pxToMm)
templateStore.updateElementPosition(absoluteDragId.value, {
type: 'absolute',
x: Math.round(newX * 10) / 10,
y: Math.round(newY * 10) / 10,
})
}
function onAbsoluteDragEnd() {
window.removeEventListener('pointermove', onAbsoluteDragMove)
window.removeEventListener('pointerup', onAbsoluteDragEnd)
isDragging.value = false
absoluteDragId.value = null
editorStore.setDragging(false)
setTimeout(() => { didDrag.value = false }, 50)
}
// --- Resize ---
const isResizing = ref(false)
const resizeElementId = ref<string | null>(null)
const resizeHandle = ref('')
const resizeStart = ref({ mouseX: 0, mouseY: 0, x: 0, y: 0, width: 0, height: 0 })
const resizeGhost = ref({ x: 0, y: 0, width: 0, height: 0 })
const resizeFinalMm = ref({ width: 0, height: 0 })
function onResizeStart(e: PointerEvent, elId: string, handle: string) {
e.stopPropagation()
e.preventDefault()
const l = props.layout[elId]
if (!l) return
resizeElementId.value = elId
resizeHandle.value = handle
isResizing.value = true
const s = ptToPx.value
const ptToMm = 1 / 2.8346
resizeStart.value = {
mouseX: e.clientX, mouseY: e.clientY,
x: l.x * s, y: l.y * s,
width: l.width * s, height: l.height * s,
}
resizeGhost.value = { x: l.x * s, y: l.y * s, width: l.width * s, height: l.height * s }
resizeFinalMm.value = { width: l.width * ptToMm, height: l.height * ptToMm }
window.addEventListener('pointermove', onResizeMove)
window.addEventListener('pointerup', onResizeEnd)
}
function onResizeMove(e: PointerEvent) {
if (!resizeElementId.value) return
const dx = e.clientX - resizeStart.value.mouseX
const dy = e.clientY - resizeStart.value.mouseY
const handle = resizeHandle.value
const pxToMm = 1 / props.scale
let gx = resizeStart.value.x, gy = resizeStart.value.y
let gw = resizeStart.value.width, gh = resizeStart.value.height
if (handle.includes('e')) gw = Math.max(20, resizeStart.value.width + dx)
if (handle.includes('w')) { gw = Math.max(20, resizeStart.value.width - dx); gx = resizeStart.value.x + dx }
if (handle.includes('s')) gh = Math.max(10, resizeStart.value.height + dy)
if (handle.includes('n')) { gh = Math.max(10, resizeStart.value.height - dy); gy = resizeStart.value.y + dy }
resizeGhost.value = { x: gx, y: gy, width: gw, height: gh }
const startWMm = resizeStart.value.width * pxToMm
const startHMm = resizeStart.value.height * pxToMm
let wMm = startWMm, hMm = startHMm
if (handle.includes('e')) wMm = Math.max(5, startWMm + dx * pxToMm)
if (handle.includes('w')) wMm = Math.max(5, startWMm - dx * pxToMm)
if (handle.includes('s')) hMm = Math.max(3, startHMm + dy * pxToMm)
if (handle.includes('n')) hMm = Math.max(3, startHMm - dy * pxToMm)
resizeFinalMm.value = { width: Math.round(wMm * 10) / 10, height: Math.round(hMm * 10) / 10 }
}
function onResizeEnd() {
window.removeEventListener('pointermove', onResizeMove)
window.removeEventListener('pointerup', onResizeEnd)
if (resizeElementId.value) {
const handle = resizeHandle.value
const sizeUpdate: { width?: SizeValue; height?: SizeValue } = {}
if (handle.includes('e') || handle.includes('w')) sizeUpdate.width = sz.fixed(resizeFinalMm.value.width)
if (handle.includes('s') || handle.includes('n')) sizeUpdate.height = sz.fixed(resizeFinalMm.value.height)
templateStore.updateElementSize(resizeElementId.value, sizeUpdate)
}
isResizing.value = false
resizeElementId.value = null
resizeHandle.value = ''
}
// ============================================================
// Toolbox sürükle-bırak (HTML5 drag API)
// ============================================================
function onToolboxDragOver(e: DragEvent) {
if (!editorStore.draggedNewElement) return
e.preventDefault()
const overlayEl = e.currentTarget as HTMLElement
const rect = overlayEl.getBoundingClientRect()
const mouseX = e.clientX - rect.left
const mouseY = e.clientY - rect.top
updateDropFromMouse(mouseX, mouseY)
}
function onToolboxDragLeave() {
clearDropTarget()
}
function onToolboxDrop(e: DragEvent) {
const newEl = editorStore.draggedNewElement
if (!newEl) return
const targetId = dropTargetContainerId.value ?? 'root'
const idx = dropLogicalIndex.value ?? undefined
templateStore.addChild(targetId, newEl, idx)
editorStore.selectElement(newEl.id)
editorStore.endDragNewElement()
clearDropTarget()
}
// Aktif sürükleme var mı (eleman veya toolbox)
const isAnyDragActive = computed(() =>
(isDragging.value && dragElementId.value !== null) || !!editorStore.draggedNewElement
)
</script>
<template>
<div
class="interaction-overlay"
:class="{ 'interaction-overlay--drop-active': isAnyDragActive }"
@pointerdown.self="onCanvasClick"
@dragover.prevent="onToolboxDragOver"
@dragleave="onToolboxDragLeave"
@drop.prevent="onToolboxDrop"
>
<!-- Element handles -->
<div
v-for="el in flatElements"
:key="el.id"
class="element-handle"
:class="{
'element-handle--selected': editorStore.selectedElementId === el.id,
'element-handle--container': isContainer(el),
'element-handle--dragging': isDragging && dragElementId === el.id,
'element-handle--drop-target': isContainer(el) && dropTargetContainerId === el.id && isAnyDragActive,
}"
:style="getElementStyle(el)"
@pointerdown="(e: PointerEvent) => { onElementClick(e, el.id); onDragStart(e, el) }"
>
<!-- Selection border -->
<div v-if="editorStore.selectedElementId === el.id" class="selection-border" />
<!-- Resize handles -->
<template v-if="editorStore.selectedElementId === el.id && !isResizing">
<div class="resize-handle resize-handle--se" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'se')" />
<div class="resize-handle resize-handle--sw" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'sw')" />
<div class="resize-handle resize-handle--ne" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'ne')" />
<div class="resize-handle resize-handle--nw" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'nw')" />
</template>
</div>
<!-- Drag ghost (mevcut eleman sürükleme) -->
<div
v-if="isDragging && dragElementId"
class="drag-ghost"
:style="{
left: `${dragGhost.x}px`,
top: `${dragGhost.y}px`,
width: `${dragGhost.width}px`,
height: `${dragGhost.height}px`,
}"
/>
<!-- Resize ghost -->
<div
v-if="isResizing && resizeElementId"
class="resize-ghost"
:style="{
left: `${resizeGhost.x}px`,
top: `${resizeGhost.y}px`,
width: `${resizeGhost.width}px`,
height: `${resizeGhost.height}px`,
}"
/>
<!-- Drop indicator (ortak hem eleman hem toolbox sürükleme) -->
<div v-if="isAnyDragActive" :style="dropIndicatorStyle" />
</div>
</template>
<style scoped>
.interaction-overlay {
position: absolute;
inset: 0;
}
.element-handle {
box-sizing: border-box;
cursor: pointer;
}
.element-handle--dragging {
opacity: 0.3;
}
/* Selection border */
.selection-border {
position: absolute;
inset: -1px;
border: 1.5px solid rgb(59, 130, 246);
pointer-events: none;
display: block;
}
.element-handle--container > .selection-border {
border-color: rgb(139, 92, 246);
border-style: dashed;
}
/* Container'ları hafif kenarlıkla göster (root hariç — root overlay'de flatElements'te yok) */
.element-handle--container {
outline: 1px dashed rgba(139, 92, 246, 0.25);
outline-offset: -1px;
}
/* Hover efekti */
.element-handle:not(.element-handle--selected):hover::after {
content: '';
position: absolute;
inset: -1px;
border: 1.5px solid rgba(59, 130, 246, 0.4);
pointer-events: none;
}
/* Resize handles */
.resize-handle {
position: absolute;
width: 6px;
height: 6px;
background: white;
border: 1.5px solid rgb(59, 130, 246);
border-radius: 1px;
z-index: 10;
}
.resize-handle--se { right: -3px; bottom: -3px; cursor: se-resize; }
.resize-handle--sw { left: -3px; bottom: -3px; cursor: sw-resize; }
.resize-handle--ne { right: -3px; top: -3px; cursor: ne-resize; }
.resize-handle--nw { left: -3px; top: -3px; cursor: nw-resize; }
/* Drag ghost */
.drag-ghost {
position: absolute;
background: rgba(59, 130, 246, 0.1);
border: 1.5px dashed rgb(59, 130, 246);
border-radius: 2px;
pointer-events: none;
z-index: 999;
}
/* Resize ghost */
.resize-ghost {
position: absolute;
border: 1.5px solid rgb(59, 130, 246);
background: rgba(59, 130, 246, 0.05);
pointer-events: none;
z-index: 999;
}
/* Sürükleme aktifken container'ları göster */
.interaction-overlay--drop-active .element-handle--container::after {
content: '';
position: absolute;
inset: 0;
border: 1.5px dashed rgba(139, 92, 246, 0.5);
border-radius: 2px;
pointer-events: none;
}
/* Drop hedef container highlight */
.element-handle--drop-target::after {
content: '';
position: absolute;
inset: -2px;
border: 2px solid rgb(139, 92, 246) !important;
background: rgba(139, 92, 246, 0.08);
border-radius: 3px;
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
defineProps<{
svg: string | null
}>()
</script>
<template>
<div class="typst-svg-layer" v-if="svg" v-html="svg" />
<div class="typst-svg-layer typst-svg-layer--empty" v-else>
<span>Derleniyor...</span>
</div>
</template>
<style scoped>
.typst-svg-layer {
position: absolute;
inset: 0;
pointer-events: none;
user-select: none;
}
.typst-svg-layer :deep(svg) {
width: 100%;
height: 100%;
display: block;
}
.typst-svg-layer--empty {
display: flex;
align-items: center;
justify-content: center;
color: #999;
font-size: 14px;
}
</style>

View File

@@ -0,0 +1,446 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useTemplateStore } from '../../stores/template'
import { useEditorStore } from '../../stores/editor'
import { isContainer } from '../../core/types'
import type {
TemplateElement,
ContainerElement,
StaticTextElement,
LineElement,
TextStyle,
SizeValue,
} from '../../core/types'
const templateStore = useTemplateStore()
const editorStore = useEditorStore()
const selectedElement = computed(() => {
const id = editorStore.selectedElementId
if (!id) return null
return templateStore.getElementById(id) ?? null
})
const parentElement = computed(() => {
const id = editorStore.selectedElementId
if (!id) return null
return templateStore.getParent(id) ?? null
})
// --- Generic updater ---
function update(updates: Partial<TemplateElement>) {
const id = editorStore.selectedElementId
if (!id) return
templateStore.updateElement(id, updates)
}
function updateStyle(key: string, value: unknown) {
const el = selectedElement.value
if (!el) return
update({ style: { ...el.style, [key]: value } } as Partial<TemplateElement>)
}
function updateSize(axis: 'width' | 'height', sv: SizeValue) {
const id = editorStore.selectedElementId
if (!id) return
templateStore.updateElementSize(id, { [axis]: sv })
}
// --- Positioning ---
function togglePositioning() {
const el = selectedElement.value
if (!el) return
if (el.position.type === 'flow') {
templateStore.updateElementPosition(el.id, { type: 'absolute', x: 0, y: 0 })
} else {
templateStore.updateElementPosition(el.id, { type: 'flow' })
}
}
// --- Delete ---
function deleteElement() {
const id = editorStore.selectedElementId
if (!id || id === 'root') return
editorStore.clearSelection()
templateStore.removeElement(id)
}
</script>
<template>
<div class="properties-panel">
<div v-if="!selectedElement" class="properties-panel__empty">
Bir eleman seçin
</div>
<template v-else>
<!-- Header -->
<div class="prop-section">
<div class="prop-section__title">
{{ selectedElement.type === 'container' ? 'Container' : selectedElement.type === 'static_text' ? 'Metin' : selectedElement.type === 'line' ? 'Çizgi' : 'Eleman' }}
<span class="prop-id">{{ selectedElement.id }}</span>
</div>
</div>
<!-- Positioning -->
<div class="prop-section">
<div class="prop-section__title">Pozisyon</div>
<div class="prop-row">
<label class="prop-label">Mod</label>
<select class="prop-input prop-select" :value="selectedElement.position.type" @change="togglePositioning">
<option value="flow">Flow</option>
<option value="absolute">Absolute</option>
</select>
</div>
<template v-if="selectedElement.position.type === 'absolute'">
<div class="prop-row">
<label class="prop-label">X (mm)</label>
<input class="prop-input" type="number" step="0.5"
:value="selectedElement.position.x"
@input="(e) => templateStore.updateElementPosition(selectedElement!.id, { type: 'absolute', x: parseFloat((e.target as HTMLInputElement).value) || 0, y: (selectedElement!.position as any).y ?? 0 })" />
</div>
<div class="prop-row">
<label class="prop-label">Y (mm)</label>
<input class="prop-input" type="number" step="0.5"
:value="selectedElement.position.y"
@input="(e) => templateStore.updateElementPosition(selectedElement!.id, { type: 'absolute', x: (selectedElement!.position as any).x ?? 0, y: parseFloat((e.target as HTMLInputElement).value) || 0 })" />
</div>
</template>
</div>
<!-- Size -->
<div class="prop-section">
<div class="prop-section__title">Boyut</div>
<div class="prop-row">
<label class="prop-label">Genişlik</label>
<select class="prop-input prop-select"
:value="selectedElement.size.width.type"
@change="(e) => {
const t = (e.target as HTMLSelectElement).value
if (t === 'auto') updateSize('width', { type: 'auto' })
else if (t === 'fr') updateSize('width', { type: 'fr', value: 1 })
else updateSize('width', { type: 'fixed', value: 50 })
}">
<option value="auto">Otomatik</option>
<option value="fixed">Sabit (mm)</option>
<option value="fr">Oran (fr)</option>
</select>
</div>
<div v-if="selectedElement.size.width.type === 'fixed'" class="prop-row">
<label class="prop-label">mm</label>
<input class="prop-input" type="number" step="1" min="1"
:value="(selectedElement.size.width as any).value"
@input="(e) => updateSize('width', { type: 'fixed', value: parseFloat((e.target as HTMLInputElement).value) || 10 })" />
</div>
<div v-if="selectedElement.size.width.type === 'fr'" class="prop-row">
<label class="prop-label">fr</label>
<input class="prop-input" type="number" step="1" min="1"
:value="(selectedElement.size.width as any).value"
@input="(e) => updateSize('width', { type: 'fr', value: parseFloat((e.target as HTMLInputElement).value) || 1 })" />
</div>
<div class="prop-row">
<label class="prop-label">Yükseklik</label>
<select class="prop-input prop-select"
:value="selectedElement.size.height.type"
@change="(e) => {
const t = (e.target as HTMLSelectElement).value
if (t === 'auto') updateSize('height', { type: 'auto' })
else if (t === 'fr') updateSize('height', { type: 'fr', value: 1 })
else updateSize('height', { type: 'fixed', value: 20 })
}">
<option value="auto">Otomatik</option>
<option value="fixed">Sabit (mm)</option>
<option value="fr">Oran (fr)</option>
</select>
</div>
<div v-if="selectedElement.size.height.type === 'fixed'" class="prop-row">
<label class="prop-label">mm</label>
<input class="prop-input" type="number" step="1" min="1"
:value="(selectedElement.size.height as any).value"
@input="(e) => updateSize('height', { type: 'fixed', value: parseFloat((e.target as HTMLInputElement).value) || 10 })" />
</div>
</div>
<!-- Text style (static_text, text) -->
<div v-if="selectedElement.type === 'static_text' || selectedElement.type === 'text'" class="prop-section">
<div class="prop-section__title">Metin Stili</div>
<div v-if="selectedElement.type === 'static_text'" class="prop-row">
<label class="prop-label">Metin</label>
<input class="prop-input" type="text"
:value="(selectedElement as StaticTextElement).content"
@input="(e) => update({ content: (e.target as HTMLInputElement).value } as any)" />
</div>
<div class="prop-row">
<label class="prop-label">Boyut (pt)</label>
<input class="prop-input" type="number" step="1" min="1"
:value="(selectedElement.style as TextStyle).fontSize ?? 11"
@input="(e) => updateStyle('fontSize', parseFloat((e.target as HTMLInputElement).value) || 11)" />
</div>
<div class="prop-row">
<label class="prop-label">Kalınlık</label>
<select class="prop-input prop-select"
:value="(selectedElement.style as TextStyle).fontWeight ?? 'normal'"
@change="(e) => updateStyle('fontWeight', (e.target as HTMLSelectElement).value)">
<option value="normal">Normal</option>
<option value="bold">Kalın</option>
</select>
</div>
<div class="prop-row">
<label class="prop-label">Renk</label>
<input class="prop-input prop-color" type="color"
:value="(selectedElement.style as TextStyle).color ?? '#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="(selectedElement.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>
<!-- Line style -->
<div v-if="selectedElement.type === 'line'" class="prop-section">
<div class="prop-section__title">Çizgi Stili</div>
<div class="prop-row">
<label class="prop-label">Kalınlık (pt)</label>
<input class="prop-input" type="number" step="0.25" min="0.25"
:value="(selectedElement as LineElement).style.strokeWidth ?? 0.5"
@input="(e) => updateStyle('strokeWidth', parseFloat((e.target as HTMLInputElement).value) || 0.5)" />
</div>
<div class="prop-row">
<label class="prop-label">Renk</label>
<input class="prop-input prop-color" type="color"
:value="(selectedElement as LineElement).style.strokeColor ?? '#000000'"
@input="(e) => updateStyle('strokeColor', (e.target as HTMLInputElement).value)" />
</div>
</div>
<!-- Container properties -->
<div v-if="isContainer(selectedElement)" class="prop-section">
<div class="prop-section__title">Container Ayarları</div>
<div class="prop-row">
<label class="prop-label">Yön</label>
<select class="prop-input prop-select"
:value="(selectedElement as ContainerElement).direction"
@change="(e) => update({ direction: (e.target as HTMLSelectElement).value } as any)">
<option value="column">Dikey</option>
<option value="row">Yatay</option>
</select>
</div>
<div class="prop-row">
<label class="prop-label">Boşluk (mm)</label>
<input class="prop-input" type="number" step="1" min="0"
:value="(selectedElement as ContainerElement).gap"
@input="(e) => update({ gap: parseFloat((e.target as HTMLInputElement).value) || 0 } as any)" />
</div>
<div class="prop-row">
<label class="prop-label">Hizalama</label>
<select class="prop-input prop-select"
:value="(selectedElement as ContainerElement).align"
@change="(e) => update({ align: (e.target as HTMLSelectElement).value } as any)">
<option value="start">Baş</option>
<option value="center">Orta</option>
<option value="end">Son</option>
<option value="stretch">Esnet</option>
</select>
</div>
<!-- Padding -->
<div class="prop-section__subtitle">Padding (mm)</div>
<div class="prop-row-grid">
<div class="prop-row">
<label class="prop-label">Üst</label>
<input class="prop-input" type="number" step="1" min="0"
:value="(selectedElement as ContainerElement).padding.top"
@input="(e) => update({ padding: { ...(selectedElement as ContainerElement).padding, top: parseFloat((e.target as HTMLInputElement).value) || 0 } } as any)" />
</div>
<div class="prop-row">
<label class="prop-label">Sag</label>
<input class="prop-input" type="number" step="1" min="0"
:value="(selectedElement as ContainerElement).padding.right"
@input="(e) => update({ padding: { ...(selectedElement as ContainerElement).padding, right: parseFloat((e.target as HTMLInputElement).value) || 0 } } as any)" />
</div>
<div class="prop-row">
<label class="prop-label">Alt</label>
<input class="prop-input" type="number" step="1" min="0"
:value="(selectedElement as ContainerElement).padding.bottom"
@input="(e) => update({ padding: { ...(selectedElement as ContainerElement).padding, bottom: parseFloat((e.target as HTMLInputElement).value) || 0 } } as any)" />
</div>
<div class="prop-row">
<label class="prop-label">Sol</label>
<input class="prop-input" type="number" step="1" min="0"
:value="(selectedElement as ContainerElement).padding.left"
@input="(e) => update({ padding: { ...(selectedElement as ContainerElement).padding, left: parseFloat((e.target as HTMLInputElement).value) || 0 } } as any)" />
</div>
</div>
<!-- Container Style -->
<div class="prop-section__subtitle">Stil</div>
<div class="prop-row">
<label class="prop-label">Arka plan</label>
<div class="prop-row-inline">
<input class="prop-input prop-color" type="color"
:value="(selectedElement as ContainerElement).style.backgroundColor ?? '#ffffff'"
@input="(e) => updateStyle('backgroundColor', (e.target as HTMLInputElement).value)" />
<button v-if="(selectedElement as ContainerElement).style.backgroundColor" class="prop-clear" @click="updateStyle('backgroundColor', undefined)">x</button>
</div>
</div>
<div class="prop-row">
<label class="prop-label">Kenarlık rengi</label>
<div class="prop-row-inline">
<input class="prop-input prop-color" type="color"
:value="(selectedElement as ContainerElement).style.borderColor ?? '#000000'"
@input="(e) => updateStyle('borderColor', (e.target as HTMLInputElement).value)" />
</div>
</div>
<div class="prop-row">
<label class="prop-label">Kenarlık (pt)</label>
<input class="prop-input" type="number" step="0.5" min="0"
:value="(selectedElement as ContainerElement).style.borderWidth ?? 0"
@input="(e) => updateStyle('borderWidth', parseFloat((e.target as HTMLInputElement).value) || 0)" />
</div>
<div class="prop-row">
<label class="prop-label">Radius (pt)</label>
<input class="prop-input" type="number" step="1" min="0"
:value="(selectedElement as ContainerElement).style.borderRadius ?? 0"
@input="(e) => updateStyle('borderRadius', parseFloat((e.target as HTMLInputElement).value) || 0)" />
</div>
</div>
<!-- Delete -->
<div v-if="selectedElement.id !== 'root'" class="prop-section">
<button class="prop-delete-btn" @click="deleteElement">Sil</button>
</div>
</template>
</div>
</template>
<style scoped>
.properties-panel {
padding: 12px;
}
.properties-panel__empty {
color: #94a3b8;
font-size: 13px;
text-align: center;
margin-top: 40px;
}
.prop-section {
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #f1f5f9;
}
.prop-section__title {
font-size: 11px;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.prop-section__subtitle {
font-size: 11px;
font-weight: 500;
color: #94a3b8;
margin: 8px 0 4px;
}
.prop-id {
font-weight: 400;
color: #94a3b8;
font-size: 10px;
margin-left: 6px;
}
.prop-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.prop-row-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
}
.prop-row-inline {
display: flex;
align-items: center;
gap: 4px;
}
.prop-label {
font-size: 12px;
color: #475569;
flex-shrink: 0;
min-width: 70px;
}
.prop-input {
width: 100px;
padding: 4px 6px;
border: 1px solid #e2e8f0;
border-radius: 4px;
font-size: 12px;
background: white;
color: #334155;
}
.prop-input:focus {
outline: none;
border-color: #93c5fd;
}
.prop-select {
cursor: pointer;
}
.prop-color {
width: 32px;
height: 24px;
padding: 1px;
cursor: pointer;
}
.prop-clear {
background: none;
border: 1px solid #e2e8f0;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
color: #94a3b8;
padding: 2px 5px;
}
.prop-delete-btn {
width: 100%;
padding: 6px;
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
}
.prop-delete-btn:hover {
background: #fee2e2;
}
</style>

View File

@@ -0,0 +1,156 @@
<script setup lang="ts">
import { useEditorStore } from '../../stores/editor'
import type { TemplateElement } from '../../core/types'
import { sz } from '../../core/types'
const editorStore = useEditorStore()
let idCounter = Date.now()
function nextId(prefix: string) {
return `${prefix}_${(++idCounter).toString(36)}`
}
interface ToolItem {
label: string
icon: string
create: () => TemplateElement
}
const tools: ToolItem[] = [
{
label: 'Metin',
icon: 'T',
create: () => ({
id: nextId('txt'),
type: 'static_text',
position: { type: 'flow' },
size: { width: sz.auto(), height: sz.auto() },
style: { fontSize: 11, color: '#000000' },
content: 'Yeni metin',
}),
},
{
label: 'Container',
icon: '▢',
create: () => ({
id: nextId('cnt'),
type: 'container',
position: { type: 'flow' },
size: { width: sz.fr(1), height: sz.auto() },
direction: 'column' as const,
gap: 3,
padding: { top: 5, right: 5, bottom: 5, left: 5 },
align: 'stretch' as const,
justify: 'start' as const,
style: {},
children: [],
}),
},
{
label: 'Cizgi',
icon: '—',
create: () => ({
id: nextId('ln'),
type: 'line',
position: { type: 'flow' },
size: { width: sz.fr(1), height: sz.auto() },
style: { strokeColor: '#000000', strokeWidth: 0.5 },
}),
},
]
function onDragStart(e: DragEvent, tool: ToolItem) {
const el = tool.create()
editorStore.startDragNewElement(el)
// Drag data (fallback)
e.dataTransfer?.setData('text/plain', el.id)
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'copy'
}
}
function onDragEnd() {
editorStore.endDragNewElement()
}
</script>
<template>
<div class="toolbox-panel">
<div class="toolbox-panel__title">Arac Kutusu</div>
<div class="toolbox-panel__grid">
<div
v-for="tool in tools"
:key="tool.label"
class="toolbox-item"
draggable="true"
@dragstart="(e) => onDragStart(e, tool)"
@dragend="onDragEnd"
>
<span class="toolbox-item__icon">{{ tool.icon }}</span>
<span class="toolbox-item__label">{{ tool.label }}</span>
</div>
</div>
</div>
</template>
<style scoped>
.toolbox-panel {
padding: 12px;
}
.toolbox-panel__title {
font-size: 11px;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 10px;
}
.toolbox-panel__grid {
display: flex;
flex-direction: column;
gap: 4px;
}
.toolbox-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 6px;
cursor: grab;
font-size: 13px;
color: #334155;
transition: all 0.15s;
user-select: none;
}
.toolbox-item:hover {
background: #eff6ff;
border-color: #bfdbfe;
}
.toolbox-item:active {
cursor: grabbing;
}
.toolbox-item__icon {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: #f1f5f9;
border-radius: 4px;
font-size: 14px;
font-weight: 600;
color: #475569;
}
.toolbox-item__label {
font-size: 13px;
}
</style>

View File

@@ -0,0 +1,80 @@
import { ref, watch, type Ref } from 'vue'
import type { ElementLayout } from '../core/template-to-typst'
export function useTypstCompiler(markup: Ref<string>) {
const svg = ref<string | null>(null)
const error = ref<string | null>(null)
const compiling = ref(false)
const layout = ref<Record<string, ElementLayout>>({})
let worker: Worker | null = null
let requestId = 0
let debounceTimer: ReturnType<typeof setTimeout> | 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<string, ElementLayout>
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,
}
}

View File

@@ -0,0 +1,60 @@
import { ref, watch, type Ref } from 'vue'
export function useUndoRedo<T>(source: Ref<T>, maxHistory = 50) {
const undoStack = ref<string[]>([]) as Ref<string[]>
const redoStack = ref<string[]>([]) as Ref<string[]>
let skipWatch = false
let debounceTimer: ReturnType<typeof setTimeout> | 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 }
}

View File

@@ -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, unknown>): 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<string, ContainerElement> {
const map = new Map<string, ContainerElement>()
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<string, ContainerElement>,
): Map<string, number> {
const map = new Map<string, number>()
// 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, '\\<')
.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<string, unknown>)
.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<string, ElementLayout> {
const result: Record<string, ElementLayout> = {}
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
}

176
frontend/src/core/types.ts Normal file
View File

@@ -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
}

8
frontend/src/main.ts Normal file
View File

@@ -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')

View File

@@ -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<string | null>(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<TemplateElement | null>(null)
const dropTargetContainerId = ref<string | null>(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,
}
})

View File

@@ -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<Template>(createDefaultTemplate())
const typstMarkup = computed(() => templateToTypst(template.value))
// Undo / Redo
const { undo, redo, canUndo, canRedo } = useUndoRedo(template)
// --- Element CRUD ---
function getElementById(id: string): TemplateElement | undefined {
return findElementById(template.value.root, id)
}
function getParent(id: string): ContainerElement | undefined {
return findParent(template.value.root, id)
}
/** Bir container'a çocuk ekle */
function addChild(parentId: string, element: TemplateElement, index?: number) {
const parent = getElementById(parentId)
if (!parent || !isContainer(parent)) return
if (index !== undefined) {
parent.children.splice(index, 0, element)
} else {
parent.children.push(element)
}
}
/** Element'i ağaçtan kaldır */
function removeElement(elementId: string) {
const parent = getParent(elementId)
if (!parent) return
const idx = parent.children.findIndex(c => c.id === elementId)
if (idx !== -1) parent.children.splice(idx, 1)
}
/** Element'i başka bir container'a taşı */
function moveElement(elementId: string, targetParentId: string, index?: number) {
const el = getElementById(elementId)
if (!el) return
removeElement(elementId)
addChild(targetParentId, el, index)
}
/** Absolute pozisyon güncelle */
function updateElementPosition(elementId: string, position: PositionMode) {
const el = getElementById(elementId)
if (el) el.position = position
}
/** Boyut güncelle */
function updateElementSize(elementId: string, size: Partial<SizeConstraint>) {
const el = getElementById(elementId)
if (el) {
el.size = { ...el.size, ...size }
}
}
/** Herhangi bir element özelliğini güncelle */
function updateElement(elementId: string, updates: Partial<TemplateElement>) {
const el = getElementById(elementId)
if (el) Object.assign(el, updates)
}
/** Çocuk sırasını değiştir (aynı parent içinde) */
function reorderChild(parentId: string, fromIndex: number, toIndex: number) {
const parent = getElementById(parentId)
if (!parent || !isContainer(parent)) return
const [moved] = parent.children.splice(fromIndex, 1)
parent.children.splice(toIndex, 0, moved)
}
return {
template,
typstMarkup,
getElementById,
getParent,
addChild,
removeElement,
moveElement,
updateElementPosition,
updateElementSize,
updateElement,
reorderChild,
undo,
redo,
canUndo,
canRedo,
}
})

View File

@@ -0,0 +1,19 @@
*,
*::before,
*::after {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
color: #1e293b;
background: #f1f5f9;
-webkit-font-smoothing: antialiased;
}
#app {
height: 100vh;
}

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,82 @@
/// Typst WASM Web Worker
/// Ana thread'i bloklamadan Typst markup → SVG derleme yapar.
import { $typst, TypstSnippet } from '@myriaddreamin/typst.ts/dist/esm/contrib/snippet.mjs'
let initialized = false
const FONT_FILES = [
'/fonts/NotoSans-Regular.ttf',
'/fonts/NotoSans-Bold.ttf',
'/fonts/NotoSans-Italic.ttf',
'/fonts/NotoSans-BoldItalic.ttf',
'/fonts/NotoSansMono-Regular.ttf',
]
async function ensureInit() {
if (initialized) return
console.log('[typst-worker] Başlatılıyor...')
try {
// Fontları URL olarak preload et (init öncesinde)
const fontUrls = FONT_FILES.map(f => new URL(f, self.location.origin).href)
$typst.use(TypstSnippet.preloadFonts(fontUrls))
await $typst.setCompilerInitOptions({
getModule: () =>
fetch('/wasm/typst_ts_web_compiler_bg.wasm').then(r => {
console.log('[typst-worker] Compiler WASM yüklendi:', r.status)
return r.arrayBuffer()
}),
})
await $typst.setRendererInitOptions({
getModule: () =>
fetch('/wasm/typst_ts_renderer_bg.wasm').then(r => {
console.log('[typst-worker] Renderer WASM yüklendi:', r.status)
return r.arrayBuffer()
}),
})
initialized = true
console.log('[typst-worker] Başlatma tamamlandı')
} catch (initErr) {
console.error('[typst-worker] Başlatma hatası:', initErr)
throw initErr
}
}
self.onmessage = async (e: MessageEvent<{ type: string; markup: string; id: number }>) => {
const { type, markup, id } = e.data
if (type === 'compile') {
console.log(`[typst-worker] Derleme başladı (id: ${id})`)
try {
await ensureInit()
const svg = await $typst.svg({ mainContent: markup })
// SVG'den layout bilgisini parse et
const layout: Record<string, { x: number; y: number; width: number; height: number }> = {}
const matches = svg.matchAll(/([a-zA-Z0-9_-]+):([\d.]+)pt,([\d.]+)pt,([\d.]+)pt,([\d.]+)pt\|/g)
for (const m of matches) {
layout[m[1]] = {
x: parseFloat(m[2]),
y: parseFloat(m[3]),
width: parseFloat(m[4]),
height: parseFloat(m[5]),
}
}
console.log(`[typst-worker] Derleme başarılı (id: ${id}, elements: ${Object.keys(layout).length})`)
self.postMessage({ type: 'result', svg, layout, id })
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err)
console.error(`[typst-worker] Derleme hatası (id: ${id}):`, err)
self.postMessage({
type: 'error',
error: errorMsg,
id,
})
}
}
}