mirror of
https://github.com/duhanbalci/dreport.git
synced 2026-07-01 18:39:16 +00:00
improvements
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
target/
|
||||||
804
CLAUDE.md
Normal file
804
CLAUDE.md
Normal file
@@ -0,0 +1,804 @@
|
|||||||
|
# CLAUDE.md — dreport
|
||||||
|
|
||||||
|
## Proje Özeti
|
||||||
|
|
||||||
|
**dreport**, kullanıcıların fatura, irsaliye, rapor gibi belge şablonlarını görsel bir drag & drop editör ile tasarlayıp, JSON veri ile birleştirerek PDF çıktı almasını sağlayan bir belge tasarım aracıdır.
|
||||||
|
|
||||||
|
Temel fark: Editördeki önizleme doğrudan Typst render çıktısıdır. Canvas üzerinde ayrı bir render engine çalışmaz — kullanıcı her zaman gerçek Typst çıktısını görür. Bu sayede "editörde gördüğüm ile PDF'te aldığım farklı" sorunu ortadan kalkar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Teknoloji Kararları
|
||||||
|
|
||||||
|
| Katman | Teknoloji | Gerekçe |
|
||||||
|
| ----------------- | ------------------------------------ | --------------------------------------------------------- |
|
||||||
|
| Frontend | Vue 3 (Composition API) + TypeScript | Kullanıcı tercihi |
|
||||||
|
| Editör Render | Typst WASM → SVG | Editör çıktısı = PDF çıktısı tutarlılığı |
|
||||||
|
| Typst WASM | `@myriaddreamin/typst.ts` | Tarayıcıda Typst derleme; SVG çıktı üretimi |
|
||||||
|
| Etkileşim Katmanı | SVG overlay (Vue bileşenleri) | Typst SVG üzerine seçim, sürükleme, yeniden boyutlandırma |
|
||||||
|
| Backend | Rust + Axum | Typst crate'lerini doğrudan kullanabilme; performans |
|
||||||
|
| PDF Render | `typst` Rust crate (server-side) | Nihai PDF üretimi sunucuda; font tutarlılığı garantisi |
|
||||||
|
| Veri Formatı | JSON (şablon tanımı + veri) | Evrensel, kolay serialize/deserialize |
|
||||||
|
| Paket Yönetimi | bun (frontend), cargo (backend) | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mimari Genel Bakış
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ VUE FRONTEND │
|
||||||
|
│ │
|
||||||
|
│ ┌───────────────┐ ┌──────────────────────────┐ │
|
||||||
|
│ │ Sol Panel │ │ Editör Canvas │ │
|
||||||
|
│ │ - Bileşenler │ │ │ │
|
||||||
|
│ │ - Schema Tree │ │ ┌────────────────────┐ │ │
|
||||||
|
│ │ - Özellikler │ │ │ Typst WASM → SVG │ │ │
|
||||||
|
│ │ │ │ │ (gerçek render) │ │ │
|
||||||
|
│ │ │ │ └────────────────────┘ │ │
|
||||||
|
│ │ │ │ ┌────────────────────┐ │ │
|
||||||
|
│ │ │ │ │ SVG Overlay (Vue) │ │ │
|
||||||
|
│ │ │ │ │ seçim / drag / resize│ │ │
|
||||||
|
│ │ │ │ └────────────────────┘ │ │
|
||||||
|
│ └───────────────┘ └──────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Template JSON ←→ Typst Markup ←→ SVG Render │
|
||||||
|
└──────────────────────────┬──────────────────────────┘
|
||||||
|
│ POST /api/render
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ RUST BACKEND (Axum) │
|
||||||
|
│ │
|
||||||
|
│ Template JSON + Data JSON → Typst Markup → PDF │
|
||||||
|
│ (typst crate ile doğrudan derleme) │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Editör Render Stratejisi: Typst WASM Full Render
|
||||||
|
|
||||||
|
### Temel Prensip
|
||||||
|
|
||||||
|
Editörde ayrı bir canvas render engine (fabric.js, konva.js vb.) KULLANILMAZ. Bunun yerine:
|
||||||
|
|
||||||
|
1. Kullanıcının tasarımı bir **Template JSON** olarak tutulur.
|
||||||
|
2. Template JSON'dan **Typst markup** üretilir (frontend'de, saf fonksiyon).
|
||||||
|
3. Typst markup, **typst.ts WASM** modülü ile tarayıcıda derlenir → **SVG** çıktı üretilir.
|
||||||
|
4. SVG, editör alanında gösterilir.
|
||||||
|
5. SVG üzerine **Vue bileşenleriyle bir interaction overlay** yerleştirilir (seçim kutuları, drag handle'lar, resize köşeleri).
|
||||||
|
6. Kullanıcı bir elemanı sürüklediğinde → Template JSON güncellenir → Typst yeniden derlenir → SVG güncellenir.
|
||||||
|
|
||||||
|
### Performans Stratejisi
|
||||||
|
|
||||||
|
Typst incremental compilation destekler, ancak her fare hareketi için full render döngüsü ağır olabilir. Bu yüzden:
|
||||||
|
|
||||||
|
- **Drag sırasında:** Overlay katmanında sadece CSS transform ile görsel geri bildirim ver (hafif, anlık). Typst derleme YAPMA.
|
||||||
|
- **Drag bittiğinde (mouseup/pointerup):** Template JSON'ı güncelle, Typst derle, SVG'yi yenile.
|
||||||
|
- **Debounce:** Özellik panelinden yapılan değişikliklerde (font boyutu, renk vs.) 150-300ms debounce ile derle.
|
||||||
|
- **Web Worker:** Typst WASM derlemeyi bir Web Worker içinde çalıştır. Ana thread'i ASLA bloklamayacak.
|
||||||
|
|
||||||
|
### typst.ts Entegrasyonu
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Temel kullanım şeması
|
||||||
|
import { $typst } from "@myriaddreamin/typst.ts/dist/esm/contrib/snippet.mjs";
|
||||||
|
|
||||||
|
// Worker içinde:
|
||||||
|
async function compile(typstMarkup: string): Promise<string> {
|
||||||
|
const svg = await $typst.svg({ mainContent: typstMarkup });
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- WASM modülleri: `typst-ts-web-compiler` (~7.6 MB) + `typst-ts-renderer` (~350 KB)
|
||||||
|
- Fontlar: Projeye gömülü font seti gerekecek (~4.4 MB). Başlangıçta Noto Sans / Inter gibi bir set yeterli.
|
||||||
|
- İlk yükleme ağır olabilir — lazy loading ve cache stratejisi gerekli (Service Worker ile WASM cache'leme).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Veri Modeli
|
||||||
|
|
||||||
|
### Layout Sistemi: Container-Based
|
||||||
|
|
||||||
|
Eski model (her eleman absolute `place()`) yerine, CSS Flexbox mantığına benzeyen container-based layout kullanılır:
|
||||||
|
|
||||||
|
- **Sayfa = kök container.** `page.margins` → kök container'ın `padding`'i olur.
|
||||||
|
- **Container'lar iç içe geçebilir.** `direction: "row" | "column"` ile yatay/dikey dizilim.
|
||||||
|
- **Elemanlar varsayılan olarak flow içindedir** — otomatik pozisyonlanır.
|
||||||
|
- **Opsiyonel absolute positioning:** Kullanıcı isterse bir elemanı `position: "absolute"` yapabilir. Bu durumda eleman parent container içinde absolute konumlanır (`place()` ile).
|
||||||
|
|
||||||
|
Bu sayede:
|
||||||
|
- Tablo satırları artarsa alttaki elemanlar otomatik kayar.
|
||||||
|
- Aynı satıra iki kolon koymak için iç içe container yeterlidir.
|
||||||
|
- Absolute mod ile serbest pozisyonlama da mümkündür.
|
||||||
|
|
||||||
|
### Boyut Sistemi (SizeValue)
|
||||||
|
|
||||||
|
Her eleman ve container için `width` ve `height` şu tiplerden biri olabilir:
|
||||||
|
|
||||||
|
| Tip | Açıklama | Typst karşılığı |
|
||||||
|
| ------- | ------------------------------------- | --------------- |
|
||||||
|
| `fixed` | Sabit boyut (mm) | `80mm` |
|
||||||
|
| `auto` | İçeriğe göre otomatik | `auto` |
|
||||||
|
| `fr` | Kalan alanı oransal doldur | `1fr`, `2fr` |
|
||||||
|
|
||||||
|
Ek olarak `minWidth`, `maxWidth`, `minHeight`, `maxHeight` (mm) desteklenir.
|
||||||
|
|
||||||
|
### Template JSON (Şablon Tanımı)
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"id": "tpl_fatura_001",
|
||||||
|
"name": "Standart Fatura",
|
||||||
|
"page": { "width": 210, "height": 297 },
|
||||||
|
"fonts": ["Noto Sans", "Noto Sans Mono"],
|
||||||
|
"root": {
|
||||||
|
"id": "root",
|
||||||
|
"type": "container",
|
||||||
|
"position": { "type": "flow" },
|
||||||
|
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
|
||||||
|
"direction": "column",
|
||||||
|
"gap": 5,
|
||||||
|
"padding": { "top": 15, "right": 15, "bottom": 15, "left": 15 },
|
||||||
|
"align": "stretch",
|
||||||
|
"justify": "start",
|
||||||
|
"style": {},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "c_header",
|
||||||
|
"type": "container",
|
||||||
|
"position": { "type": "flow" },
|
||||||
|
"size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } },
|
||||||
|
"direction": "row",
|
||||||
|
"gap": 5,
|
||||||
|
"padding": { "top": 0, "right": 0, "bottom": 0, "left": 0 },
|
||||||
|
"align": "start",
|
||||||
|
"justify": "start",
|
||||||
|
"style": {},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "el_firma",
|
||||||
|
"type": "text",
|
||||||
|
"position": { "type": "flow" },
|
||||||
|
"size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } },
|
||||||
|
"style": { "fontSize": 14, "fontWeight": "bold" },
|
||||||
|
"binding": { "type": "scalar", "path": "firma.unvan" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "el_fatura_baslik",
|
||||||
|
"type": "static_text",
|
||||||
|
"position": { "type": "flow" },
|
||||||
|
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
|
||||||
|
"style": { "fontSize": 12, "fontWeight": "bold", "align": "right" },
|
||||||
|
"content": "FATURA"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "el_cizgi",
|
||||||
|
"type": "line",
|
||||||
|
"position": { "type": "flow" },
|
||||||
|
"size": { "width": { "type": "fr", "value": 1 }, "height": { "type": "auto" } },
|
||||||
|
"style": { "strokeColor": "#000000", "strokeWidth": 0.5 }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Eleman Tipleri
|
||||||
|
|
||||||
|
| Tip | Açıklama | Binding |
|
||||||
|
| ----------------- | ------------------------------------- | ---------------- |
|
||||||
|
| `container` | Düzen kutusu, çocuk elemanları barındırır | Yok |
|
||||||
|
| `static_text` | Sabit metin, veri bağlantısı yok | Yok |
|
||||||
|
| `text` | Dinamik metin, schema'dan veri çeker | Scalar |
|
||||||
|
| `repeating_table` | Array verisinden tekrarlayan tablo | Array |
|
||||||
|
| `line` | Yatay/dikey çizgi | Yok |
|
||||||
|
| `image` | Statik veya dinamik görsel | Opsiyonel scalar |
|
||||||
|
| `page_number` | Sayfa numarası (çok sayfalı belgeler) | Otomatik |
|
||||||
|
|
||||||
|
### Container Özellikleri
|
||||||
|
|
||||||
|
| Özellik | Tip | Açıklama |
|
||||||
|
| ----------- | ---------------------------------------- | --------------------------------- |
|
||||||
|
| `direction` | `"row"` \| `"column"` | Çocukları yatay mı dikey mi diz |
|
||||||
|
| `gap` | number (mm) | Çocuklar arası boşluk |
|
||||||
|
| `padding` | `{ top, right, bottom, left }` (mm) | İç boşluk |
|
||||||
|
| `align` | `"start"` \| `"center"` \| `"end"` \| `"stretch"` | Cross-axis hizalama |
|
||||||
|
| `justify` | `"start"` \| `"center"` \| `"end"` \| `"space-between"` | Main-axis dağılım |
|
||||||
|
| `style` | `{ backgroundColor, borderColor, borderWidth, borderRadius }` | Görsel stil |
|
||||||
|
|
||||||
|
### Positioning Modları
|
||||||
|
|
||||||
|
| Mod | Açıklama | Typst karşılığı |
|
||||||
|
| ---------- | ------------------------------------------- | ----------------------- |
|
||||||
|
| `flow` | Parent container'ın flow'una katıl (default)| `stack` / `box` içinde |
|
||||||
|
| `absolute` | Parent container içinde sabit konum | `place(dx, dy)` |
|
||||||
|
|
||||||
|
### Fatura Örneği — Container Ağacı
|
||||||
|
|
||||||
|
```
|
||||||
|
Sayfa (kök container, column, padding: 15mm)
|
||||||
|
├── Header (container, row, gap: 5mm)
|
||||||
|
│ ├── Logo alanı (container, column, width: 60mm)
|
||||||
|
│ │ ├── image (logo)
|
||||||
|
│ │ └── text (firma ünvanı)
|
||||||
|
│ └── Fatura bilgi (container, column, width: fill, align: end)
|
||||||
|
│ ├── static_text ("FATURA")
|
||||||
|
│ ├── text (fatura no)
|
||||||
|
│ └── text (tarih)
|
||||||
|
├── line (ayırıcı çizgi)
|
||||||
|
├── repeating_table (kalemler)
|
||||||
|
└── Footer (container, row)
|
||||||
|
├── boş alan (width: fill)
|
||||||
|
└── Toplamlar (container, column, width: 80mm)
|
||||||
|
├── text (ara toplam)
|
||||||
|
├── text (KDV)
|
||||||
|
└── text (genel toplam)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data JSON (Gerçek Veri)
|
||||||
|
|
||||||
|
Render zamanında şablonla birleştirilen veri:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"firma": {
|
||||||
|
"unvan": "Acme Teknoloji A.Ş.",
|
||||||
|
"vergiNo": "1234567890",
|
||||||
|
"logo": "data:image/png;base64,...",
|
||||||
|
},
|
||||||
|
"fatura": {
|
||||||
|
"no": "FTR-2026-001",
|
||||||
|
"tarih": "2026-03-29",
|
||||||
|
},
|
||||||
|
"kalemler": [
|
||||||
|
{
|
||||||
|
"siraNo": 1,
|
||||||
|
"adi": "Web Geliştirme Hizmeti",
|
||||||
|
"miktar": 1,
|
||||||
|
"birim": "Adet",
|
||||||
|
"birimFiyat": 15000,
|
||||||
|
"tutar": 15000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"siraNo": 2,
|
||||||
|
"adi": "SSL Sertifikası",
|
||||||
|
"miktar": 2,
|
||||||
|
"birim": "Adet",
|
||||||
|
"birimFiyat": 500,
|
||||||
|
"tutar": 1000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"toplamlar": {
|
||||||
|
"araToplam": 16000,
|
||||||
|
"kdv": 2880,
|
||||||
|
"genelToplam": 18880,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSON Schema (Veri Yapısı Tanımı)
|
||||||
|
|
||||||
|
Editörün sol panelinde kullanıcıya sunulan, bağlanabilir alanların ağaç yapısı. Kullanıcı bu ağaçtan sürükleyerek elemanları bağlar.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"$id": "fatura-schema",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"firma": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"unvan": { "type": "string", "title": "Firma Ünvanı" },
|
||||||
|
"vergiNo": { "type": "string", "title": "Vergi No" },
|
||||||
|
"logo": { "type": "string", "title": "Logo", "format": "image" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"fatura": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"no": { "type": "string", "title": "Fatura No" },
|
||||||
|
"tarih": { "type": "string", "title": "Tarih", "format": "date" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"kalemler": {
|
||||||
|
"type": "array",
|
||||||
|
"title": "Fatura Kalemleri",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"siraNo": { "type": "integer", "title": "Sıra No" },
|
||||||
|
"adi": { "type": "string", "title": "Ürün / Hizmet Adı" },
|
||||||
|
"miktar": { "type": "number", "title": "Miktar" },
|
||||||
|
"birim": { "type": "string", "title": "Birim" },
|
||||||
|
"birimFiyat": {
|
||||||
|
"type": "number",
|
||||||
|
"title": "Birim Fiyat",
|
||||||
|
"format": "currency",
|
||||||
|
},
|
||||||
|
"tutar": { "type": "number", "title": "Tutar", "format": "currency" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"toplamlar": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"araToplam": {
|
||||||
|
"type": "number",
|
||||||
|
"title": "Ara Toplam",
|
||||||
|
"format": "currency",
|
||||||
|
},
|
||||||
|
"kdv": { "type": "number", "title": "KDV", "format": "currency" },
|
||||||
|
"genelToplam": {
|
||||||
|
"type": "number",
|
||||||
|
"title": "Genel Toplam",
|
||||||
|
"format": "currency",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Binding Mekanizması
|
||||||
|
|
||||||
|
### Scalar Binding
|
||||||
|
|
||||||
|
Basit alan bağlama — bir eleman, JSON'daki tek bir değere bağlanır.
|
||||||
|
|
||||||
|
- Editörde: Kullanıcı schema ağacından bir alanı sürükleyip text elemanına bırakır.
|
||||||
|
- Template JSON'da: `"binding": { "type": "scalar", "path": "firma.vergiNo" }`
|
||||||
|
- Typst çıktısında: `#data.firma.vergiNo`
|
||||||
|
|
||||||
|
### Array Binding (Tekrarlayan Tablo)
|
||||||
|
|
||||||
|
Array verisi için özel tablo bileşeni. Kullanıcı:
|
||||||
|
|
||||||
|
1. Araç çubuğundan "Tekrarlayan Tablo" bileşenini sürükler.
|
||||||
|
2. `dataSource` olarak schema'daki bir array alanı seçer (ör: `kalemler`).
|
||||||
|
3. Sütun tanımlarında array'in alt alanlarını seçer (ör: `kalemler[].adi`).
|
||||||
|
4. Tablo stili (header rengi, zebra satırlar vs.) ayarlar.
|
||||||
|
|
||||||
|
Typst çıktısında:
|
||||||
|
|
||||||
|
```typst
|
||||||
|
#let kalemler = data.kalemler
|
||||||
|
#table(
|
||||||
|
columns: (8%, 40%, 12%, 10%, 15%, 15%),
|
||||||
|
align: (center, left, right, center, right, right),
|
||||||
|
fill: (_, row) => if row == 0 { rgb("#f0f0f0") } else if calc.odd(row) { rgb("#fafafa") } else { none },
|
||||||
|
[*\#*], [*Ürün / Hizmet*], [*Miktar*], [*Birim*], [*Birim Fiyat*], [*Tutar*],
|
||||||
|
..kalemler.map(k => (
|
||||||
|
[#k.siraNo],
|
||||||
|
[#k.adi],
|
||||||
|
[#k.miktar],
|
||||||
|
[#k.birim],
|
||||||
|
[#format-currency(k.birimFiyat)],
|
||||||
|
[#format-currency(k.tutar)],
|
||||||
|
)).flatten()
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Format Fonksiyonları
|
||||||
|
|
||||||
|
Schema'daki `format` alanına göre Typst helper fonksiyonları üretilir:
|
||||||
|
|
||||||
|
- `currency` → para birimi formatlama (binlik ayracı, kuruş, ₺ sembolü)
|
||||||
|
- `date` → tarih formatlama (gün.ay.yıl)
|
||||||
|
- `percentage` → yüzde formatlama
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Template JSON → Typst Markup Dönüşümü
|
||||||
|
|
||||||
|
Bu dönüşüm hem frontend'de (WASM önizleme için) hem backend'de (PDF üretimi için) çalışır. Aynı saf fonksiyon her iki tarafta da kullanılmalıdır:
|
||||||
|
|
||||||
|
- **Frontend:** TypeScript'te yazılır (`core/template-to-typst.ts`).
|
||||||
|
- **Backend:** Aynı mantık Rust'ta implemente edilir.
|
||||||
|
|
||||||
|
Her iki implementasyon da birebir aynı Typst çıktısını üretmelidir. Tutarlılık testleri yazılmalıdır.
|
||||||
|
|
||||||
|
### Dönüşüm Kuralları (Container-Based)
|
||||||
|
|
||||||
|
1. **Sayfa ayarları** — kök container'ın padding'i = sayfa margin:
|
||||||
|
|
||||||
|
```typst
|
||||||
|
#set page(width: 210mm, height: 297mm, margin: (top: 15mm, right: 15mm, bottom: 15mm, left: 15mm))
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Veri enjeksiyonu:**
|
||||||
|
|
||||||
|
```typst
|
||||||
|
#let data = ( firma: ( unvan: "Acme A.Ş.", ... ), ... )
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Container (column) → `stack(dir: ttb)`:**
|
||||||
|
|
||||||
|
```typst
|
||||||
|
#stack(dir: ttb, spacing: 5mm,
|
||||||
|
[#text(size: 18pt, weight: "bold")[dreport]],
|
||||||
|
[#text(size: 11pt, fill: rgb("#666666"))[Alt başlık]],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Container (row) → `stack(dir: ltr)`:**
|
||||||
|
|
||||||
|
```typst
|
||||||
|
#stack(dir: ltr, spacing: 5mm,
|
||||||
|
[#box(width: 1fr)[Sol kolon]],
|
||||||
|
[#box(width: 1fr)[Sağ kolon]],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **İç içe container → `box` + `stack`:**
|
||||||
|
|
||||||
|
```typst
|
||||||
|
#box(width: 1fr, inset: (top: 5mm, bottom: 5mm))[
|
||||||
|
#stack(dir: ttb, spacing: 3mm,
|
||||||
|
[#text[Eleman 1]],
|
||||||
|
[#text[Eleman 2]],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Absolute eleman → `place()` (sadece absolute positioning seçilmişse):**
|
||||||
|
|
||||||
|
```typst
|
||||||
|
#place(top + left, dx: 130mm, dy: 30mm)[
|
||||||
|
#text(size: 12pt, weight: "bold")[FATURA]
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
7. **Çizgi → `line()`:**
|
||||||
|
|
||||||
|
```typst
|
||||||
|
#line(length: 1fr, stroke: 0.5pt + rgb("#000000"))
|
||||||
|
```
|
||||||
|
|
||||||
|
8. **Önizleme vs. nihai render:**
|
||||||
|
- Önizleme: `data` mock veri ile doldurulur.
|
||||||
|
- Nihai render: `data` gerçek JSON verisi ile doldurulur.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SVG Overlay — Etkileşim Katmanı
|
||||||
|
|
||||||
|
Typst SVG'si read-only bir render çıktısıdır — tıklama veya sürükleme algılamaz. SVG'nin üstüne Vue bileşenleriyle bir etkileşim katmanı (overlay) koyulur.
|
||||||
|
|
||||||
|
### Overlay Nasıl Çalışır (Container Layout)
|
||||||
|
|
||||||
|
1. Overlay, template JSON'un ağaç yapısını yansıtır (recursive `ElementHandle` bileşenleri).
|
||||||
|
2. Kök overlay, sayfa padding'ini CSS padding olarak uygular.
|
||||||
|
3. Flow elemanlar `position: relative` ile doğal akışta durur.
|
||||||
|
4. Absolute elemanlar `position: absolute` ile parent container içinde konumlanır.
|
||||||
|
5. Tıklama ile seçim → mavi kenarlık (container ise mor kenarlık) + resize handle'lar.
|
||||||
|
6. Absolute elemanlar sürüklenebilir — drag sırasında CSS transform, bırakınca Typst re-render.
|
||||||
|
7. Flow elemanlar sürüklenemez — sıra değişikliği drag-to-reorder ile yapılır (ilerideki fazda).
|
||||||
|
|
||||||
|
### Overlay ↔ SVG Koordinat Eşleştirme
|
||||||
|
|
||||||
|
- Overlay container'ı, sayfa CSS variable'ları ile margin'leri eşler.
|
||||||
|
- Zoom yapıldığında hem SVG hem overlay aynı oranda scale edilir.
|
||||||
|
- Koordinat dönüşümü: `px = mm * (containerWidthPx / pageWidthMm) * zoomLevel`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Proje Yapısı (Monorepo)
|
||||||
|
|
||||||
|
```
|
||||||
|
dreport/
|
||||||
|
├── CLAUDE.md
|
||||||
|
├── README.md
|
||||||
|
├── frontend/ # Vue 3 + TypeScript
|
||||||
|
│ ├── package.json
|
||||||
|
│ ├── vite.config.ts
|
||||||
|
│ ├── tsconfig.json
|
||||||
|
│ ├── public/
|
||||||
|
│ │ └── fonts/ # Typst WASM için gömülü fontlar
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── main.ts
|
||||||
|
│ │ ├── App.vue
|
||||||
|
│ │ ├── stores/ # Pinia
|
||||||
|
│ │ │ ├── template.ts # Template JSON state
|
||||||
|
│ │ │ ├── schema.ts # JSON Schema state
|
||||||
|
│ │ │ └── editor.ts # Editör UI state (seçili eleman, zoom vs.)
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ │ ├── editor/
|
||||||
|
│ │ │ │ ├── EditorCanvas.vue # Ana editör alanı (SVG + overlay container)
|
||||||
|
│ │ │ │ ├── TypstSvgLayer.vue # Typst SVG render katmanı
|
||||||
|
│ │ │ │ ├── InteractionOverlay.vue # Etkileşim katmanı (tüm handle'ların parent'ı)
|
||||||
|
│ │ │ │ ├── ElementHandle.vue # Tekil eleman seçim/drag/resize handle
|
||||||
|
│ │ │ │ ├── SnapGuides.vue # Hizalama çizgileri
|
||||||
|
│ │ │ │ └── RulerBar.vue # Cetvel
|
||||||
|
│ │ │ ├── panels/
|
||||||
|
│ │ │ │ ├── ToolboxPanel.vue # Sol: bileşen araç kutusu
|
||||||
|
│ │ │ │ ├── SchemaTreePanel.vue # Sol: JSON schema ağacı (drag source)
|
||||||
|
│ │ │ │ └── PropertiesPanel.vue # Sağ: seçili elemanın özellikleri
|
||||||
|
│ │ │ ├── properties/
|
||||||
|
│ │ │ │ ├── TextProperties.vue
|
||||||
|
│ │ │ │ ├── TableProperties.vue
|
||||||
|
│ │ │ │ ├── ImageProperties.vue
|
||||||
|
│ │ │ │ └── StyleProperties.vue
|
||||||
|
│ │ │ └── common/
|
||||||
|
│ │ │ ├── ColorPicker.vue
|
||||||
|
│ │ │ ├── FontSelector.vue
|
||||||
|
│ │ │ └── UnitInput.vue # mm/pt girişi
|
||||||
|
│ │ ├── composables/
|
||||||
|
│ │ │ ├── useTypstCompiler.ts # Typst WASM yönetimi (Web Worker iletişimi)
|
||||||
|
│ │ │ ├── useDragDrop.ts # Sürükle-bırak mantığı
|
||||||
|
│ │ │ ├── useElementSelection.ts # Eleman seçimi
|
||||||
|
│ │ │ ├── useSnapGuides.ts # Mıknatıslı hizalama
|
||||||
|
│ │ │ ├── useUndoRedo.ts # Geri al / yinele
|
||||||
|
│ │ │ └── useZoomPan.ts # Zoom ve kaydırma
|
||||||
|
│ │ ├── workers/
|
||||||
|
│ │ │ └── typst.worker.ts # Typst WASM Web Worker
|
||||||
|
│ │ ├── core/
|
||||||
|
│ │ │ ├── template-to-typst.ts # Template JSON → Typst markup dönüşümü
|
||||||
|
│ │ │ ├── schema-parser.ts # JSON Schema → ağaç yapısı (panel için)
|
||||||
|
│ │ │ ├── mock-data-generator.ts # Schema'dan örnek veri üretme
|
||||||
|
│ │ │ └── types.ts # Ortak TypeScript tip tanımları
|
||||||
|
│ │ └── styles/
|
||||||
|
│ │ └── editor.css
|
||||||
|
│ └── tests/
|
||||||
|
│ ├── template-to-typst.test.ts
|
||||||
|
│ └── schema-parser.test.ts
|
||||||
|
│
|
||||||
|
├── backend/ # Rust + Axum
|
||||||
|
│ ├── Cargo.toml
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── main.rs
|
||||||
|
│ │ ├── routes/
|
||||||
|
│ │ │ ├── mod.rs
|
||||||
|
│ │ │ ├── render.rs # POST /api/render → PDF
|
||||||
|
│ │ │ └── health.rs # GET /api/health
|
||||||
|
│ │ ├── typst_engine/
|
||||||
|
│ │ │ ├── mod.rs
|
||||||
|
│ │ │ ├── compiler.rs # typst crate wrapper (World impl)
|
||||||
|
│ │ │ ├── template_to_typst.rs # Template JSON → Typst markup (Rust)
|
||||||
|
│ │ │ └── fonts.rs # Font yönetimi ve yükleme
|
||||||
|
│ │ └── models/
|
||||||
|
│ │ ├── mod.rs
|
||||||
|
│ │ ├── template.rs # Template JSON serde modelleri
|
||||||
|
│ │ └── schema.rs # JSON Schema modelleri
|
||||||
|
│ ├── fonts/ # Gömülü font dosyaları
|
||||||
|
│ │ ├── NotoSans-Regular.ttf
|
||||||
|
│ │ ├── NotoSans-Bold.ttf
|
||||||
|
│ │ ├── NotoSans-Italic.ttf
|
||||||
|
│ │ └── NotoSansMono-Regular.ttf
|
||||||
|
│ └── tests/
|
||||||
|
│ ├── render_test.rs
|
||||||
|
│ └── template_to_typst_test.rs
|
||||||
|
│
|
||||||
|
└── shared/ # Ortak şema tanımları
|
||||||
|
└── schemas/
|
||||||
|
├── fatura.schema.json
|
||||||
|
└── irsaliye.schema.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
İlk aşamada minimal API:
|
||||||
|
|
||||||
|
### `POST /api/render`
|
||||||
|
|
||||||
|
Template JSON + Data JSON alır, PDF döner.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"template": {},
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** `Content-Type: application/pdf` — binary PDF
|
||||||
|
|
||||||
|
### `GET /api/health`
|
||||||
|
|
||||||
|
Sunucu sağlık kontrolü.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Editör UI/UX Davranışları
|
||||||
|
|
||||||
|
### Eleman Ekleme
|
||||||
|
|
||||||
|
- **Araç kutusundan sürükle-bırak:** Container, statik metin, çizgi, görsel, tekrarlayan tablo.
|
||||||
|
- **Container'a bırakma:** Eleman hedef container'ın flow'una eklenir. Drop pozisyonuna göre sıra belirlenir.
|
||||||
|
- **Schema ağacından sürükle-bırak:** Scalar alan container'a bırakılınca `text` elemanı oluşur, binding ayarlanır.
|
||||||
|
- **Schema'dan array alanı sürükle-bırak:** `repeating_table` oluşur, sütunları ayarlama diyaloğu açılır.
|
||||||
|
|
||||||
|
### Eleman Seçimi ve Manipülasyon
|
||||||
|
|
||||||
|
- Tıklama ile seçim → mavi kenarlık (container ise mor kenarlık) + resize handle'lar.
|
||||||
|
- **Flow elemanlar:** Sürüklenemez (pozisyon otomatik). Sıra değişikliği drag-to-reorder ile.
|
||||||
|
- **Absolute elemanlar:** Drag ile taşınır. Sürükleme sırasında CSS transform, bırakınca Typst re-render.
|
||||||
|
- **Container seçimi:** Tıklayınca sağ panelde direction, gap, align, padding ayarları.
|
||||||
|
- **Positioning modu değişikliği:** Sağ panelde flow ↔ absolute geçişi.
|
||||||
|
- Delete/Backspace ile silme.
|
||||||
|
- Shift+tıklama ile çoklu seçim.
|
||||||
|
|
||||||
|
### Undo/Redo
|
||||||
|
|
||||||
|
- Template JSON üzerinde immutable snapshot stack.
|
||||||
|
- Ctrl+Z / Ctrl+Shift+Z.
|
||||||
|
|
||||||
|
### Zoom ve Pan
|
||||||
|
|
||||||
|
- Ctrl+scroll ile zoom.
|
||||||
|
- Space+drag veya orta fare tuşu ile pan.
|
||||||
|
- Zoom aralığı: %25 – %400.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Geliştirme Öncelikleri (Roadmap)
|
||||||
|
|
||||||
|
### Faz 1: Temel Altyapı ✓
|
||||||
|
|
||||||
|
- [x] Proje iskeleti kurulumu (Vue + Vite + Pinia, Axum boilerplate)
|
||||||
|
- [x] Typst WASM entegrasyonu — Web Worker'da Typst markup → SVG
|
||||||
|
- [x] Template JSON → Typst markup dönüşümü (static_text, text, line)
|
||||||
|
- [x] Container-based layout sistemi (tree yapı, flow + absolute positioning)
|
||||||
|
- [x] EditorCanvas: Typst SVG + recursive overlay + seçim
|
||||||
|
- [x] Absolute elemanlar için drag ile taşıma
|
||||||
|
- [x] Resize handle'lar
|
||||||
|
- [x] Backend iskeleti (Axum, health endpoint, render placeholder)
|
||||||
|
- [x] Font dosyaları (Noto Sans ailesi)
|
||||||
|
|
||||||
|
### Faz 2: Editör Temelleri
|
||||||
|
|
||||||
|
- [ ] `text` (dinamik binding'li) eleman tipi (Typst dönüşümü var, UI eksik)
|
||||||
|
- [ ] Schema tree paneli — JSON schema'dan ağaç oluşturma
|
||||||
|
- [ ] Schema'dan drag ile binding oluşturma
|
||||||
|
- [ ] Properties paneli — seçili elemanın stillerini düzenleme (font, renk, boyut, hizalama)
|
||||||
|
- [ ] Container properties paneli — direction, gap, padding, align ayarları
|
||||||
|
- [ ] Mock data generator — schema'dan örnek veri üretip önizlemede kullanma
|
||||||
|
- [ ] Undo/redo
|
||||||
|
- [ ] Toolbox paneli — eleman/container ekleme
|
||||||
|
|
||||||
|
### Faz 3: Tablo ve Array Binding
|
||||||
|
|
||||||
|
- [ ] `repeating_table` bileşeni ve Typst markup üretimi
|
||||||
|
- [ ] Sütun tanımlama UI'ı (alan seçimi, genişlik, hizalama)
|
||||||
|
- [ ] Array field'larına binding
|
||||||
|
- [ ] Tablo stili ayarları (header, zebra, border)
|
||||||
|
- [ ] Format fonksiyonları (currency, date)
|
||||||
|
|
||||||
|
### Faz 4: PDF Render Backend
|
||||||
|
|
||||||
|
- [ ] Axum server setup + `POST /api/render`
|
||||||
|
- [ ] Rust'ta template-to-typst dönüşümü (TypeScript versiyonuyla tutarlı)
|
||||||
|
- [ ] `typst` crate ile `World` trait implementasyonu
|
||||||
|
- [ ] PDF derleme ve response olarak dönme
|
||||||
|
- [ ] Font embed (frontend ile aynı font seti)
|
||||||
|
- [ ] Frontend'den "PDF İndir" butonu
|
||||||
|
|
||||||
|
### Faz 5: Polish
|
||||||
|
|
||||||
|
- [ ] Snap guides ve hizalama
|
||||||
|
- [ ] Zoom / pan
|
||||||
|
- [ ] `line`, `rect` eleman tipleri
|
||||||
|
- [ ] `image` eleman tipi (statik + dinamik)
|
||||||
|
- [ ] Sayfa numarası
|
||||||
|
- [ ] Çoklu sayfa desteği
|
||||||
|
- [ ] Template kaydetme / yükleme (JSON dosyası export/import)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Önemli Teknik Notlar
|
||||||
|
|
||||||
|
### Typst WASM Font Stratejisi
|
||||||
|
|
||||||
|
Typst tarayıcıda çalışırken sistem fontlarına erişemez. Font dosyaları (`*.ttf` / `*.otf`) projeye dahil edilmeli ve WASM'a yüklenmelidir. Başlangıçta minimal bir set:
|
||||||
|
|
||||||
|
- Noto Sans (Regular, Bold, Italic, Bold Italic) — genel metin
|
||||||
|
- Noto Sans Mono (Regular) — tablo sayıları, monospace ihtiyaçları
|
||||||
|
- Toplam ~4-5 MB
|
||||||
|
|
||||||
|
**Kritik:** Backend'de (Rust) ve frontend'de (WASM) birebir aynı font dosyaları kullanılmalıdır. Farklı font = farklı metrik = render uyumsuzluğu.
|
||||||
|
|
||||||
|
### Koordinat Sistemi
|
||||||
|
|
||||||
|
- Tüm pozisyonlar **milimetre (mm)** cinsindendir.
|
||||||
|
- Template JSON'daki değerler mm, Typst'e `Xmm` olarak yazılır.
|
||||||
|
- Editör canvas'ta mm → px dönüşümü: `px = mm * (containerWidthPx / pageWidthMm) * zoomLevel`
|
||||||
|
- Referans: A4 = 210mm × 297mm.
|
||||||
|
|
||||||
|
### Typst Özel Karakter Escape
|
||||||
|
|
||||||
|
Template JSON → Typst dönüşümünde kullanıcı verisindeki özel karakterler escape edilmelidir:
|
||||||
|
|
||||||
|
- `#`, `$`, `@`, `*`, `_`, `<`, `>`, `\` Typst'te özel anlam taşır.
|
||||||
|
- Kullanıcı verisi `[...]` content block'a sarılarak büyük ölçüde güvenli hale gelir.
|
||||||
|
- İçerideki `[`, `]` karakterleri ise `\[`, `\]` olarak escape edilmelidir.
|
||||||
|
|
||||||
|
### Hata Yönetimi
|
||||||
|
|
||||||
|
- Typst derleme hatası olursa → editörde kırmızı banner ile hata mesajı göster.
|
||||||
|
- Derleme başarısız olduğunda son başarılı SVG'yi koru, kullanıcının çalışmasını bozma.
|
||||||
|
- Web Worker crash olursa → yeniden başlat, state'i koru.
|
||||||
|
|
||||||
|
### Eleman Sırası (Z-Order)
|
||||||
|
|
||||||
|
- Template JSON'daki `elements` dizisinin sırası = çizim sırası (sonraki üstte).
|
||||||
|
- Kullanıcı "Öne Getir" / "Arkaya Gönder" yapabilmeli → dizi sırası değişir.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rust Backend — Typst World Implementasyonu
|
||||||
|
|
||||||
|
Typst crate ile PDF üretmek için `World` trait'i implement etmek gerekir. Bu trait, Typst'e dosya sistemi, fontlar ve zaman bilgisi sağlar.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use typst::World;
|
||||||
|
|
||||||
|
struct DreportWorld {
|
||||||
|
/// Ana .typ dosyasının içeriği (dinamik üretilen markup)
|
||||||
|
main_source: String,
|
||||||
|
/// Yüklenmiş font dosyaları
|
||||||
|
fonts: Vec<typst::text::Font>,
|
||||||
|
/// Data JSON (json dosyası olarak erişilebilir)
|
||||||
|
data_json: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl World for DreportWorld {
|
||||||
|
// file(), font(), main(), source(), ... implementasyonları
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Derleme akışı:
|
||||||
|
|
||||||
|
1. HTTP request gelir (template + data JSON).
|
||||||
|
2. Template JSON → Typst markup string üretilir.
|
||||||
|
3. Data JSON, sanal dosya sistemi üzerinden `data.json` olarak erişilebilir yapılır.
|
||||||
|
4. `DreportWorld` oluşturulur.
|
||||||
|
5. `typst::compile(&world)` → `Document` elde edilir.
|
||||||
|
6. `typst_pdf::pdf(&document, ...)` → PDF bytes.
|
||||||
|
7. Response olarak döndürülür.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kod Stili ve Konvansiyonlar
|
||||||
|
|
||||||
|
### Frontend (TypeScript / Vue)
|
||||||
|
|
||||||
|
- Composition API + `<script setup>` kullan, Options API KULLANMA.
|
||||||
|
- Pinia store'lar `defineStore` ile.
|
||||||
|
- Tip güvenliği: `strict: true` tsconfig'de. `any` kullanma, gerekirse `unknown` + type guard.
|
||||||
|
- Composable isimlendirme: `useXxx` pattern.
|
||||||
|
- Bileşen isimleri: PascalCase, en az iki kelime (ör: `EditorCanvas`, `SchemaTreePanel`).
|
||||||
|
- CSS: Scoped styles veya CSS modules. Global CSS minimum.
|
||||||
|
|
||||||
|
### Backend (Rust)
|
||||||
|
|
||||||
|
- Axum handler'lar async.
|
||||||
|
- Serde ile JSON serialize/deserialize (`#[derive(Serialize, Deserialize)]`).
|
||||||
|
- Hata yönetimi: `thiserror` ile typed errors, handler'larda `anyhow` kabul edilebilir.
|
||||||
|
- Typst crate dependency: `typst`, `typst-pdf`.
|
||||||
|
- Clippy uyarıları temiz tutulacak.
|
||||||
|
|
||||||
|
### Genel
|
||||||
|
|
||||||
|
- Commit mesajları: conventional commits (`feat:`, `fix:`, `refactor:`, `docs:` vs.).
|
||||||
|
- Türkçe yorum yazılabilir, kod ve değişken isimleri İngilizce.
|
||||||
|
- Template JSON field isimleri İngilizce (ör: `position`, `size`, `binding`).
|
||||||
|
- UI etiketleri ve kullanıcıya gösterilen metinler Türkçe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kısıtlamalar ve Bilinçli Tercihler
|
||||||
|
|
||||||
|
1. **Veritabanı yok (ilk aşama).** Template'ler JSON dosyası olarak import/export edilir.
|
||||||
|
2. **Kullanıcı auth yok.** Tek kullanıcılı yerel kullanım senaryosu.
|
||||||
|
3. **Sadece PDF çıktı.** Typst'in SVG/PNG/HTML çıktıları ileride eklenebilir.
|
||||||
|
4. **Tekrarlayan bölge (repeating region) yok — sadece tekrarlayan tablo.** Array binding yalnızca tablo bileşeni ile yapılır. Serbest form repeating region ilerideki fazlarda değerlendirilir.
|
||||||
|
5. **WYSIWYG garantisi Typst üzerinden.** Editörde kendi render engine'imiz yok — Typst ne üretiyorsa kullanıcı onu görür.
|
||||||
|
6. **Canvas kütüphanesi (fabric.js / konva.js) kullanılmıyor.** Etkileşim katmanı saf Vue bileşenleri + pointer event'ler ile yapılır. Render zaten Typst SVG'sidir.
|
||||||
4110
Cargo.lock
generated
Normal file
4110
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
Cargo.toml
Normal file
3
Cargo.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[workspace]
|
||||||
|
members = ["core", "backend"]
|
||||||
|
resolver = "2"
|
||||||
17
core/Cargo.toml
Normal file
17
core/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "dreport-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
base64 = "0.22"
|
||||||
|
wasm-bindgen = "0.2"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
wasm = []
|
||||||
5
core/src/lib.rs
Normal file
5
core/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod models;
|
||||||
|
pub mod template_to_typst;
|
||||||
|
|
||||||
|
#[cfg(feature = "wasm")]
|
||||||
|
mod wasm_api;
|
||||||
297
core/src/models.rs
Normal file
297
core/src/models.rs
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// --- Boyut sistemi ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum SizeValue {
|
||||||
|
#[serde(rename = "fixed")]
|
||||||
|
Fixed { value: f64 },
|
||||||
|
#[serde(rename = "auto")]
|
||||||
|
Auto,
|
||||||
|
#[serde(rename = "fr")]
|
||||||
|
Fr { value: f64 },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SizeConstraint {
|
||||||
|
pub width: SizeValue,
|
||||||
|
pub height: SizeValue,
|
||||||
|
pub min_width: Option<f64>,
|
||||||
|
pub min_height: Option<f64>,
|
||||||
|
pub max_width: Option<f64>,
|
||||||
|
pub max_height: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PageSettings {
|
||||||
|
pub width: f64,
|
||||||
|
pub height: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Padding {
|
||||||
|
pub top: f64,
|
||||||
|
pub right: f64,
|
||||||
|
pub bottom: f64,
|
||||||
|
pub left: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Positioning ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum PositionMode {
|
||||||
|
#[serde(rename = "flow")]
|
||||||
|
Flow,
|
||||||
|
#[serde(rename = "absolute")]
|
||||||
|
Absolute { x: f64, y: f64 },
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Stil ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct TextStyle {
|
||||||
|
pub font_size: Option<f64>,
|
||||||
|
pub font_weight: Option<String>,
|
||||||
|
pub font_family: Option<String>,
|
||||||
|
pub color: Option<String>,
|
||||||
|
pub align: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct LineStyle {
|
||||||
|
pub stroke_color: Option<String>,
|
||||||
|
pub stroke_width: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct ContainerStyle {
|
||||||
|
pub background_color: Option<String>,
|
||||||
|
pub border_color: Option<String>,
|
||||||
|
pub border_width: Option<f64>,
|
||||||
|
pub border_radius: Option<f64>,
|
||||||
|
pub border_style: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Binding ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ScalarBinding {
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ArrayBinding {
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tablo ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct TableColumn {
|
||||||
|
pub id: String,
|
||||||
|
pub field: String,
|
||||||
|
pub title: String,
|
||||||
|
pub width: SizeValue,
|
||||||
|
pub align: String,
|
||||||
|
pub format: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct TableStyle {
|
||||||
|
pub header_bg: Option<String>,
|
||||||
|
pub header_color: Option<String>,
|
||||||
|
pub zebra_odd: Option<String>,
|
||||||
|
pub zebra_even: Option<String>,
|
||||||
|
pub border_color: Option<String>,
|
||||||
|
pub border_width: Option<f64>,
|
||||||
|
pub font_size: Option<f64>,
|
||||||
|
pub header_font_size: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Barcode ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct BarcodeStyle {
|
||||||
|
pub color: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Element tipleri ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct ImageStyle {
|
||||||
|
pub object_fit: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum TemplateElement {
|
||||||
|
#[serde(rename = "container")]
|
||||||
|
Container(ContainerElement),
|
||||||
|
#[serde(rename = "static_text")]
|
||||||
|
StaticText(StaticTextElement),
|
||||||
|
#[serde(rename = "text")]
|
||||||
|
Text(TextElement),
|
||||||
|
#[serde(rename = "line")]
|
||||||
|
Line(LineElement),
|
||||||
|
#[serde(rename = "repeating_table")]
|
||||||
|
RepeatingTable(RepeatingTableElement),
|
||||||
|
#[serde(rename = "image")]
|
||||||
|
Image(ImageElement),
|
||||||
|
#[serde(rename = "page_number")]
|
||||||
|
PageNumber(PageNumberElement),
|
||||||
|
#[serde(rename = "barcode")]
|
||||||
|
Barcode(BarcodeElement),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TemplateElement {
|
||||||
|
pub fn id(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::Container(e) => &e.id,
|
||||||
|
Self::StaticText(e) => &e.id,
|
||||||
|
Self::Text(e) => &e.id,
|
||||||
|
Self::Line(e) => &e.id,
|
||||||
|
Self::RepeatingTable(e) => &e.id,
|
||||||
|
Self::Image(e) => &e.id,
|
||||||
|
Self::PageNumber(e) => &e.id,
|
||||||
|
Self::Barcode(e) => &e.id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn position(&self) -> &PositionMode {
|
||||||
|
match self {
|
||||||
|
Self::Container(e) => &e.position,
|
||||||
|
Self::StaticText(e) => &e.position,
|
||||||
|
Self::Text(e) => &e.position,
|
||||||
|
Self::Line(e) => &e.position,
|
||||||
|
Self::RepeatingTable(e) => &e.position,
|
||||||
|
Self::Image(e) => &e.position,
|
||||||
|
Self::PageNumber(e) => &e.position,
|
||||||
|
Self::Barcode(e) => &e.position,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn size(&self) -> &SizeConstraint {
|
||||||
|
match self {
|
||||||
|
Self::Container(e) => &e.size,
|
||||||
|
Self::StaticText(e) => &e.size,
|
||||||
|
Self::Text(e) => &e.size,
|
||||||
|
Self::Line(e) => &e.size,
|
||||||
|
Self::RepeatingTable(e) => &e.size,
|
||||||
|
Self::Image(e) => &e.size,
|
||||||
|
Self::PageNumber(e) => &e.size,
|
||||||
|
Self::Barcode(e) => &e.size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ContainerElement {
|
||||||
|
pub id: String,
|
||||||
|
pub position: PositionMode,
|
||||||
|
pub size: SizeConstraint,
|
||||||
|
pub direction: String,
|
||||||
|
pub gap: f64,
|
||||||
|
pub padding: Padding,
|
||||||
|
pub align: String,
|
||||||
|
pub justify: String,
|
||||||
|
pub style: ContainerStyle,
|
||||||
|
pub children: Vec<TemplateElement>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct StaticTextElement {
|
||||||
|
pub id: String,
|
||||||
|
pub position: PositionMode,
|
||||||
|
pub size: SizeConstraint,
|
||||||
|
pub style: TextStyle,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct TextElement {
|
||||||
|
pub id: String,
|
||||||
|
pub position: PositionMode,
|
||||||
|
pub size: SizeConstraint,
|
||||||
|
pub style: TextStyle,
|
||||||
|
pub content: Option<String>,
|
||||||
|
pub binding: ScalarBinding,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct LineElement {
|
||||||
|
pub id: String,
|
||||||
|
pub position: PositionMode,
|
||||||
|
pub size: SizeConstraint,
|
||||||
|
pub style: LineStyle,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ImageElement {
|
||||||
|
pub id: String,
|
||||||
|
pub position: PositionMode,
|
||||||
|
pub size: SizeConstraint,
|
||||||
|
pub src: Option<String>,
|
||||||
|
pub binding: Option<ScalarBinding>,
|
||||||
|
pub style: ImageStyle,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PageNumberElement {
|
||||||
|
pub id: String,
|
||||||
|
pub position: PositionMode,
|
||||||
|
pub size: SizeConstraint,
|
||||||
|
pub style: TextStyle,
|
||||||
|
pub format: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct BarcodeElement {
|
||||||
|
pub id: String,
|
||||||
|
pub position: PositionMode,
|
||||||
|
pub size: SizeConstraint,
|
||||||
|
pub format: String, // qr, ean13, ean8, code128, code39
|
||||||
|
pub value: Option<String>,
|
||||||
|
pub binding: Option<ScalarBinding>,
|
||||||
|
pub style: BarcodeStyle,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RepeatingTableElement {
|
||||||
|
pub id: String,
|
||||||
|
pub position: PositionMode,
|
||||||
|
pub size: SizeConstraint,
|
||||||
|
pub data_source: ArrayBinding,
|
||||||
|
pub columns: Vec<TableColumn>,
|
||||||
|
pub style: TableStyle,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Template ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Template {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub page: PageSettings,
|
||||||
|
pub fonts: Vec<String>,
|
||||||
|
pub root: ContainerElement,
|
||||||
|
}
|
||||||
1367
core/src/template_to_typst.rs
Normal file
1367
core/src/template_to_typst.rs
Normal file
File diff suppressed because it is too large
Load Diff
26
core/src/wasm_api.rs
Normal file
26
core/src/wasm_api.rs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
|
use crate::models::Template;
|
||||||
|
use crate::template_to_typst::{self, RenderMode};
|
||||||
|
|
||||||
|
/// Template JSON + Data JSON → Typst markup (editör modu, layout query dahil)
|
||||||
|
#[wasm_bindgen(js_name = "templateToTypstEditor")]
|
||||||
|
pub fn template_to_typst_editor(template_json: &str, data_json: &str) -> Result<String, JsValue> {
|
||||||
|
let template: Template = serde_json::from_str(template_json)
|
||||||
|
.map_err(|e| JsValue::from_str(&format!("Template parse hatasi: {}", e)))?;
|
||||||
|
let data: serde_json::Value = serde_json::from_str(data_json)
|
||||||
|
.map_err(|e| JsValue::from_str(&format!("Data parse hatasi: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(template_to_typst::template_to_typst(&template, &data, RenderMode::Editor))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Template JSON + Data JSON → Typst markup (PDF modu, layout query yok)
|
||||||
|
#[wasm_bindgen(js_name = "templateToTypstPdf")]
|
||||||
|
pub fn template_to_typst_pdf(template_json: &str, data_json: &str) -> Result<String, JsValue> {
|
||||||
|
let template: Template = serde_json::from_str(template_json)
|
||||||
|
.map_err(|e| JsValue::from_str(&format!("Template parse hatasi: {}", e)))?;
|
||||||
|
let data: serde_json::Value = serde_json::from_str(data_json)
|
||||||
|
.map_err(|e| JsValue::from_str(&format!("Data parse hatasi: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(template_to_typst::template_to_typst(&template, &data, RenderMode::Pdf))
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onBeforeUnmount } from 'vue'
|
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import EditorCanvas from './components/editor/EditorCanvas.vue'
|
import EditorCanvas from './components/editor/EditorCanvas.vue'
|
||||||
import ToolboxPanel from './components/panels/ToolboxPanel.vue'
|
import ToolboxPanel from './components/panels/ToolboxPanel.vue'
|
||||||
import PropertiesPanel from './components/panels/PropertiesPanel.vue'
|
import PropertiesPanel from './components/panels/PropertiesPanel.vue'
|
||||||
@@ -9,6 +9,70 @@ import { useEditorStore } from './stores/editor'
|
|||||||
const templateStore = useTemplateStore()
|
const templateStore = useTemplateStore()
|
||||||
const editorStore = useEditorStore()
|
const editorStore = useEditorStore()
|
||||||
|
|
||||||
|
const pdfLoading = ref(false)
|
||||||
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
function exportTemplate() {
|
||||||
|
const json = templateStore.exportTemplate()
|
||||||
|
const blob = new Blob([json], { type: 'application/json' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `${templateStore.template.name || 'sablon'}.json`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerImport() {
|
||||||
|
fileInputRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImportFile(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
try {
|
||||||
|
templateStore.importTemplate(reader.result as string)
|
||||||
|
} catch {
|
||||||
|
alert('Gecersiz sablon dosyasi')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.readAsText(file)
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadPdf() {
|
||||||
|
pdfLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await fetch('http://localhost:3001/api/render', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
template: templateStore.template,
|
||||||
|
data: templateStore.mockData,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text()
|
||||||
|
alert('PDF olusturulamadi: ' + text)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const blob = await res.blob()
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `${templateStore.template.name || 'belge'}.pdf`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
} catch (err) {
|
||||||
|
alert('Backend baglantisi kurulamadi. Sunucu calisiyor mu?')
|
||||||
|
} finally {
|
||||||
|
pdfLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
// Delete / Backspace — seçili elemanı sil
|
// Delete / Backspace — seçili elemanı sil
|
||||||
if ((e.key === 'Delete' || e.key === 'Backspace') && editorStore.selectedElementId) {
|
if ((e.key === 'Delete' || e.key === 'Backspace') && editorStore.selectedElementId) {
|
||||||
@@ -51,6 +115,13 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeyDown))
|
|||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<h1>dreport</h1>
|
<h1>dreport</h1>
|
||||||
<span class="app-header__subtitle">Belge Tasarim Araci</span>
|
<span class="app-header__subtitle">Belge Tasarim Araci</span>
|
||||||
|
<div style="flex: 1"></div>
|
||||||
|
<input ref="fileInputRef" type="file" accept=".json" style="display: none" @change="onImportFile" />
|
||||||
|
<button class="header-btn header-btn--secondary" @click="triggerImport">Yukle</button>
|
||||||
|
<button class="header-btn header-btn--secondary" @click="exportTemplate">Kaydet</button>
|
||||||
|
<button class="header-btn" :disabled="pdfLoading" @click="downloadPdf">
|
||||||
|
{{ pdfLoading ? 'Hazirlaniyor...' : 'PDF Indir' }}
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<main class="app-main">
|
<main class="app-main">
|
||||||
<aside class="app-sidebar app-sidebar--left">
|
<aside class="app-sidebar app-sidebar--left">
|
||||||
@@ -94,6 +165,38 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeyDown))
|
|||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-btn {
|
||||||
|
padding: 6px 16px;
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-btn:hover:not(:disabled) {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-btn--secondary {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #475569;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-btn--secondary:hover {
|
||||||
|
background: #334155;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
.app-main {
|
.app-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ import InteractionOverlay from './InteractionOverlay.vue'
|
|||||||
|
|
||||||
const templateStore = useTemplateStore()
|
const templateStore = useTemplateStore()
|
||||||
const editorStore = useEditorStore()
|
const editorStore = useEditorStore()
|
||||||
const { typstMarkup } = storeToRefs(templateStore)
|
const { template, mockData } = storeToRefs(templateStore)
|
||||||
|
|
||||||
const containerRef = ref<HTMLElement | null>(null)
|
const containerRef = ref<HTMLElement | null>(null)
|
||||||
const containerWidth = ref(800)
|
const containerWidth = ref(800)
|
||||||
|
|
||||||
// Typst compiler
|
// Typst compiler — template + data'yı worker'a gönderir, WASM ile derlenir
|
||||||
const { svg, error, compiling, layout, dispose } = useTypstCompiler(typstMarkup)
|
const { svg, error, compiling, layout, dispose } = useTypstCompiler(template, mockData)
|
||||||
|
|
||||||
// mm → px dönüşüm katsayısı
|
// mm → px dönüşüm katsayısı
|
||||||
const scale = computed(() => {
|
const scale = computed(() => {
|
||||||
@@ -37,6 +37,24 @@ const pageStyle = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Pan transform — sayfa container'ına uygulanacak
|
||||||
|
const panTransform = computed(() => {
|
||||||
|
if (editorStore.panX === 0 && editorStore.panY === 0) return undefined
|
||||||
|
return `translate(${editorStore.panX}px, ${editorStore.panY}px)`
|
||||||
|
})
|
||||||
|
|
||||||
|
// Pan: Space+drag veya orta fare tuşu
|
||||||
|
const isPanning = ref(false)
|
||||||
|
const panStart = ref({ x: 0, y: 0 })
|
||||||
|
const spaceHeld = ref(false)
|
||||||
|
|
||||||
|
// Pan cursor style
|
||||||
|
const canvasCursor = computed(() => {
|
||||||
|
if (isPanning.value) return 'grabbing'
|
||||||
|
if (spaceHeld.value) return 'grab'
|
||||||
|
return 'default'
|
||||||
|
})
|
||||||
|
|
||||||
// Container boyutunu izle
|
// Container boyutunu izle
|
||||||
let resizeObserver: ResizeObserver | null = null
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
@@ -48,11 +66,15 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
resizeObserver.observe(containerRef.value)
|
resizeObserver.observe(containerRef.value)
|
||||||
}
|
}
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
window.addEventListener('keyup', onKeyUp)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
resizeObserver?.disconnect()
|
resizeObserver?.disconnect()
|
||||||
dispose()
|
dispose()
|
||||||
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
|
window.removeEventListener('keyup', onKeyUp)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Zoom
|
// Zoom
|
||||||
@@ -63,27 +85,68 @@ function onWheel(e: WheelEvent) {
|
|||||||
editorStore.setZoom(editorStore.zoom + delta)
|
editorStore.setZoom(editorStore.zoom + delta)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.code === 'Space' && !e.repeat && !(e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement || e.target instanceof HTMLTextAreaElement)) {
|
||||||
|
e.preventDefault()
|
||||||
|
spaceHeld.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyUp(e: KeyboardEvent) {
|
||||||
|
if (e.code === 'Space') {
|
||||||
|
spaceHeld.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
if (e.button === 1 || (e.button === 0 && spaceHeld.value)) {
|
||||||
|
e.preventDefault()
|
||||||
|
isPanning.value = true
|
||||||
|
panStart.value = { x: e.clientX - editorStore.panX, y: e.clientY - editorStore.panY }
|
||||||
|
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(e: PointerEvent) {
|
||||||
|
if (!isPanning.value) return
|
||||||
|
editorStore.setPan(e.clientX - panStart.value.x, e.clientY - panStart.value.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp(e: PointerEvent) {
|
||||||
|
if (isPanning.value) {
|
||||||
|
isPanning.value = false
|
||||||
|
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="editor-canvas" ref="containerRef" @wheel="onWheel">
|
<div class="editor-canvas-wrapper">
|
||||||
<!-- Hata banner -->
|
<!-- Scroll alanı -->
|
||||||
<div v-if="error" class="editor-canvas__error">
|
<div
|
||||||
{{ error }}
|
class="editor-canvas"
|
||||||
</div>
|
ref="containerRef"
|
||||||
|
:style="{ cursor: canvasCursor }"
|
||||||
<!-- Derleme göstergesi -->
|
@wheel="onWheel"
|
||||||
<div v-if="compiling" class="editor-canvas__compiling">
|
@pointerdown="onPointerDown"
|
||||||
Derleniyor...
|
@pointermove="onPointerMove"
|
||||||
</div>
|
@pointerup="onPointerUp"
|
||||||
|
>
|
||||||
<!-- Sayfa -->
|
<!-- Sayfa -->
|
||||||
<div class="editor-canvas__page" :style="pageStyle">
|
<div class="editor-canvas__page" :style="[pageStyle, panTransform ? { transform: panTransform } : {}]">
|
||||||
<TypstSvgLayer :svg="svg" />
|
<TypstSvgLayer :svg="svg" />
|
||||||
<InteractionOverlay :scale="scale" :layout="layout" :page-width-pt="templateStore.template.page.width * 2.8346" />
|
<InteractionOverlay :scale="scale" :layout="layout" :page-width-pt="templateStore.template.page.width * 2.8346" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Zoom göstergesi -->
|
<!-- Sabit overlay'ler — scroll dışında -->
|
||||||
|
<div v-if="error" class="editor-canvas__error">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
<div v-if="compiling" class="editor-canvas__compiling">
|
||||||
|
Derleniyor...
|
||||||
|
</div>
|
||||||
<div class="editor-canvas__zoom">
|
<div class="editor-canvas__zoom">
|
||||||
%{{ editorStore.zoomPercent }}
|
%{{ editorStore.zoomPercent }}
|
||||||
</div>
|
</div>
|
||||||
@@ -91,16 +154,21 @@ function onWheel(e: WheelEvent) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.editor-canvas {
|
.editor-canvas-wrapper {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
background: #e5e7eb;
|
background: #e5e7eb;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
position: relative;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-canvas__page {
|
.editor-canvas__page {
|
||||||
|
|||||||
@@ -115,19 +115,22 @@ function findDeepestContainer(mouseX: number, mouseY: number, excludeId?: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Container içinde drop index hesapla */
|
/** Container içinde drop index hesapla */
|
||||||
function computeDropIndex(container: ContainerElement, mouseY: number, excludeId?: string) {
|
function computeDropIndex(container: ContainerElement, mouseX: number, mouseY: number, excludeId?: string) {
|
||||||
const s = ptToPx.value
|
const s = ptToPx.value
|
||||||
const flowChildren = container.children.filter(c => c.position.type !== 'absolute' && c.id !== excludeId)
|
const flowChildren = container.children.filter(c => c.position.type !== 'absolute' && c.id !== excludeId)
|
||||||
|
const isRow = container.direction === 'row'
|
||||||
|
|
||||||
let visualIdx = flowChildren.length
|
let visualIdx = flowChildren.length
|
||||||
|
|
||||||
for (let i = 0; i < flowChildren.length; i++) {
|
for (let i = 0; i < flowChildren.length; i++) {
|
||||||
const l = props.layout[flowChildren[i].id]
|
const l = props.layout[flowChildren[i].id]
|
||||||
if (!l) continue
|
if (!l) continue
|
||||||
|
if (isRow) {
|
||||||
|
const centerX = l.x * s + (l.width * s) / 2
|
||||||
|
if (mouseX < centerX) { visualIdx = i; break }
|
||||||
|
} else {
|
||||||
const centerY = l.y * s + (l.height * s) / 2
|
const centerY = l.y * s + (l.height * s) / 2
|
||||||
if (mouseY < centerY) {
|
if (mouseY < centerY) { visualIdx = i; break }
|
||||||
visualIdx = i
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +164,7 @@ function updateDropFromMouse(mouseX: number, mouseY: number, excludeId?: string)
|
|||||||
const container = findDeepestContainer(mouseX, mouseY, excludeId)
|
const container = findDeepestContainer(mouseX, mouseY, excludeId)
|
||||||
dropTargetContainerId.value = container.id
|
dropTargetContainerId.value = container.id
|
||||||
|
|
||||||
const { visualIdx, logicalIdx } = computeDropIndex(container, mouseY, excludeId)
|
const { visualIdx, logicalIdx } = computeDropIndex(container, mouseX, mouseY, excludeId)
|
||||||
dropVisualIndex.value = visualIdx
|
dropVisualIndex.value = visualIdx
|
||||||
dropLogicalIndex.value = logicalIdx
|
dropLogicalIndex.value = logicalIdx
|
||||||
}
|
}
|
||||||
@@ -183,24 +186,67 @@ const dropIndicatorStyle = computed(() => {
|
|||||||
|
|
||||||
const s = ptToPx.value
|
const s = ptToPx.value
|
||||||
const idx = dropVisualIndex.value
|
const idx = dropVisualIndex.value
|
||||||
|
const isRow = container.direction === 'row'
|
||||||
|
|
||||||
// Sürüklenen elemanı çıkar
|
// Sürüklenen elemanı çıkar
|
||||||
const dragId = dragElementId.value
|
const dragId = dragElementId.value
|
||||||
const flowChildren = container.children.filter(c => c.position.type !== 'absolute' && c.id !== dragId)
|
const flowChildren = container.children.filter(c => c.position.type !== 'absolute' && c.id !== dragId)
|
||||||
|
|
||||||
// Gap'in ortasına yerleştir: üstteki elemanın alt kenarı ile alttaki elemanın üst kenarı arası
|
const cl = props.layout[container.id]
|
||||||
|
if (!cl) return { display: 'none' }
|
||||||
|
|
||||||
|
if (isRow) {
|
||||||
|
// Row container: dikey gösterge çizgisi
|
||||||
|
let x = 0
|
||||||
|
if (idx === 0 && flowChildren.length > 0) {
|
||||||
|
const l = props.layout[flowChildren[0].id]
|
||||||
|
if (l) x = (cl.x * s + l.x * s) / 2
|
||||||
|
else x = cl.x * s
|
||||||
|
} else if (idx < flowChildren.length && idx > 0) {
|
||||||
|
const left = props.layout[flowChildren[idx - 1].id]
|
||||||
|
const right = props.layout[flowChildren[idx].id]
|
||||||
|
if (left && right) {
|
||||||
|
const leftEnd = (left.x + left.width) * s
|
||||||
|
const rightStart = right.x * s
|
||||||
|
x = (leftEnd + rightStart) / 2
|
||||||
|
}
|
||||||
|
} else if (idx === 0 && flowChildren.length === 0) {
|
||||||
|
x = cl.x * s + 8
|
||||||
|
} else if (flowChildren.length > 0) {
|
||||||
|
const last = flowChildren[flowChildren.length - 1]
|
||||||
|
const l = props.layout[last.id]
|
||||||
|
if (l) {
|
||||||
|
const gapPx = container.gap * props.scale
|
||||||
|
x = (l.x + l.width) * s + gapPx / 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const top = cl.y * s
|
||||||
|
const height = cl.height * s
|
||||||
|
|
||||||
|
return {
|
||||||
|
position: 'absolute' as const,
|
||||||
|
left: `${x}px`,
|
||||||
|
top: `${top}px`,
|
||||||
|
width: '2px',
|
||||||
|
height: `${height}px`,
|
||||||
|
background: 'rgb(59, 130, 246)',
|
||||||
|
borderRadius: '1px',
|
||||||
|
zIndex: 1000,
|
||||||
|
pointerEvents: 'none' as const,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column container: yatay gösterge çizgisi
|
||||||
let y = 0
|
let y = 0
|
||||||
if (idx === 0 && flowChildren.length > 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 l = props.layout[flowChildren[0].id]
|
||||||
const cl = props.layout[container.id]
|
if (l) {
|
||||||
if (l && cl) {
|
y = (cl.y * s + l.y * s) / 2
|
||||||
y = (cl.y * s + l.y * s) / 2 // container top ile eleman top arası
|
} else {
|
||||||
} else if (l) {
|
y = cl.y * s - 4
|
||||||
y = l.y * s - 4
|
|
||||||
}
|
}
|
||||||
} else if (idx < flowChildren.length && idx > 0) {
|
} 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 above = props.layout[flowChildren[idx - 1].id]
|
||||||
const below = props.layout[flowChildren[idx].id]
|
const below = props.layout[flowChildren[idx].id]
|
||||||
if (above && below) {
|
if (above && below) {
|
||||||
@@ -209,11 +255,8 @@ const dropIndicatorStyle = computed(() => {
|
|||||||
y = (aboveBottom + belowTop) / 2
|
y = (aboveBottom + belowTop) / 2
|
||||||
}
|
}
|
||||||
} else if (idx === 0 && flowChildren.length === 0) {
|
} else if (idx === 0 && flowChildren.length === 0) {
|
||||||
// Boş container
|
y = cl.y * s + 8
|
||||||
const cl = props.layout[container.id]
|
|
||||||
if (cl) y = cl.y * s + 8
|
|
||||||
} else if (flowChildren.length > 0) {
|
} else if (flowChildren.length > 0) {
|
||||||
// Son pozisyon: son elemanın altından gap kadar aşağıda
|
|
||||||
const last = flowChildren[flowChildren.length - 1]
|
const last = flowChildren[flowChildren.length - 1]
|
||||||
const l = props.layout[last.id]
|
const l = props.layout[last.id]
|
||||||
if (l) {
|
if (l) {
|
||||||
@@ -222,9 +265,8 @@ const dropIndicatorStyle = computed(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const cl = props.layout[container.id]
|
const x = cl.x * s
|
||||||
const x = cl ? cl.x * s : 0
|
const width = cl.width * s
|
||||||
const width = cl ? cl.width * s : 100
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
position: 'absolute' as const,
|
position: 'absolute' as const,
|
||||||
@@ -392,6 +434,7 @@ const resizeHandle = ref('')
|
|||||||
const resizeStart = ref({ mouseX: 0, mouseY: 0, x: 0, y: 0, width: 0, height: 0 })
|
const 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 resizeGhost = ref({ x: 0, y: 0, width: 0, height: 0 })
|
||||||
const resizeFinalMm = ref({ width: 0, height: 0 })
|
const resizeFinalMm = ref({ width: 0, height: 0 })
|
||||||
|
const resizeAspectRatio = ref(0) // > 0 ise aspect ratio korunur (width / height)
|
||||||
|
|
||||||
function onResizeStart(e: PointerEvent, elId: string, handle: string) {
|
function onResizeStart(e: PointerEvent, elId: string, handle: string) {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
@@ -407,6 +450,10 @@ function onResizeStart(e: PointerEvent, elId: string, handle: string) {
|
|||||||
const s = ptToPx.value
|
const s = ptToPx.value
|
||||||
const ptToMm = 1 / 2.8346
|
const ptToMm = 1 / 2.8346
|
||||||
|
|
||||||
|
// Barkod elemanları için aspect ratio'yu kaydet
|
||||||
|
const el = flatElements.value.find(e => e.id === elId)
|
||||||
|
resizeAspectRatio.value = (el?.type === 'barcode' && l.height > 0) ? l.width / l.height : 0
|
||||||
|
|
||||||
resizeStart.value = {
|
resizeStart.value = {
|
||||||
mouseX: e.clientX, mouseY: e.clientY,
|
mouseX: e.clientX, mouseY: e.clientY,
|
||||||
x: l.x * s, y: l.y * s,
|
x: l.x * s, y: l.y * s,
|
||||||
@@ -426,6 +473,7 @@ function onResizeMove(e: PointerEvent) {
|
|||||||
const dy = e.clientY - resizeStart.value.mouseY
|
const dy = e.clientY - resizeStart.value.mouseY
|
||||||
const handle = resizeHandle.value
|
const handle = resizeHandle.value
|
||||||
const pxToMm = 1 / props.scale
|
const pxToMm = 1 / props.scale
|
||||||
|
const ar = resizeAspectRatio.value
|
||||||
|
|
||||||
let gx = resizeStart.value.x, gy = resizeStart.value.y
|
let gx = resizeStart.value.x, gy = resizeStart.value.y
|
||||||
let gw = resizeStart.value.width, gh = resizeStart.value.height
|
let gw = resizeStart.value.width, gh = resizeStart.value.height
|
||||||
@@ -435,6 +483,11 @@ function onResizeMove(e: PointerEvent) {
|
|||||||
if (handle.includes('s')) gh = Math.max(10, resizeStart.value.height + dy)
|
if (handle.includes('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 }
|
if (handle.includes('n')) { gh = Math.max(10, resizeStart.value.height - dy); gy = resizeStart.value.y + dy }
|
||||||
|
|
||||||
|
// Aspect ratio koruma (barkod)
|
||||||
|
if (ar > 0) {
|
||||||
|
gh = gw / ar
|
||||||
|
}
|
||||||
|
|
||||||
resizeGhost.value = { x: gx, y: gy, width: gw, height: gh }
|
resizeGhost.value = { x: gx, y: gy, width: gw, height: gh }
|
||||||
|
|
||||||
const startWMm = resizeStart.value.width * pxToMm
|
const startWMm = resizeStart.value.width * pxToMm
|
||||||
@@ -445,6 +498,10 @@ function onResizeMove(e: PointerEvent) {
|
|||||||
if (handle.includes('s')) hMm = Math.max(3, startHMm + dy * pxToMm)
|
if (handle.includes('s')) hMm = Math.max(3, startHMm + dy * pxToMm)
|
||||||
if (handle.includes('n')) hMm = Math.max(3, startHMm - dy * pxToMm)
|
if (handle.includes('n')) hMm = Math.max(3, startHMm - dy * pxToMm)
|
||||||
|
|
||||||
|
if (ar > 0) {
|
||||||
|
hMm = wMm / ar
|
||||||
|
}
|
||||||
|
|
||||||
resizeFinalMm.value = { width: Math.round(wMm * 10) / 10, height: Math.round(hMm * 10) / 10 }
|
resizeFinalMm.value = { width: Math.round(wMm * 10) / 10, height: Math.round(hMm * 10) / 10 }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,7 +542,7 @@ function onToolboxDragLeave() {
|
|||||||
clearDropTarget()
|
clearDropTarget()
|
||||||
}
|
}
|
||||||
|
|
||||||
function onToolboxDrop(e: DragEvent) {
|
function onToolboxDrop(_e: DragEvent) {
|
||||||
const newEl = editorStore.draggedNewElement
|
const newEl = editorStore.draggedNewElement
|
||||||
if (!newEl) return
|
if (!newEl) return
|
||||||
|
|
||||||
@@ -532,11 +589,18 @@ const isAnyDragActive = computed(() =>
|
|||||||
|
|
||||||
<!-- Resize handles -->
|
<!-- Resize handles -->
|
||||||
<template v-if="editorStore.selectedElementId === el.id && !isResizing">
|
<template v-if="editorStore.selectedElementId === el.id && !isResizing">
|
||||||
|
<template v-if="el.type === 'barcode'">
|
||||||
|
<!-- Barkod: sadece yatay resize (aspect ratio korunur) -->
|
||||||
|
<div class="resize-handle resize-handle--e" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'e')" />
|
||||||
|
<div class="resize-handle resize-handle--w" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'w')" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
<div class="resize-handle resize-handle--se" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'se')" />
|
<div class="resize-handle resize-handle--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--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--ne" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'ne')" />
|
||||||
<div class="resize-handle resize-handle--nw" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'nw')" />
|
<div class="resize-handle resize-handle--nw" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'nw')" />
|
||||||
</template>
|
</template>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Drag ghost (mevcut eleman sürükleme) -->
|
<!-- Drag ghost (mevcut eleman sürükleme) -->
|
||||||
@@ -627,6 +691,8 @@ const isAnyDragActive = computed(() =>
|
|||||||
.resize-handle--sw { left: -3px; bottom: -3px; cursor: sw-resize; }
|
.resize-handle--sw { left: -3px; bottom: -3px; cursor: sw-resize; }
|
||||||
.resize-handle--ne { right: -3px; top: -3px; cursor: ne-resize; }
|
.resize-handle--ne { right: -3px; top: -3px; cursor: ne-resize; }
|
||||||
.resize-handle--nw { left: -3px; top: -3px; cursor: nw-resize; }
|
.resize-handle--nw { left: -3px; top: -3px; cursor: nw-resize; }
|
||||||
|
.resize-handle--e { right: -3px; top: calc(50% - 3px); cursor: e-resize; }
|
||||||
|
.resize-handle--w { left: -3px; top: calc(50% - 3px); cursor: w-resize; }
|
||||||
|
|
||||||
/* Drag ghost */
|
/* Drag ghost */
|
||||||
.drag-ghost {
|
.drag-ghost {
|
||||||
|
|||||||
@@ -1,19 +1,28 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useTemplateStore } from '../../stores/template'
|
import { useTemplateStore } from '../../stores/template'
|
||||||
import { useEditorStore } from '../../stores/editor'
|
import { useEditorStore } from '../../stores/editor'
|
||||||
import { isContainer } from '../../core/types'
|
import { useSchemaStore } from '../../stores/schema'
|
||||||
|
import { isContainer, sz } from '../../core/types'
|
||||||
|
import { schemaFormatToFormatType, defaultAlignForSchema } from '../../core/schema-parser'
|
||||||
import type {
|
import type {
|
||||||
TemplateElement,
|
TemplateElement,
|
||||||
ContainerElement,
|
ContainerElement,
|
||||||
StaticTextElement,
|
StaticTextElement,
|
||||||
LineElement,
|
LineElement,
|
||||||
|
RepeatingTableElement,
|
||||||
|
ImageElement,
|
||||||
|
PageNumberElement,
|
||||||
|
BarcodeElement,
|
||||||
|
TableColumn,
|
||||||
TextStyle,
|
TextStyle,
|
||||||
SizeValue,
|
SizeValue,
|
||||||
|
FormatType,
|
||||||
} from '../../core/types'
|
} from '../../core/types'
|
||||||
|
|
||||||
const templateStore = useTemplateStore()
|
const templateStore = useTemplateStore()
|
||||||
const editorStore = useEditorStore()
|
const editorStore = useEditorStore()
|
||||||
|
const schemaStore = useSchemaStore()
|
||||||
|
|
||||||
const selectedElement = computed(() => {
|
const selectedElement = computed(() => {
|
||||||
const id = editorStore.selectedElementId
|
const id = editorStore.selectedElementId
|
||||||
@@ -21,12 +30,6 @@ const selectedElement = computed(() => {
|
|||||||
return templateStore.getElementById(id) ?? null
|
return templateStore.getElementById(id) ?? null
|
||||||
})
|
})
|
||||||
|
|
||||||
const parentElement = computed(() => {
|
|
||||||
const id = editorStore.selectedElementId
|
|
||||||
if (!id) return null
|
|
||||||
return templateStore.getParent(id) ?? null
|
|
||||||
})
|
|
||||||
|
|
||||||
// --- Generic updater ---
|
// --- Generic updater ---
|
||||||
|
|
||||||
function update(updates: Partial<TemplateElement>) {
|
function update(updates: Partial<TemplateElement>) {
|
||||||
@@ -59,6 +62,188 @@ function togglePositioning() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Table helpers ---
|
||||||
|
|
||||||
|
let colIdCounter = Date.now()
|
||||||
|
function nextColId() {
|
||||||
|
return `col_${(++colIdCounter).toString(36)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTableDataSource(path: string) {
|
||||||
|
// Veri kaynağı değişince schema'dan sütunları otomatik doldur
|
||||||
|
const itemFields = schemaStore.getArrayItemFields(path)
|
||||||
|
if (itemFields.length > 0) {
|
||||||
|
const columns: TableColumn[] = itemFields.map(field => ({
|
||||||
|
id: nextColId(),
|
||||||
|
field: field.key,
|
||||||
|
title: field.title,
|
||||||
|
width: sz.auto(),
|
||||||
|
align: defaultAlignForSchema(field),
|
||||||
|
format: schemaFormatToFormatType(field.format),
|
||||||
|
}))
|
||||||
|
update({
|
||||||
|
dataSource: { type: 'array', path },
|
||||||
|
columns,
|
||||||
|
} as Partial<TemplateElement>)
|
||||||
|
} else {
|
||||||
|
update({ dataSource: { type: 'array', path } } as Partial<TemplateElement>)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTableStyle(key: string, value: unknown) {
|
||||||
|
const el = selectedElement.value as RepeatingTableElement
|
||||||
|
if (!el || el.type !== 'repeating_table') return
|
||||||
|
const newStyle = { ...el.style, [key]: value }
|
||||||
|
if (value === undefined || value === '') delete (newStyle as Record<string, unknown>)[key]
|
||||||
|
update({ style: newStyle } as Partial<TemplateElement>)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateColumn(colId: string, updates: Partial<TableColumn>) {
|
||||||
|
const el = selectedElement.value as RepeatingTableElement
|
||||||
|
if (!el || el.type !== 'repeating_table') return
|
||||||
|
const columns = el.columns.map(c => c.id === colId ? { ...c, ...updates } : c)
|
||||||
|
update({ columns } as Partial<TemplateElement>)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addColumn() {
|
||||||
|
const el = selectedElement.value as RepeatingTableElement
|
||||||
|
if (!el || el.type !== 'repeating_table') return
|
||||||
|
const newCol: TableColumn = {
|
||||||
|
id: nextColId(),
|
||||||
|
field: 'alan',
|
||||||
|
title: 'Yeni Sutun',
|
||||||
|
width: sz.auto(),
|
||||||
|
align: 'left',
|
||||||
|
}
|
||||||
|
update({ columns: [...el.columns, newCol] } as Partial<TemplateElement>)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeColumn(colId: string) {
|
||||||
|
const el = selectedElement.value as RepeatingTableElement
|
||||||
|
if (!el || el.type !== 'repeating_table') return
|
||||||
|
update({ columns: el.columns.filter(c => c.id !== colId) } as Partial<TemplateElement>)
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveColumn(colId: string, direction: -1 | 1) {
|
||||||
|
const el = selectedElement.value as RepeatingTableElement
|
||||||
|
if (!el || el.type !== 'repeating_table') return
|
||||||
|
const cols = [...el.columns]
|
||||||
|
const idx = cols.findIndex(c => c.id === colId)
|
||||||
|
const newIdx = idx + direction
|
||||||
|
if (newIdx < 0 || newIdx >= cols.length) return
|
||||||
|
;[cols[idx], cols[newIdx]] = [cols[newIdx], cols[idx]]
|
||||||
|
update({ columns: cols } as Partial<TemplateElement>)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seçili tablonun veri kaynağının item alanları (sütun field seçimi için) */
|
||||||
|
const tableItemFields = computed(() => {
|
||||||
|
const el = selectedElement.value
|
||||||
|
if (!el || el.type !== 'repeating_table') return []
|
||||||
|
return schemaStore.getArrayItemFields(el.dataSource.path)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Image ---
|
||||||
|
|
||||||
|
function onImageFileSelect(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
update({ src: reader.result as string } as Partial<TemplateElement>)
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Barcode ---
|
||||||
|
|
||||||
|
import type { BarcodeFormat } from '../../core/types'
|
||||||
|
|
||||||
|
const barcodeDefaults: Record<BarcodeFormat, string> = {
|
||||||
|
qr: 'https://example.com',
|
||||||
|
ean13: '5901234123457',
|
||||||
|
ean8: '96385074',
|
||||||
|
code128: 'DREPORT-001',
|
||||||
|
code39: 'DREPORT',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** EAN kontrol basamağı hesapla (12 veya 7 haneli data için) */
|
||||||
|
function eanCheckDigit(data: string): number {
|
||||||
|
let sum = 0
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
const d = parseInt(data[i])
|
||||||
|
// EAN ağırlıkları: 1, 3, 1, 3, ... (soldan sağa)
|
||||||
|
sum += d * (i % 2 === 0 ? 1 : 3)
|
||||||
|
}
|
||||||
|
return (10 - (sum % 10)) % 10
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateBarcode(format: BarcodeFormat, value: string): boolean {
|
||||||
|
if (!value) return false
|
||||||
|
switch (format) {
|
||||||
|
case 'ean13':
|
||||||
|
// Tam 13 haneli + geçerli kontrol basamağı
|
||||||
|
if (!/^\d{13}$/.test(value)) return false
|
||||||
|
return eanCheckDigit(value.slice(0, 12)) === parseInt(value[12])
|
||||||
|
case 'ean8':
|
||||||
|
// Tam 8 haneli + geçerli kontrol basamağı
|
||||||
|
if (!/^\d{8}$/.test(value)) return false
|
||||||
|
return eanCheckDigit(value.slice(0, 7)) === parseInt(value[7])
|
||||||
|
case 'code39':
|
||||||
|
return /^[A-Z0-9\-. $/+%]+$/i.test(value)
|
||||||
|
case 'code128':
|
||||||
|
return value.length > 0 && [...value].every(c => c.charCodeAt(0) < 128)
|
||||||
|
case 'qr':
|
||||||
|
return value.length > 0
|
||||||
|
default:
|
||||||
|
return value.length > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const barcodeInputValue = ref('')
|
||||||
|
const barcodeInputInvalid = ref(false)
|
||||||
|
|
||||||
|
// Seçili eleman değişince input'u senkronla
|
||||||
|
watch(() => {
|
||||||
|
const el = selectedElement.value
|
||||||
|
if (el?.type === 'barcode') return (el as BarcodeElement).value ?? ''
|
||||||
|
return ''
|
||||||
|
}, (val) => {
|
||||||
|
barcodeInputValue.value = val
|
||||||
|
barcodeInputInvalid.value = false
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
function onBarcodeValueInput(e: Event) {
|
||||||
|
const val = (e.target as HTMLInputElement).value
|
||||||
|
barcodeInputValue.value = val
|
||||||
|
const el = selectedElement.value as BarcodeElement
|
||||||
|
if (!el || el.type !== 'barcode') return
|
||||||
|
|
||||||
|
if (validateBarcode(el.format, val)) {
|
||||||
|
barcodeInputInvalid.value = false
|
||||||
|
update({ value: val } as any)
|
||||||
|
} else {
|
||||||
|
barcodeInputInvalid.value = true
|
||||||
|
// Template'i güncelleme — eski değer ile render devam eder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBarcodeFormatChange(newFormat: BarcodeFormat) {
|
||||||
|
const el = selectedElement.value as BarcodeElement
|
||||||
|
if (!el || el.type !== 'barcode') return
|
||||||
|
|
||||||
|
const currentValue = el.value ?? ''
|
||||||
|
if (validateBarcode(newFormat, currentValue)) {
|
||||||
|
update({ format: newFormat } as any)
|
||||||
|
} else {
|
||||||
|
// Değer yeni formata uymuyor → default değer ata
|
||||||
|
const defaultVal = barcodeDefaults[newFormat]
|
||||||
|
barcodeInputValue.value = defaultVal
|
||||||
|
barcodeInputInvalid.value = false
|
||||||
|
update({ format: newFormat, value: defaultVal } as any)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Delete ---
|
// --- Delete ---
|
||||||
|
|
||||||
function deleteElement() {
|
function deleteElement() {
|
||||||
@@ -79,7 +264,7 @@ function deleteElement() {
|
|||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="prop-section">
|
<div class="prop-section">
|
||||||
<div class="prop-section__title">
|
<div class="prop-section__title">
|
||||||
{{ selectedElement.type === 'container' ? 'Container' : selectedElement.type === 'static_text' ? 'Metin' : selectedElement.type === 'line' ? 'Çizgi' : 'Eleman' }}
|
{{ selectedElement.type === 'container' ? 'Container' : selectedElement.type === 'static_text' ? 'Metin' : selectedElement.type === 'line' ? 'Çizgi' : selectedElement.type === 'repeating_table' ? 'Tablo' : selectedElement.type === 'image' ? 'Gorsel' : selectedElement.type === 'page_number' ? 'Sayfa No' : 'Eleman' }}
|
||||||
<span class="prop-id">{{ selectedElement.id }}</span>
|
<span class="prop-id">{{ selectedElement.id }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -225,6 +410,127 @@ function deleteElement() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Image properties -->
|
||||||
|
<div v-if="selectedElement.type === 'image'" class="prop-section">
|
||||||
|
<div class="prop-section__title">Gorsel</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Kaynak</label>
|
||||||
|
<label class="prop-file-btn">
|
||||||
|
Dosya Sec
|
||||||
|
<input type="file" accept="image/*" style="display: none" @change="onImageFileSelect" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div v-if="(selectedElement as ImageElement).src" class="prop-row">
|
||||||
|
<label class="prop-label">Onizleme</label>
|
||||||
|
<img :src="(selectedElement as ImageElement).src" class="prop-image-preview" />
|
||||||
|
</div>
|
||||||
|
<div v-if="(selectedElement as ImageElement).src" class="prop-row">
|
||||||
|
<label class="prop-label"></label>
|
||||||
|
<button class="prop-clear" @click="update({ src: undefined } as any)">Gorseli kaldir</button>
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Sigdirma</label>
|
||||||
|
<select class="prop-input prop-select"
|
||||||
|
:value="(selectedElement as ImageElement).style.objectFit ?? 'contain'"
|
||||||
|
@change="(e) => updateStyle('objectFit', (e.target as HTMLSelectElement).value)">
|
||||||
|
<option value="contain">Sigdir</option>
|
||||||
|
<option value="cover">Kap</option>
|
||||||
|
<option value="stretch">Esnet</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Page number properties -->
|
||||||
|
<div v-if="selectedElement.type === 'page_number'" class="prop-section">
|
||||||
|
<div class="prop-section__title">Sayfa Numarasi</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Format</label>
|
||||||
|
<select class="prop-input prop-select"
|
||||||
|
:value="(selectedElement as PageNumberElement).format ?? '{current} / {total}'"
|
||||||
|
@change="(e) => update({ format: (e.target as HTMLSelectElement).value } as any)">
|
||||||
|
<option value="{current} / {total}">1 / 5</option>
|
||||||
|
<option value="{current}">1</option>
|
||||||
|
<option value="Sayfa {current}">Sayfa 1</option>
|
||||||
|
<option value="Sayfa {current} / {total}">Sayfa 1 / 5</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Boyut (pt)</label>
|
||||||
|
<input class="prop-input" type="number" step="1" min="1"
|
||||||
|
:value="(selectedElement.style as TextStyle).fontSize ?? 10"
|
||||||
|
@input="(e) => updateStyle('fontSize', parseFloat((e.target as HTMLInputElement).value) || 10)" />
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Renk</label>
|
||||||
|
<input class="prop-input prop-color" type="color"
|
||||||
|
:value="(selectedElement.style as TextStyle).color ?? '#666666'"
|
||||||
|
@input="(e) => updateStyle('color', (e.target as HTMLInputElement).value)" />
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Hizalama</label>
|
||||||
|
<select class="prop-input prop-select"
|
||||||
|
:value="(selectedElement.style as TextStyle).align ?? 'center'"
|
||||||
|
@change="(e) => updateStyle('align', (e.target as HTMLSelectElement).value)">
|
||||||
|
<option value="left">Sol</option>
|
||||||
|
<option value="center">Orta</option>
|
||||||
|
<option value="right">Sag</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Barcode properties -->
|
||||||
|
<div v-if="selectedElement.type === 'barcode'" class="prop-section">
|
||||||
|
<div class="prop-section__title">Barkod Ayarları</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Format</label>
|
||||||
|
<select class="prop-input prop-select"
|
||||||
|
:value="(selectedElement as BarcodeElement).format"
|
||||||
|
@change="(e) => onBarcodeFormatChange((e.target as HTMLSelectElement).value as BarcodeFormat)">
|
||||||
|
<option value="qr">QR Kod</option>
|
||||||
|
<option value="ean13">EAN-13</option>
|
||||||
|
<option value="ean8">EAN-8</option>
|
||||||
|
<option value="code128">Code 128</option>
|
||||||
|
<option value="code39">Code 39</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Deger</label>
|
||||||
|
<input class="prop-input" type="text"
|
||||||
|
:class="{ 'prop-input--invalid': barcodeInputInvalid }"
|
||||||
|
:value="barcodeInputValue"
|
||||||
|
@input="onBarcodeValueInput" />
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Renk</label>
|
||||||
|
<div class="prop-row-inline">
|
||||||
|
<input class="prop-input prop-color" type="color"
|
||||||
|
:value="(selectedElement as BarcodeElement).style.color ?? '#000000'"
|
||||||
|
@input="(e) => updateStyle('color', (e.target as HTMLInputElement).value)" />
|
||||||
|
<button v-if="(selectedElement as BarcodeElement).style.color" class="prop-clear" @click="updateStyle('color', undefined)">x</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="schemaStore.scalarFields.length > 0" class="prop-row">
|
||||||
|
<label class="prop-label">Veri Baglama</label>
|
||||||
|
<select class="prop-input prop-select"
|
||||||
|
:value="(selectedElement as BarcodeElement).binding?.path ?? ''"
|
||||||
|
@change="(e) => {
|
||||||
|
const val = (e.target as HTMLSelectElement).value
|
||||||
|
if (val) {
|
||||||
|
update({ binding: { type: 'scalar', path: val } } as any)
|
||||||
|
} else {
|
||||||
|
update({ binding: undefined } as any)
|
||||||
|
}
|
||||||
|
}">
|
||||||
|
<option value="">Yok (statik deger)</option>
|
||||||
|
<option
|
||||||
|
v-for="field in schemaStore.scalarFields"
|
||||||
|
:key="field.path"
|
||||||
|
:value="field.path"
|
||||||
|
>{{ field.title }} ({{ field.path }})</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Container properties -->
|
<!-- Container properties -->
|
||||||
<div v-if="isContainer(selectedElement)" class="prop-section">
|
<div v-if="isContainer(selectedElement)" class="prop-section">
|
||||||
<div class="prop-section__title">Container Ayarları</div>
|
<div class="prop-section__title">Container Ayarları</div>
|
||||||
@@ -244,16 +550,27 @@ function deleteElement() {
|
|||||||
@input="(e) => update({ gap: parseFloat((e.target as HTMLInputElement).value) || 0 } as any)" />
|
@input="(e) => update({ gap: parseFloat((e.target as HTMLInputElement).value) || 0 } as any)" />
|
||||||
</div>
|
</div>
|
||||||
<div class="prop-row">
|
<div class="prop-row">
|
||||||
<label class="prop-label">Hizalama</label>
|
<label class="prop-label">{{ (selectedElement as ContainerElement).direction === 'column' ? 'Yatay Hizalama' : 'Dikey Hizalama' }}</label>
|
||||||
<select class="prop-input prop-select"
|
<select class="prop-input prop-select"
|
||||||
:value="(selectedElement as ContainerElement).align"
|
:value="(selectedElement as ContainerElement).align"
|
||||||
@change="(e) => update({ align: (e.target as HTMLSelectElement).value } as any)">
|
@change="(e) => update({ align: (e.target as HTMLSelectElement).value } as any)">
|
||||||
<option value="start">Baş</option>
|
<option value="start">{{ (selectedElement as ContainerElement).direction === 'column' ? 'Sol' : 'Üst' }}</option>
|
||||||
<option value="center">Orta</option>
|
<option value="center">Orta</option>
|
||||||
<option value="end">Son</option>
|
<option value="end">{{ (selectedElement as ContainerElement).direction === 'column' ? 'Sag' : 'Alt' }}</option>
|
||||||
<option value="stretch">Esnet</option>
|
<option value="stretch">Esnet</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">{{ (selectedElement as ContainerElement).direction === 'column' ? 'Dikey Dagılım' : 'Yatay Dagılım' }}</label>
|
||||||
|
<select class="prop-input prop-select"
|
||||||
|
:value="(selectedElement as ContainerElement).justify"
|
||||||
|
@change="(e) => update({ justify: (e.target as HTMLSelectElement).value } as any)">
|
||||||
|
<option value="start">{{ (selectedElement as ContainerElement).direction === 'column' ? 'Üst' : 'Sol' }}</option>
|
||||||
|
<option value="center">Orta</option>
|
||||||
|
<option value="end">{{ (selectedElement as ContainerElement).direction === 'column' ? 'Alt' : 'Sag' }}</option>
|
||||||
|
<option value="space-between">Esit Aralık</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Padding -->
|
<!-- Padding -->
|
||||||
<div class="prop-section__subtitle">Padding (mm)</div>
|
<div class="prop-section__subtitle">Padding (mm)</div>
|
||||||
@@ -295,20 +612,31 @@ function deleteElement() {
|
|||||||
<button v-if="(selectedElement as ContainerElement).style.backgroundColor" class="prop-clear" @click="updateStyle('backgroundColor', undefined)">x</button>
|
<button v-if="(selectedElement as ContainerElement).style.backgroundColor" class="prop-clear" @click="updateStyle('backgroundColor', undefined)">x</button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="prop-row">
|
||||||
<label class="prop-label">Kenarlık (pt)</label>
|
<label class="prop-label">Kenarlık (pt)</label>
|
||||||
<input class="prop-input" type="number" step="0.5" min="0"
|
<input class="prop-input" type="number" step="0.5" min="0"
|
||||||
:value="(selectedElement as ContainerElement).style.borderWidth ?? 0"
|
:value="(selectedElement as ContainerElement).style.borderWidth ?? 0"
|
||||||
@input="(e) => updateStyle('borderWidth', parseFloat((e.target as HTMLInputElement).value) || 0)" />
|
@input="(e) => updateStyle('borderWidth', parseFloat((e.target as HTMLInputElement).value) || 0)" />
|
||||||
</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)" />
|
||||||
|
<button v-if="(selectedElement as ContainerElement).style.borderColor" class="prop-clear" @click="updateStyle('borderColor', undefined)">x</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Kenarlık stili</label>
|
||||||
|
<select class="prop-input prop-select"
|
||||||
|
:value="(selectedElement as ContainerElement).style.borderStyle ?? 'solid'"
|
||||||
|
@change="(e) => updateStyle('borderStyle', (e.target as HTMLSelectElement).value)">
|
||||||
|
<option value="solid">Düz</option>
|
||||||
|
<option value="dashed">Kesikli</option>
|
||||||
|
<option value="dotted">Noktalı</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="prop-row">
|
<div class="prop-row">
|
||||||
<label class="prop-label">Radius (pt)</label>
|
<label class="prop-label">Radius (pt)</label>
|
||||||
<input class="prop-input" type="number" step="1" min="0"
|
<input class="prop-input" type="number" step="1" min="0"
|
||||||
@@ -317,6 +645,159 @@ function deleteElement() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Repeating Table properties -->
|
||||||
|
<div v-if="selectedElement.type === 'repeating_table'" class="prop-section">
|
||||||
|
<div class="prop-section__title">Veri Kaynagi</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Kaynak</label>
|
||||||
|
<select class="prop-input prop-select"
|
||||||
|
:value="(selectedElement as RepeatingTableElement).dataSource.path"
|
||||||
|
@change="(e) => updateTableDataSource((e.target as HTMLSelectElement).value)">
|
||||||
|
<option value="" disabled>Secin...</option>
|
||||||
|
<option
|
||||||
|
v-for="arr in schemaStore.arrayFields"
|
||||||
|
:key="arr.path"
|
||||||
|
:value="arr.path"
|
||||||
|
>{{ arr.title }} ({{ arr.path }})</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedElement.type === 'repeating_table'" class="prop-section">
|
||||||
|
<div class="prop-section__title">
|
||||||
|
Sutunlar
|
||||||
|
<button class="prop-add-btn" @click="addColumn">+</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="col in (selectedElement as RepeatingTableElement).columns"
|
||||||
|
:key="col.id"
|
||||||
|
class="prop-column-card"
|
||||||
|
>
|
||||||
|
<div class="prop-column-header">
|
||||||
|
<span class="prop-column-title">{{ col.title || col.field }}</span>
|
||||||
|
<div class="prop-column-actions">
|
||||||
|
<button class="prop-icon-btn" @click="moveColumn(col.id, -1)" title="Yukari">↑</button>
|
||||||
|
<button class="prop-icon-btn" @click="moveColumn(col.id, 1)" title="Asagi">↓</button>
|
||||||
|
<button class="prop-icon-btn prop-icon-btn--danger" @click="removeColumn(col.id)" title="Sil">x</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Baslik</label>
|
||||||
|
<input class="prop-input" type="text" :value="col.title"
|
||||||
|
@change="(e) => updateColumn(col.id, { title: (e.target as HTMLInputElement).value })" />
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Alan</label>
|
||||||
|
<select v-if="tableItemFields.length > 0" class="prop-input prop-select" :value="col.field"
|
||||||
|
@change="(e) => {
|
||||||
|
const field = (e.target as HTMLSelectElement).value
|
||||||
|
const node = tableItemFields.find(f => f.key === field)
|
||||||
|
if (node) {
|
||||||
|
updateColumn(col.id, {
|
||||||
|
field,
|
||||||
|
title: node.title,
|
||||||
|
align: defaultAlignForSchema(node),
|
||||||
|
format: schemaFormatToFormatType(node.format),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
updateColumn(col.id, { field })
|
||||||
|
}
|
||||||
|
}">
|
||||||
|
<option v-for="f in tableItemFields" :key="f.key" :value="f.key">{{ f.title }} ({{ f.key }})</option>
|
||||||
|
</select>
|
||||||
|
<input v-else class="prop-input" type="text" :value="col.field"
|
||||||
|
@change="(e) => updateColumn(col.id, { field: (e.target as HTMLInputElement).value })" />
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Hizalama</label>
|
||||||
|
<select class="prop-input prop-select" :value="col.align"
|
||||||
|
@change="(e) => updateColumn(col.id, { align: (e.target as HTMLSelectElement).value as 'left'|'center'|'right' })">
|
||||||
|
<option value="left">Sol</option>
|
||||||
|
<option value="center">Orta</option>
|
||||||
|
<option value="right">Sag</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Format</label>
|
||||||
|
<select class="prop-input prop-select" :value="col.format ?? ''"
|
||||||
|
@change="(e) => updateColumn(col.id, { format: ((e.target as HTMLSelectElement).value || undefined) as FormatType | undefined })">
|
||||||
|
<option value="">Yok</option>
|
||||||
|
<option value="currency">Para birimi</option>
|
||||||
|
<option value="number">Sayi</option>
|
||||||
|
<option value="date">Tarih</option>
|
||||||
|
<option value="percentage">Yuzde</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Genislik</label>
|
||||||
|
<select class="prop-input prop-select"
|
||||||
|
:value="col.width.type"
|
||||||
|
@change="(e) => {
|
||||||
|
const t = (e.target as HTMLSelectElement).value
|
||||||
|
if (t === 'auto') updateColumn(col.id, { width: { type: 'auto' } })
|
||||||
|
else if (t === 'fr') updateColumn(col.id, { width: { type: 'fr', value: 1 } })
|
||||||
|
else updateColumn(col.id, { width: { type: 'fixed', value: 30 } })
|
||||||
|
}">
|
||||||
|
<option value="auto">Otomatik</option>
|
||||||
|
<option value="fixed">Sabit (mm)</option>
|
||||||
|
<option value="fr">Oran (fr)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div v-if="col.width.type === 'fixed'" class="prop-row">
|
||||||
|
<label class="prop-label">mm</label>
|
||||||
|
<input class="prop-input" type="number" step="1" min="5"
|
||||||
|
:value="(col.width as any).value"
|
||||||
|
@change="(e) => updateColumn(col.id, { width: { type: 'fixed', value: parseFloat((e.target as HTMLInputElement).value) || 30 } })" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedElement.type === 'repeating_table'" class="prop-section">
|
||||||
|
<div class="prop-section__title">Tablo Stili</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Yazi boyutu</label>
|
||||||
|
<input class="prop-input" type="number" step="1" min="6"
|
||||||
|
:value="(selectedElement as RepeatingTableElement).style.fontSize ?? 10"
|
||||||
|
@input="(e) => updateTableStyle('fontSize', parseFloat((e.target as HTMLInputElement).value) || 10)" />
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Header bg</label>
|
||||||
|
<input class="prop-input prop-color" type="color"
|
||||||
|
:value="(selectedElement as RepeatingTableElement).style.headerBg ?? '#f0f0f0'"
|
||||||
|
@input="(e) => updateTableStyle('headerBg', (e.target as HTMLInputElement).value)" />
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Header renk</label>
|
||||||
|
<input class="prop-input prop-color" type="color"
|
||||||
|
:value="(selectedElement as RepeatingTableElement).style.headerColor ?? '#000000'"
|
||||||
|
@input="(e) => updateTableStyle('headerColor', (e.target as HTMLInputElement).value)" />
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Zebra tek</label>
|
||||||
|
<div class="prop-row-inline">
|
||||||
|
<input class="prop-input prop-color" type="color"
|
||||||
|
:value="(selectedElement as RepeatingTableElement).style.zebraOdd ?? '#fafafa'"
|
||||||
|
@input="(e) => updateTableStyle('zebraOdd', (e.target as HTMLInputElement).value)" />
|
||||||
|
<button v-if="(selectedElement as RepeatingTableElement).style.zebraOdd" class="prop-clear" @click="updateTableStyle('zebraOdd', undefined)">x</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Kenarlık rengi</label>
|
||||||
|
<div class="prop-row-inline">
|
||||||
|
<input class="prop-input prop-color" type="color"
|
||||||
|
:value="(selectedElement as RepeatingTableElement).style.borderColor ?? '#cccccc'"
|
||||||
|
@input="(e) => updateTableStyle('borderColor', (e.target as HTMLInputElement).value)" />
|
||||||
|
<button v-if="(selectedElement as RepeatingTableElement).style.borderColor" class="prop-clear" @click="updateTableStyle('borderColor', undefined)">x</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label class="prop-label">Kenarlık (pt)</label>
|
||||||
|
<input class="prop-input" type="number" step="0.25" min="0"
|
||||||
|
:value="(selectedElement as RepeatingTableElement).style.borderWidth ?? 0.5"
|
||||||
|
@input="(e) => updateTableStyle('borderWidth', parseFloat((e.target as HTMLInputElement).value) || 0)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Delete -->
|
<!-- Delete -->
|
||||||
<div v-if="selectedElement.id !== 'root'" class="prop-section">
|
<div v-if="selectedElement.id !== 'root'" class="prop-section">
|
||||||
<button class="prop-delete-btn" @click="deleteElement">Sil</button>
|
<button class="prop-delete-btn" @click="deleteElement">Sil</button>
|
||||||
@@ -407,6 +888,16 @@ function deleteElement() {
|
|||||||
border-color: #93c5fd;
|
border-color: #93c5fd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prop-input--invalid {
|
||||||
|
border-color: #ef4444;
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-input--invalid:focus {
|
||||||
|
border-color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
.prop-select {
|
.prop-select {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -428,6 +919,29 @@ function deleteElement() {
|
|||||||
padding: 2px 5px;
|
padding: 2px 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prop-file-btn {
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #3b82f6;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-file-btn:hover {
|
||||||
|
background: #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-image-preview {
|
||||||
|
max-width: 80px;
|
||||||
|
max-height: 60px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 4px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
.prop-delete-btn {
|
.prop-delete-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
@@ -443,4 +957,72 @@ function deleteElement() {
|
|||||||
.prop-delete-btn:hover {
|
.prop-delete-btn:hover {
|
||||||
background: #fee2e2;
|
background: #fee2e2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prop-add-btn {
|
||||||
|
float: right;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #3b82f6;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
width: 22px;
|
||||||
|
height: 20px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-add-btn:hover {
|
||||||
|
background: #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-column-card {
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-column-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-column-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-column-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-icon-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
padding: 1px 4px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-icon-btn:hover {
|
||||||
|
background: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-icon-btn--danger:hover {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
border-color: #fecaca;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useEditorStore } from '../../stores/editor'
|
import { useEditorStore } from '../../stores/editor'
|
||||||
import type { TemplateElement } from '../../core/types'
|
import { useSchemaStore } from '../../stores/schema'
|
||||||
|
import type { TemplateElement, RepeatingTableElement, TableColumn, ImageElement, PageNumberElement, BarcodeElement } from '../../core/types'
|
||||||
import { sz } from '../../core/types'
|
import { sz } from '../../core/types'
|
||||||
|
import { schemaFormatToFormatType, defaultAlignForSchema } from '../../core/schema-parser'
|
||||||
|
|
||||||
const editorStore = useEditorStore()
|
const editorStore = useEditorStore()
|
||||||
|
const schemaStore = useSchemaStore()
|
||||||
|
|
||||||
let idCounter = Date.now()
|
let idCounter = Date.now()
|
||||||
function nextId(prefix: string) {
|
function nextId(prefix: string) {
|
||||||
@@ -57,6 +60,81 @@ const tools: ToolItem[] = [
|
|||||||
style: { strokeColor: '#000000', strokeWidth: 0.5 },
|
style: { strokeColor: '#000000', strokeWidth: 0.5 },
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Tablo',
|
||||||
|
icon: '▤',
|
||||||
|
create: (): RepeatingTableElement => {
|
||||||
|
// Schema'daki ilk array alanını bul ve sütunları otomatik doldur
|
||||||
|
const arrays = schemaStore.arrayFields
|
||||||
|
const firstArray = arrays[0]
|
||||||
|
let dataPath = ''
|
||||||
|
let columns: TableColumn[] = []
|
||||||
|
|
||||||
|
if (firstArray) {
|
||||||
|
dataPath = firstArray.path
|
||||||
|
const itemFields = schemaStore.getArrayItemFields(firstArray.path)
|
||||||
|
columns = itemFields.map(field => ({
|
||||||
|
id: nextId('col'),
|
||||||
|
field: field.key,
|
||||||
|
title: field.title,
|
||||||
|
width: sz.auto(),
|
||||||
|
align: defaultAlignForSchema(field),
|
||||||
|
format: schemaFormatToFormatType(field.format),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: nextId('tbl'),
|
||||||
|
type: 'repeating_table',
|
||||||
|
position: { type: 'flow' },
|
||||||
|
size: { width: sz.fr(1), height: sz.auto() },
|
||||||
|
dataSource: { type: 'array', path: dataPath },
|
||||||
|
columns,
|
||||||
|
style: {
|
||||||
|
headerBg: '#f0f0f0',
|
||||||
|
headerColor: '#000000',
|
||||||
|
fontSize: 10,
|
||||||
|
headerFontSize: 10,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Gorsel',
|
||||||
|
icon: '🖼',
|
||||||
|
create: (): ImageElement => ({
|
||||||
|
id: nextId('img'),
|
||||||
|
type: 'image',
|
||||||
|
position: { type: 'flow' },
|
||||||
|
size: { width: sz.fixed(40), height: sz.fixed(30) },
|
||||||
|
style: { objectFit: 'contain' },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Sayfa No',
|
||||||
|
icon: '#',
|
||||||
|
create: (): PageNumberElement => ({
|
||||||
|
id: nextId('pgn'),
|
||||||
|
type: 'page_number',
|
||||||
|
position: { type: 'flow' },
|
||||||
|
size: { width: sz.auto(), height: sz.auto() },
|
||||||
|
style: { fontSize: 10, color: '#666666', align: 'center' },
|
||||||
|
format: '{current} / {total}',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Barkod',
|
||||||
|
icon: '⣿',
|
||||||
|
create: (): BarcodeElement => ({
|
||||||
|
id: nextId('bc'),
|
||||||
|
type: 'barcode',
|
||||||
|
position: { type: 'flow' },
|
||||||
|
size: { width: sz.fixed(30), height: sz.auto() },
|
||||||
|
format: 'qr',
|
||||||
|
value: 'https://example.com',
|
||||||
|
style: {},
|
||||||
|
}),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
function onDragStart(e: DragEvent, tool: ToolItem) {
|
function onDragStart(e: DragEvent, tool: ToolItem) {
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { ref, watch, type Ref } from 'vue'
|
import { ref, watch, type Ref } from 'vue'
|
||||||
import type { ElementLayout } from '../core/template-to-typst'
|
import type { ElementLayout } from '../core/template-to-typst'
|
||||||
|
import type { Template } from '../core/types'
|
||||||
|
|
||||||
export function useTypstCompiler(markup: Ref<string>) {
|
export function useTypstCompiler(
|
||||||
|
template: Ref<Template>,
|
||||||
|
data: Ref<Record<string, unknown>>,
|
||||||
|
) {
|
||||||
const svg = ref<string | null>(null)
|
const svg = ref<string | null>(null)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const compiling = ref(false)
|
const compiling = ref(false)
|
||||||
@@ -45,22 +49,28 @@ export function useTypstCompiler(markup: Ref<string>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function compile(typstMarkup: string) {
|
function compile() {
|
||||||
if (!worker) initWorker()
|
if (!worker) initWorker()
|
||||||
requestId++
|
requestId++
|
||||||
compiling.value = true
|
compiling.value = true
|
||||||
worker!.postMessage({ type: 'compile', markup: typstMarkup, id: requestId })
|
worker!.postMessage({
|
||||||
|
type: 'compile',
|
||||||
|
templateJson: JSON.stringify(template.value),
|
||||||
|
dataJson: JSON.stringify(data.value),
|
||||||
|
id: requestId,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// template veya data değiştiğinde yeniden derle
|
||||||
watch(
|
watch(
|
||||||
markup,
|
[template, data],
|
||||||
(newMarkup) => {
|
() => {
|
||||||
if (debounceTimer) clearTimeout(debounceTimer)
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
debounceTimer = setTimeout(() => {
|
debounceTimer = setTimeout(() => {
|
||||||
compile(newMarkup)
|
compile()
|
||||||
}, 200)
|
}, 200)
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true, deep: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
function dispose() {
|
function dispose() {
|
||||||
@@ -74,7 +84,7 @@ export function useTypstCompiler(markup: Ref<string>) {
|
|||||||
error,
|
error,
|
||||||
compiling,
|
compiling,
|
||||||
layout,
|
layout,
|
||||||
compile: () => compile(markup.value),
|
compile,
|
||||||
dispose,
|
dispose,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
102
frontend/src/core/mock-data-generator.ts
Normal file
102
frontend/src/core/mock-data-generator.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import type { Template, TemplateElement, RepeatingTableElement } from './types'
|
||||||
|
import { isContainer } from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template'teki binding'lerden mock veri üretir.
|
||||||
|
* Scalar binding → placeholder metin, Array binding → 3 satır örnek veri.
|
||||||
|
*/
|
||||||
|
export function generateMockData(template: Template): Record<string, unknown> {
|
||||||
|
const data: Record<string, unknown> = {}
|
||||||
|
collectBindings(template.root, data)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectBindings(el: TemplateElement, data: Record<string, unknown>) {
|
||||||
|
if (el.type === 'text' && el.binding) {
|
||||||
|
setNestedValue(data, el.binding.path, mockScalarValue(el.binding.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el.type === 'barcode' && el.binding) {
|
||||||
|
setNestedValue(data, el.binding.path, mockScalarValue(el.binding.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el.type === 'repeating_table' && el.dataSource && el.dataSource.path) {
|
||||||
|
const rows = generateMockRows(el)
|
||||||
|
setNestedValue(data, el.dataSource.path, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isContainer(el)) {
|
||||||
|
for (const child of el.children) {
|
||||||
|
collectBindings(child, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateMockRows(el: RepeatingTableElement): Record<string, unknown>[] {
|
||||||
|
const rowCount = 3
|
||||||
|
const rows: Record<string, unknown>[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < rowCount; i++) {
|
||||||
|
const row: Record<string, unknown> = {}
|
||||||
|
for (const col of el.columns) {
|
||||||
|
row[col.field] = mockColumnValue(col.field, col.format, i)
|
||||||
|
}
|
||||||
|
rows.push(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockColumnValue(field: string, format: string | undefined, index: number): unknown {
|
||||||
|
if (format === 'currency') return [1500, 2750, 500][index % 3]
|
||||||
|
if (format === 'date') return ['2026-01-15', '2026-02-20', '2026-03-10'][index % 3]
|
||||||
|
if (format === 'percentage') return [18, 8, 20][index % 3]
|
||||||
|
if (format === 'number') return [1, 2, 3][index % 3]
|
||||||
|
|
||||||
|
// Alan adına göre tahmin
|
||||||
|
const lower = field.toLowerCase()
|
||||||
|
if (lower.includes('sira') || lower.includes('no') || lower === 'id') return index + 1
|
||||||
|
if (lower.includes('miktar') || lower.includes('adet')) return [2, 1, 5][index % 3]
|
||||||
|
if (lower.includes('fiyat') || lower.includes('tutar') || lower.includes('toplam')) return [1500, 2750, 500][index % 3]
|
||||||
|
if (lower.includes('birim')) return ['Adet', 'Saat', 'Adet'][index % 3]
|
||||||
|
if (lower.includes('tarih') || lower.includes('date')) return ['2026-01-15', '2026-02-20', '2026-03-10'][index % 3]
|
||||||
|
if (lower.includes('ad') || lower.includes('isim') || lower.includes('name')) return ['Kalem A', 'Kalem B', 'Kalem C'][index % 3]
|
||||||
|
|
||||||
|
return `Ornek ${index + 1}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockScalarValue(path: string): string {
|
||||||
|
const last = path.split('.').pop() ?? path
|
||||||
|
const lower = last.toLowerCase()
|
||||||
|
|
||||||
|
if (lower.includes('unvan') || lower.includes('firma')) return 'Ornek Firma A.S.'
|
||||||
|
if (lower.includes('vergi')) return '1234567890'
|
||||||
|
if (lower.includes('tarih') || lower.includes('date')) return '2026-03-29'
|
||||||
|
if (lower.includes('no') || lower.includes('numara')) return 'FTR-2026-001'
|
||||||
|
if (lower.includes('toplam') || lower.includes('tutar')) return '18.880,00'
|
||||||
|
if (lower.includes('adres')) return 'Ornek Mah. Test Sk. No:1'
|
||||||
|
if (lower.includes('tel') || lower.includes('phone')) return '0212 555 0000'
|
||||||
|
if (lower.includes('mail') || lower.includes('email')) return 'info@ornek.com'
|
||||||
|
|
||||||
|
return `[${path}]`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Noktalı yolu kullanarak nested objeye değer atar */
|
||||||
|
function setNestedValue(obj: Record<string, unknown>, path: string, value: unknown) {
|
||||||
|
const parts = path.split('.')
|
||||||
|
let current: Record<string, unknown> = obj
|
||||||
|
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
const key = parts[i]
|
||||||
|
if (!(key in current) || typeof current[key] !== 'object' || current[key] === null) {
|
||||||
|
current[key] = {}
|
||||||
|
}
|
||||||
|
current = current[key] as Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastKey = parts[parts.length - 1]
|
||||||
|
// Mevcut değeri override etme (ilk binding kazanır)
|
||||||
|
if (!(lastKey in current)) {
|
||||||
|
current[lastKey] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
103
frontend/src/core/schema-parser.ts
Normal file
103
frontend/src/core/schema-parser.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* JSON Schema'dan editör için kullanılabilir ağaç yapısı üretir.
|
||||||
|
* Schema'daki array alanlarını ve alt özelliklerini tespit eder.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SchemaNode {
|
||||||
|
path: string // Noktalı yol — ör: "firma.unvan"
|
||||||
|
key: string // Son segment — ör: "unvan"
|
||||||
|
title: string // Görüntüleme adı — schema'daki "title" veya key
|
||||||
|
type: 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean'
|
||||||
|
format?: string // "currency", "date", "percentage", "image" vs.
|
||||||
|
children: SchemaNode[]
|
||||||
|
/** Sadece array tipi için: array item'larının alt alanları */
|
||||||
|
itemProperties?: SchemaNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JsonSchema {
|
||||||
|
$id?: string
|
||||||
|
type: string
|
||||||
|
title?: string
|
||||||
|
format?: string
|
||||||
|
properties?: Record<string, JsonSchema>
|
||||||
|
items?: JsonSchema
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON Schema'yı SchemaNode ağacına dönüştürür */
|
||||||
|
export function parseSchema(schema: JsonSchema, path = '', key = 'root'): SchemaNode {
|
||||||
|
const title = schema.title ?? key
|
||||||
|
const type = schema.type as SchemaNode['type']
|
||||||
|
|
||||||
|
const node: SchemaNode = {
|
||||||
|
path,
|
||||||
|
key,
|
||||||
|
title,
|
||||||
|
type,
|
||||||
|
format: schema.format,
|
||||||
|
children: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema.type === 'object' && schema.properties) {
|
||||||
|
for (const [propKey, propSchema] of Object.entries(schema.properties)) {
|
||||||
|
const childPath = path ? `${path}.${propKey}` : propKey
|
||||||
|
node.children.push(parseSchema(propSchema, childPath, propKey))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema.type === 'array' && schema.items) {
|
||||||
|
// Array'in item schema'sını ayrıca parse et
|
||||||
|
if (schema.items.type === 'object' && schema.items.properties) {
|
||||||
|
const itemPath = path ? `${path}[]` : `${key}[]`
|
||||||
|
node.itemProperties = []
|
||||||
|
for (const [propKey, propSchema] of Object.entries(schema.items.properties)) {
|
||||||
|
node.itemProperties.push(parseSchema(propSchema, `${itemPath}.${propKey}`, propKey))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schema ağacından tüm array alanlarını bulur (tablo binding için) */
|
||||||
|
export function findArrayFields(node: SchemaNode): SchemaNode[] {
|
||||||
|
const result: SchemaNode[] = []
|
||||||
|
if (node.type === 'array') {
|
||||||
|
result.push(node)
|
||||||
|
}
|
||||||
|
for (const child of node.children) {
|
||||||
|
result.push(...findArrayFields(child))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schema ağacından tüm scalar alanları bulur (metin binding için) */
|
||||||
|
export function findScalarFields(node: SchemaNode): SchemaNode[] {
|
||||||
|
const result: SchemaNode[] = []
|
||||||
|
if (node.type === 'string' || node.type === 'number' || node.type === 'integer' || node.type === 'boolean') {
|
||||||
|
result.push(node)
|
||||||
|
}
|
||||||
|
for (const child of node.children) {
|
||||||
|
result.push(...findScalarFields(child))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format tipinden FormatType'a dönüşüm */
|
||||||
|
export function schemaFormatToFormatType(format?: string): 'currency' | 'date' | 'percentage' | 'number' | undefined {
|
||||||
|
if (!format) return undefined
|
||||||
|
switch (format) {
|
||||||
|
case 'currency': return 'currency'
|
||||||
|
case 'date': return 'date'
|
||||||
|
case 'percentage': return 'percentage'
|
||||||
|
default: return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bir SchemaNode'un tipinden varsayılan tablo hizalamasını belirle */
|
||||||
|
export function defaultAlignForSchema(node: SchemaNode): 'left' | 'center' | 'right' {
|
||||||
|
if (node.type === 'number' || node.type === 'integer') return 'right'
|
||||||
|
if (node.format === 'currency') return 'right'
|
||||||
|
if (node.format === 'percentage') return 'right'
|
||||||
|
if (node.format === 'date') return 'center'
|
||||||
|
return 'left'
|
||||||
|
}
|
||||||
@@ -1,378 +1,7 @@
|
|||||||
import type {
|
|
||||||
Template,
|
|
||||||
TemplateElement,
|
|
||||||
ContainerElement,
|
|
||||||
StaticTextElement,
|
|
||||||
TextElement,
|
|
||||||
LineElement,
|
|
||||||
TextStyle,
|
|
||||||
SizeValue,
|
|
||||||
SizeConstraint,
|
|
||||||
} from './types'
|
|
||||||
import { isContainer } from './types'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Template JSON → Typst markup dönüşümü.
|
* Layout data parsing — SVG'den element pozisyon bilgilerini çıkarır.
|
||||||
* Container-based layout + layout query (her element için pozisyon/boyut bilgisi).
|
* Template → Typst dönüşümü artık dreport-core WASM tarafından yapılır.
|
||||||
*/
|
*/
|
||||||
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 {
|
export interface ElementLayout {
|
||||||
x: number // pt
|
x: number // pt
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export interface ContainerStyle {
|
|||||||
borderColor?: string
|
borderColor?: string
|
||||||
borderWidth?: number // pt
|
borderWidth?: number // pt
|
||||||
borderRadius?: number // pt
|
borderRadius?: number // pt
|
||||||
|
borderStyle?: 'solid' | 'dashed' | 'dotted'
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Binding ---
|
// --- Binding ---
|
||||||
@@ -71,7 +72,44 @@ export interface ScalarBinding {
|
|||||||
path: string // ör: "firma.unvan"
|
path: string // ör: "firma.unvan"
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ElementBinding = ScalarBinding
|
export interface ArrayBinding {
|
||||||
|
type: 'array'
|
||||||
|
path: string // ör: "kalemler"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ElementBinding = ScalarBinding | ArrayBinding
|
||||||
|
|
||||||
|
// --- Tablo ---
|
||||||
|
|
||||||
|
export type FormatType = 'currency' | 'date' | 'percentage' | 'number'
|
||||||
|
|
||||||
|
export interface TableColumn {
|
||||||
|
id: string
|
||||||
|
field: string // array item içindeki alan — ör: "adi", "tutar"
|
||||||
|
title: string // Sütun başlığı
|
||||||
|
width: SizeValue
|
||||||
|
align: 'left' | 'center' | 'right'
|
||||||
|
format?: FormatType
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableStyle {
|
||||||
|
headerBg?: string // hex — header arka plan rengi
|
||||||
|
headerColor?: string // hex — header metin rengi
|
||||||
|
zebraOdd?: string // hex — tek satır arka plan
|
||||||
|
zebraEven?: string // hex — çift satır arka plan
|
||||||
|
borderColor?: string // hex
|
||||||
|
borderWidth?: number // pt
|
||||||
|
fontSize?: number // pt
|
||||||
|
headerFontSize?: number // pt
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Barcode ---
|
||||||
|
|
||||||
|
export type BarcodeFormat = 'qr' | 'ean13' | 'ean8' | 'code128' | 'code39'
|
||||||
|
|
||||||
|
export interface BarcodeStyle {
|
||||||
|
color?: string // ön plan rengi (varsayılan: siyah)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Element tipleri ---
|
// --- Element tipleri ---
|
||||||
|
|
||||||
@@ -99,6 +137,31 @@ export interface LineElement extends BaseElement {
|
|||||||
style: LineStyle
|
style: LineStyle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ImageStyle {
|
||||||
|
objectFit?: 'contain' | 'cover' | 'stretch'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageElement extends BaseElement {
|
||||||
|
type: 'image'
|
||||||
|
src?: string // statik görsel: data URI veya URL
|
||||||
|
binding?: ScalarBinding // dinamik görsel: schema'dan path
|
||||||
|
style: ImageStyle
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageNumberElement extends BaseElement {
|
||||||
|
type: 'page_number'
|
||||||
|
style: TextStyle
|
||||||
|
format?: string // ör: "{current} / {total}"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BarcodeElement extends BaseElement {
|
||||||
|
type: 'barcode'
|
||||||
|
format: BarcodeFormat
|
||||||
|
value?: string // statik değer
|
||||||
|
binding?: ScalarBinding // dinamik değer (schema'dan)
|
||||||
|
style: BarcodeStyle
|
||||||
|
}
|
||||||
|
|
||||||
export interface ContainerElement extends BaseElement {
|
export interface ContainerElement extends BaseElement {
|
||||||
type: 'container'
|
type: 'container'
|
||||||
direction: 'row' | 'column'
|
direction: 'row' | 'column'
|
||||||
@@ -110,7 +173,14 @@ export interface ContainerElement extends BaseElement {
|
|||||||
children: TemplateElement[]
|
children: TemplateElement[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LeafElement = StaticTextElement | TextElement | LineElement
|
export interface RepeatingTableElement extends BaseElement {
|
||||||
|
type: 'repeating_table'
|
||||||
|
dataSource: ArrayBinding
|
||||||
|
columns: TableColumn[]
|
||||||
|
style: TableStyle
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LeafElement = StaticTextElement | TextElement | LineElement | RepeatingTableElement | ImageElement | PageNumberElement | BarcodeElement
|
||||||
export type TemplateElement = LeafElement | ContainerElement
|
export type TemplateElement = LeafElement | ContainerElement
|
||||||
|
|
||||||
// --- Template ---
|
// --- Template ---
|
||||||
|
|||||||
48
frontend/src/core/wasm/dreport_core.d.ts
vendored
Normal file
48
frontend/src/core/wasm/dreport_core.d.ts
vendored
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template JSON + Data JSON → Typst markup (editör modu, layout query dahil)
|
||||||
|
*/
|
||||||
|
export function templateToTypstEditor(template_json: string, data_json: string): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template JSON + Data JSON → Typst markup (PDF modu, layout query yok)
|
||||||
|
*/
|
||||||
|
export function templateToTypstPdf(template_json: string, data_json: string): string;
|
||||||
|
|
||||||
|
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
|
||||||
|
|
||||||
|
export interface InitOutput {
|
||||||
|
readonly memory: WebAssembly.Memory;
|
||||||
|
readonly templateToTypstEditor: (a: number, b: number, c: number, d: number) => [number, number, number, number];
|
||||||
|
readonly templateToTypstPdf: (a: number, b: number, c: number, d: number) => [number, number, number, number];
|
||||||
|
readonly __wbindgen_externrefs: WebAssembly.Table;
|
||||||
|
readonly __wbindgen_malloc: (a: number, b: number) => number;
|
||||||
|
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
||||||
|
readonly __externref_table_dealloc: (a: number) => void;
|
||||||
|
readonly __wbindgen_free: (a: number, b: number, c: number) => void;
|
||||||
|
readonly __wbindgen_start: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SyncInitInput = BufferSource | WebAssembly.Module;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instantiates the given `module`, which can either be bytes or
|
||||||
|
* a precompiled `WebAssembly.Module`.
|
||||||
|
*
|
||||||
|
* @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated.
|
||||||
|
*
|
||||||
|
* @returns {InitOutput}
|
||||||
|
*/
|
||||||
|
export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If `module_or_path` is {RequestInfo} or {URL}, makes a request and
|
||||||
|
* for everything else, calls `WebAssembly.instantiate` directly.
|
||||||
|
*
|
||||||
|
* @param {{ module_or_path: InitInput | Promise<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated.
|
||||||
|
*
|
||||||
|
* @returns {Promise<InitOutput>}
|
||||||
|
*/
|
||||||
|
export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise<InitInput> } | InitInput | Promise<InitInput>): Promise<InitOutput>;
|
||||||
260
frontend/src/core/wasm/dreport_core.js
Normal file
260
frontend/src/core/wasm/dreport_core.js
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
/* @ts-self-types="./dreport_core.d.ts" */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template JSON + Data JSON → Typst markup (editör modu, layout query dahil)
|
||||||
|
* @param {string} template_json
|
||||||
|
* @param {string} data_json
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function templateToTypstEditor(template_json, data_json) {
|
||||||
|
let deferred4_0;
|
||||||
|
let deferred4_1;
|
||||||
|
try {
|
||||||
|
const ptr0 = passStringToWasm0(template_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||||
|
const len0 = WASM_VECTOR_LEN;
|
||||||
|
const ptr1 = passStringToWasm0(data_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||||
|
const len1 = WASM_VECTOR_LEN;
|
||||||
|
const ret = wasm.templateToTypstEditor(ptr0, len0, ptr1, len1);
|
||||||
|
var ptr3 = ret[0];
|
||||||
|
var len3 = ret[1];
|
||||||
|
if (ret[3]) {
|
||||||
|
ptr3 = 0; len3 = 0;
|
||||||
|
throw takeFromExternrefTable0(ret[2]);
|
||||||
|
}
|
||||||
|
deferred4_0 = ptr3;
|
||||||
|
deferred4_1 = len3;
|
||||||
|
return getStringFromWasm0(ptr3, len3);
|
||||||
|
} finally {
|
||||||
|
wasm.__wbindgen_free(deferred4_0, deferred4_1, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template JSON + Data JSON → Typst markup (PDF modu, layout query yok)
|
||||||
|
* @param {string} template_json
|
||||||
|
* @param {string} data_json
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function templateToTypstPdf(template_json, data_json) {
|
||||||
|
let deferred4_0;
|
||||||
|
let deferred4_1;
|
||||||
|
try {
|
||||||
|
const ptr0 = passStringToWasm0(template_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||||
|
const len0 = WASM_VECTOR_LEN;
|
||||||
|
const ptr1 = passStringToWasm0(data_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||||
|
const len1 = WASM_VECTOR_LEN;
|
||||||
|
const ret = wasm.templateToTypstPdf(ptr0, len0, ptr1, len1);
|
||||||
|
var ptr3 = ret[0];
|
||||||
|
var len3 = ret[1];
|
||||||
|
if (ret[3]) {
|
||||||
|
ptr3 = 0; len3 = 0;
|
||||||
|
throw takeFromExternrefTable0(ret[2]);
|
||||||
|
}
|
||||||
|
deferred4_0 = ptr3;
|
||||||
|
deferred4_1 = len3;
|
||||||
|
return getStringFromWasm0(ptr3, len3);
|
||||||
|
} finally {
|
||||||
|
wasm.__wbindgen_free(deferred4_0, deferred4_1, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function __wbg_get_imports() {
|
||||||
|
const import0 = {
|
||||||
|
__proto__: null,
|
||||||
|
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
|
||||||
|
// Cast intrinsic for `Ref(String) -> Externref`.
|
||||||
|
const ret = getStringFromWasm0(arg0, arg1);
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
__wbindgen_init_externref_table: function() {
|
||||||
|
const table = wasm.__wbindgen_externrefs;
|
||||||
|
const offset = table.grow(4);
|
||||||
|
table.set(0, undefined);
|
||||||
|
table.set(offset + 0, undefined);
|
||||||
|
table.set(offset + 1, null);
|
||||||
|
table.set(offset + 2, true);
|
||||||
|
table.set(offset + 3, false);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
__proto__: null,
|
||||||
|
"./dreport_core_bg.js": import0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStringFromWasm0(ptr, len) {
|
||||||
|
ptr = ptr >>> 0;
|
||||||
|
return decodeText(ptr, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedUint8ArrayMemory0 = null;
|
||||||
|
function getUint8ArrayMemory0() {
|
||||||
|
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
|
||||||
|
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
|
||||||
|
}
|
||||||
|
return cachedUint8ArrayMemory0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function passStringToWasm0(arg, malloc, realloc) {
|
||||||
|
if (realloc === undefined) {
|
||||||
|
const buf = cachedTextEncoder.encode(arg);
|
||||||
|
const ptr = malloc(buf.length, 1) >>> 0;
|
||||||
|
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
|
||||||
|
WASM_VECTOR_LEN = buf.length;
|
||||||
|
return ptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
let len = arg.length;
|
||||||
|
let ptr = malloc(len, 1) >>> 0;
|
||||||
|
|
||||||
|
const mem = getUint8ArrayMemory0();
|
||||||
|
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
for (; offset < len; offset++) {
|
||||||
|
const code = arg.charCodeAt(offset);
|
||||||
|
if (code > 0x7F) break;
|
||||||
|
mem[ptr + offset] = code;
|
||||||
|
}
|
||||||
|
if (offset !== len) {
|
||||||
|
if (offset !== 0) {
|
||||||
|
arg = arg.slice(offset);
|
||||||
|
}
|
||||||
|
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
|
||||||
|
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
|
||||||
|
const ret = cachedTextEncoder.encodeInto(arg, view);
|
||||||
|
|
||||||
|
offset += ret.written;
|
||||||
|
ptr = realloc(ptr, len, offset, 1) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
WASM_VECTOR_LEN = offset;
|
||||||
|
return ptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function takeFromExternrefTable0(idx) {
|
||||||
|
const value = wasm.__wbindgen_externrefs.get(idx);
|
||||||
|
wasm.__externref_table_dealloc(idx);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
||||||
|
cachedTextDecoder.decode();
|
||||||
|
const MAX_SAFARI_DECODE_BYTES = 2146435072;
|
||||||
|
let numBytesDecoded = 0;
|
||||||
|
function decodeText(ptr, len) {
|
||||||
|
numBytesDecoded += len;
|
||||||
|
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
|
||||||
|
cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
||||||
|
cachedTextDecoder.decode();
|
||||||
|
numBytesDecoded = len;
|
||||||
|
}
|
||||||
|
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedTextEncoder = new TextEncoder();
|
||||||
|
|
||||||
|
if (!('encodeInto' in cachedTextEncoder)) {
|
||||||
|
cachedTextEncoder.encodeInto = function (arg, view) {
|
||||||
|
const buf = cachedTextEncoder.encode(arg);
|
||||||
|
view.set(buf);
|
||||||
|
return {
|
||||||
|
read: arg.length,
|
||||||
|
written: buf.length
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let WASM_VECTOR_LEN = 0;
|
||||||
|
|
||||||
|
let wasmModule, wasm;
|
||||||
|
function __wbg_finalize_init(instance, module) {
|
||||||
|
wasm = instance.exports;
|
||||||
|
wasmModule = module;
|
||||||
|
cachedUint8ArrayMemory0 = null;
|
||||||
|
wasm.__wbindgen_start();
|
||||||
|
return wasm;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function __wbg_load(module, imports) {
|
||||||
|
if (typeof Response === 'function' && module instanceof Response) {
|
||||||
|
if (typeof WebAssembly.instantiateStreaming === 'function') {
|
||||||
|
try {
|
||||||
|
return await WebAssembly.instantiateStreaming(module, imports);
|
||||||
|
} catch (e) {
|
||||||
|
const validResponse = module.ok && expectedResponseType(module.type);
|
||||||
|
|
||||||
|
if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
|
||||||
|
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
|
||||||
|
|
||||||
|
} else { throw e; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = await module.arrayBuffer();
|
||||||
|
return await WebAssembly.instantiate(bytes, imports);
|
||||||
|
} else {
|
||||||
|
const instance = await WebAssembly.instantiate(module, imports);
|
||||||
|
|
||||||
|
if (instance instanceof WebAssembly.Instance) {
|
||||||
|
return { instance, module };
|
||||||
|
} else {
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectedResponseType(type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'basic': case 'cors': case 'default': return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSync(module) {
|
||||||
|
if (wasm !== undefined) return wasm;
|
||||||
|
|
||||||
|
|
||||||
|
if (module !== undefined) {
|
||||||
|
if (Object.getPrototypeOf(module) === Object.prototype) {
|
||||||
|
({module} = module)
|
||||||
|
} else {
|
||||||
|
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const imports = __wbg_get_imports();
|
||||||
|
if (!(module instanceof WebAssembly.Module)) {
|
||||||
|
module = new WebAssembly.Module(module);
|
||||||
|
}
|
||||||
|
const instance = new WebAssembly.Instance(module, imports);
|
||||||
|
return __wbg_finalize_init(instance, module);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function __wbg_init(module_or_path) {
|
||||||
|
if (wasm !== undefined) return wasm;
|
||||||
|
|
||||||
|
|
||||||
|
if (module_or_path !== undefined) {
|
||||||
|
if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
|
||||||
|
({module_or_path} = module_or_path)
|
||||||
|
} else {
|
||||||
|
console.warn('using deprecated parameters for the initialization function; pass a single object instead')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (module_or_path === undefined) {
|
||||||
|
module_or_path = new URL('dreport_core_bg.wasm', import.meta.url);
|
||||||
|
}
|
||||||
|
const imports = __wbg_get_imports();
|
||||||
|
|
||||||
|
if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
|
||||||
|
module_or_path = fetch(module_or_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { instance, module } = await __wbg_load(await module_or_path, imports);
|
||||||
|
|
||||||
|
return __wbg_finalize_init(instance, module);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { initSync, __wbg_init as default };
|
||||||
11
frontend/src/core/wasm/dreport_core_bg.wasm.d.ts
vendored
Normal file
11
frontend/src/core/wasm/dreport_core_bg.wasm.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
export const memory: WebAssembly.Memory;
|
||||||
|
export const templateToTypstEditor: (a: number, b: number, c: number, d: number) => [number, number, number, number];
|
||||||
|
export const templateToTypstPdf: (a: number, b: number, c: number, d: number) => [number, number, number, number];
|
||||||
|
export const __wbindgen_externrefs: WebAssembly.Table;
|
||||||
|
export const __wbindgen_malloc: (a: number, b: number) => number;
|
||||||
|
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
||||||
|
export const __externref_table_dealloc: (a: number) => void;
|
||||||
|
export const __wbindgen_free: (a: number, b: number, c: number) => void;
|
||||||
|
export const __wbindgen_start: () => void;
|
||||||
@@ -27,6 +27,16 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
zoom.value = Math.max(0.25, Math.min(4, value))
|
zoom.value = Math.max(0.25, Math.min(4, value))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setPan(x: number, y: number) {
|
||||||
|
panX.value = x
|
||||||
|
panY.value = y
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPan() {
|
||||||
|
panX.value = 0
|
||||||
|
panY.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
function setDragging(value: boolean) {
|
function setDragging(value: boolean) {
|
||||||
isDragging.value = value
|
isDragging.value = value
|
||||||
}
|
}
|
||||||
@@ -57,6 +67,8 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
selectElement,
|
selectElement,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
setZoom,
|
setZoom,
|
||||||
|
setPan,
|
||||||
|
resetPan,
|
||||||
setDragging,
|
setDragging,
|
||||||
startDragNewElement,
|
startDragNewElement,
|
||||||
setDropTargetContainer,
|
setDropTargetContainer,
|
||||||
|
|||||||
111
frontend/src/stores/schema.ts
Normal file
111
frontend/src/stores/schema.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import type { JsonSchema, SchemaNode } from '../core/schema-parser'
|
||||||
|
import { parseSchema, findArrayFields, findScalarFields } from '../core/schema-parser'
|
||||||
|
|
||||||
|
/** Varsayılan fatura schema'sı */
|
||||||
|
const defaultSchema: JsonSchema = {
|
||||||
|
$id: 'fatura-schema',
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
firma: {
|
||||||
|
type: 'object',
|
||||||
|
title: 'Firma',
|
||||||
|
properties: {
|
||||||
|
unvan: { type: 'string', title: 'Firma Unvani' },
|
||||||
|
vergiNo: { type: 'string', title: 'Vergi No' },
|
||||||
|
logo: { type: 'string', title: 'Logo', format: 'image' },
|
||||||
|
adres: { type: 'string', title: 'Adres' },
|
||||||
|
telefon: { type: 'string', title: 'Telefon' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fatura: {
|
||||||
|
type: 'object',
|
||||||
|
title: 'Fatura',
|
||||||
|
properties: {
|
||||||
|
no: { type: 'string', title: 'Fatura No' },
|
||||||
|
tarih: { type: 'string', title: 'Tarih', format: 'date' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
musteri: {
|
||||||
|
type: 'object',
|
||||||
|
title: 'Musteri',
|
||||||
|
properties: {
|
||||||
|
unvan: { type: 'string', title: 'Musteri Unvani' },
|
||||||
|
vergiNo: { type: 'string', title: 'Vergi No' },
|
||||||
|
adres: { type: 'string', title: 'Adres' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
kalemler: {
|
||||||
|
type: 'array',
|
||||||
|
title: 'Fatura Kalemleri',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
siraNo: { type: 'integer', title: 'Sira No' },
|
||||||
|
adi: { type: 'string', title: 'Urun / Hizmet Adi' },
|
||||||
|
miktar: { type: 'number', title: 'Miktar' },
|
||||||
|
birim: { type: 'string', title: 'Birim' },
|
||||||
|
birimFiyat: { type: 'number', title: 'Birim Fiyat', format: 'currency' },
|
||||||
|
tutar: { type: 'number', title: 'Tutar', format: 'currency' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
toplamlar: {
|
||||||
|
type: 'object',
|
||||||
|
title: 'Toplamlar',
|
||||||
|
properties: {
|
||||||
|
araToplam: { type: 'number', title: 'Ara Toplam', format: 'currency' },
|
||||||
|
kdv: { type: 'number', title: 'KDV', format: 'currency' },
|
||||||
|
genelToplam: { type: 'number', title: 'Genel Toplam', format: 'currency' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSchemaStore = defineStore('schema', () => {
|
||||||
|
const rawSchema = ref<JsonSchema>(defaultSchema)
|
||||||
|
|
||||||
|
const schemaTree = computed<SchemaNode>(() => parseSchema(rawSchema.value))
|
||||||
|
|
||||||
|
/** Tüm array alanları (tablo binding için) */
|
||||||
|
const arrayFields = computed(() => findArrayFields(schemaTree.value))
|
||||||
|
|
||||||
|
/** Tüm scalar alanlar (metin binding için) */
|
||||||
|
const scalarFields = computed(() => findScalarFields(schemaTree.value))
|
||||||
|
|
||||||
|
/** Schema'yı güncelle (ör: JSON import) */
|
||||||
|
function setSchema(schema: JsonSchema) {
|
||||||
|
rawSchema.value = schema
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Belirli bir path'teki SchemaNode'u bul */
|
||||||
|
function getNodeByPath(path: string): SchemaNode | undefined {
|
||||||
|
return findNode(schemaTree.value, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bir array alanının item property'lerini getir */
|
||||||
|
function getArrayItemFields(arrayPath: string): SchemaNode[] {
|
||||||
|
const node = findNode(schemaTree.value, arrayPath)
|
||||||
|
return node?.itemProperties ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rawSchema,
|
||||||
|
schemaTree,
|
||||||
|
arrayFields,
|
||||||
|
scalarFields,
|
||||||
|
setSchema,
|
||||||
|
getNodeByPath,
|
||||||
|
getArrayItemFields,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function findNode(node: SchemaNode, path: string): SchemaNode | undefined {
|
||||||
|
if (node.path === path) return node
|
||||||
|
for (const child of node.children) {
|
||||||
|
const found = findNode(child, path)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { defineStore } from 'pinia'
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import type { Template, TemplateElement, ContainerElement, SizeConstraint, PositionMode } from '../core/types'
|
import type { Template, TemplateElement, ContainerElement, SizeConstraint, PositionMode } from '../core/types'
|
||||||
import { findElementById, findParent, isContainer, sz } from '../core/types'
|
import { findElementById, findParent, isContainer, sz } from '../core/types'
|
||||||
import { templateToTypst } from '../core/template-to-typst'
|
import { generateMockData } from '../core/mock-data-generator'
|
||||||
import { useUndoRedo } from '../composables/useUndoRedo'
|
import { useUndoRedo } from '../composables/useUndoRedo'
|
||||||
|
|
||||||
function createDefaultTemplate(): Template {
|
function createDefaultTemplate(): Template {
|
||||||
@@ -47,7 +47,7 @@ function createDefaultTemplate(): Template {
|
|||||||
export const useTemplateStore = defineStore('template', () => {
|
export const useTemplateStore = defineStore('template', () => {
|
||||||
const template = ref<Template>(createDefaultTemplate())
|
const template = ref<Template>(createDefaultTemplate())
|
||||||
|
|
||||||
const typstMarkup = computed(() => templateToTypst(template.value))
|
const mockData = computed(() => generateMockData(template.value))
|
||||||
|
|
||||||
// Undo / Redo
|
// Undo / Redo
|
||||||
const { undo, redo, canUndo, canRedo } = useUndoRedo(template)
|
const { undo, redo, canUndo, canRedo } = useUndoRedo(template)
|
||||||
@@ -117,9 +117,25 @@ export const useTemplateStore = defineStore('template', () => {
|
|||||||
parent.children.splice(toIndex, 0, moved)
|
parent.children.splice(toIndex, 0, moved)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Şablonu JSON olarak dışa aktar */
|
||||||
|
function exportTemplate(): string {
|
||||||
|
return JSON.stringify(template.value, null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON'dan şablon yükle */
|
||||||
|
function importTemplate(json: string) {
|
||||||
|
const parsed = JSON.parse(json) as Template
|
||||||
|
template.value = parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Yeni boş şablon oluştur */
|
||||||
|
function resetTemplate() {
|
||||||
|
template.value = createDefaultTemplate()
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
template,
|
template,
|
||||||
typstMarkup,
|
mockData,
|
||||||
getElementById,
|
getElementById,
|
||||||
getParent,
|
getParent,
|
||||||
addChild,
|
addChild,
|
||||||
@@ -129,6 +145,9 @@ export const useTemplateStore = defineStore('template', () => {
|
|||||||
updateElementSize,
|
updateElementSize,
|
||||||
updateElement,
|
updateElement,
|
||||||
reorderChild,
|
reorderChild,
|
||||||
|
exportTemplate,
|
||||||
|
importTemplate,
|
||||||
|
resetTemplate,
|
||||||
undo,
|
undo,
|
||||||
redo,
|
redo,
|
||||||
canUndo,
|
canUndo,
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
/// Typst WASM Web Worker
|
/// Typst WASM Web Worker
|
||||||
/// Ana thread'i bloklamadan Typst markup → SVG derleme yapar.
|
/// Template JSON + Data JSON → (dreport-core WASM ile) Typst markup → (typst.ts WASM ile) SVG
|
||||||
|
|
||||||
import { $typst, TypstSnippet } from '@myriaddreamin/typst.ts/dist/esm/contrib/snippet.mjs'
|
import { $typst, TypstSnippet } from '@myriaddreamin/typst.ts/dist/esm/contrib/snippet.mjs'
|
||||||
|
import initCore, { templateToTypstEditor } from '../core/wasm/dreport_core.js'
|
||||||
|
|
||||||
let initialized = false
|
let typstInitialized = false
|
||||||
|
let coreInitialized = false
|
||||||
|
|
||||||
const FONT_FILES = [
|
const FONT_FILES = [
|
||||||
'/fonts/NotoSans-Regular.ttf',
|
'/fonts/NotoSans-Regular.ttf',
|
||||||
@@ -14,14 +16,19 @@ const FONT_FILES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
async function ensureInit() {
|
async function ensureInit() {
|
||||||
if (initialized) return
|
if (!coreInitialized) {
|
||||||
|
console.log('[typst-worker] dreport-core WASM başlatılıyor...')
|
||||||
|
await initCore({ module_or_path: '/wasm/dreport_core_bg.wasm' })
|
||||||
|
coreInitialized = true
|
||||||
|
console.log('[typst-worker] dreport-core WASM hazır')
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[typst-worker] Başlatılıyor...')
|
if (!typstInitialized) {
|
||||||
|
console.log('[typst-worker] Typst WASM 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)
|
const fontUrls = FONT_FILES.map(f => new URL(f, self.location.origin).href)
|
||||||
$typst.use(TypstSnippet.preloadFonts(fontUrls))
|
$typst.use(TypstSnippet.preloadFonts(fontUrls))
|
||||||
|
$typst.use(TypstSnippet.fetchPackageRegistry())
|
||||||
|
|
||||||
await $typst.setCompilerInitOptions({
|
await $typst.setCompilerInitOptions({
|
||||||
getModule: () =>
|
getModule: () =>
|
||||||
@@ -38,21 +45,47 @@ async function ensureInit() {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
initialized = true
|
typstInitialized = true
|
||||||
console.log('[typst-worker] Başlatma tamamlandı')
|
console.log('[typst-worker] Typst WASM hazır')
|
||||||
} catch (initErr) {
|
|
||||||
console.error('[typst-worker] Başlatma hatası:', initErr)
|
|
||||||
throw initErr
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.onmessage = async (e: MessageEvent<{ type: string; markup: string; id: number }>) => {
|
interface CompileMessage {
|
||||||
const { type, markup, id } = e.data
|
type: 'compile'
|
||||||
|
templateJson: string
|
||||||
|
dataJson: string
|
||||||
|
id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Geriye uyumluluk için eski markup tabanlı mesaj desteği
|
||||||
|
interface LegacyCompileMessage {
|
||||||
|
type: 'compile'
|
||||||
|
markup: string
|
||||||
|
id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkerMessage = CompileMessage | LegacyCompileMessage
|
||||||
|
|
||||||
|
self.onmessage = async (e: MessageEvent<WorkerMessage>) => {
|
||||||
|
const { type, id } = e.data
|
||||||
|
|
||||||
if (type === 'compile') {
|
if (type === 'compile') {
|
||||||
console.log(`[typst-worker] Derleme başladı (id: ${id})`)
|
console.log(`[typst-worker] Derleme başladı (id: ${id})`)
|
||||||
try {
|
try {
|
||||||
await ensureInit()
|
await ensureInit()
|
||||||
|
|
||||||
|
let markup: string
|
||||||
|
|
||||||
|
if ('templateJson' in e.data) {
|
||||||
|
// Yeni yol: Template JSON → Typst markup (dreport-core WASM)
|
||||||
|
markup = templateToTypstEditor(e.data.templateJson, e.data.dataJson)
|
||||||
|
console.log('[typst-worker] Generated Typst markup:\n', markup)
|
||||||
|
} else {
|
||||||
|
// Eski yol: doğrudan markup (geriye uyumluluk)
|
||||||
|
markup = (e.data as LegacyCompileMessage).markup
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typst markup → SVG
|
||||||
const svg = await $typst.svg({ mainContent: markup })
|
const svg = await $typst.svg({ mainContent: markup })
|
||||||
|
|
||||||
// SVG'den layout bilgisini parse et
|
// SVG'den layout bilgisini parse et
|
||||||
|
|||||||
22
justfile
Normal file
22
justfile
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# dreport justfile
|
||||||
|
|
||||||
|
# Frontend dev server
|
||||||
|
front:
|
||||||
|
cd frontend && bun run dev
|
||||||
|
|
||||||
|
# Backend dev server
|
||||||
|
back:
|
||||||
|
cargo run -p dreport-backend
|
||||||
|
|
||||||
|
# Frontend + Backend birlikte
|
||||||
|
dev:
|
||||||
|
just front & just back & wait
|
||||||
|
|
||||||
|
# WASM build (core -> frontend)
|
||||||
|
wasm:
|
||||||
|
wasm-pack build core --target web --release --out-dir ../frontend/src/core/wasm-pkg -- --features wasm
|
||||||
|
cp frontend/src/core/wasm-pkg/dreport_core.js frontend/src/core/wasm/dreport_core.js
|
||||||
|
cp frontend/src/core/wasm-pkg/dreport_core.d.ts frontend/src/core/wasm/dreport_core.d.ts
|
||||||
|
cp frontend/src/core/wasm-pkg/dreport_core_bg.wasm frontend/src/core/wasm/dreport_core_bg.wasm
|
||||||
|
cp frontend/src/core/wasm-pkg/dreport_core_bg.wasm.d.ts frontend/src/core/wasm/dreport_core_bg.wasm.d.ts
|
||||||
|
cp frontend/src/core/wasm/dreport_core_bg.wasm frontend/public/wasm/dreport_core_bg.wasm
|
||||||
Reference in New Issue
Block a user