mirror of
https://github.com/duhanbalci/dreport.git
synced 2026-07-02 02:49:16 +00:00
Compare commits
11 Commits
b6aecc5f12
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2db5929e39 | |||
| 92583141c9 | |||
| 58a59f2609 | |||
| aa27228d08 | |||
| 4fda0e7d98 | |||
| e574889e5d | |||
| 238e911875 | |||
| 09dc2b4ecd | |||
| 5ffc6d866c | |||
| 33f7556b03 | |||
| 603624724c |
@@ -17,7 +17,7 @@ jobs:
|
|||||||
targets: wasm32-unknown-unknown
|
targets: wasm32-unknown-unknown
|
||||||
components: rustfmt, clippy
|
components: rustfmt, clippy
|
||||||
- name: Format check
|
- name: Format check
|
||||||
run: cargo fmt --workspace --check
|
run: cargo fmt --all --check
|
||||||
- name: Clippy
|
- name: Clippy
|
||||||
run: cargo clippy --workspace -- -D warnings
|
run: cargo clippy --workspace -- -D warnings
|
||||||
- name: Test
|
- name: Test
|
||||||
@@ -28,6 +28,13 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: oven-sh/setup-bun@v2
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: wasm32-unknown-unknown
|
||||||
|
- name: Install wasm-pack
|
||||||
|
run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
|
||||||
|
- name: Build WASM
|
||||||
|
run: wasm-pack build layout-engine --target web --release --out-dir ../frontend/src/core/wasm-layout
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: cd frontend && bun install
|
run: cd frontend && bun install
|
||||||
- name: Type check
|
- name: Type check
|
||||||
|
|||||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -9,3 +9,17 @@ dist/
|
|||||||
frontend/tests/visual/cross-renderer-refs/
|
frontend/tests/visual/cross-renderer-refs/
|
||||||
frontend/tests/visual/cross-renderer-diffs/
|
frontend/tests/visual/cross-renderer-diffs/
|
||||||
frontend/tests/visual/test-results/
|
frontend/tests/visual/test-results/
|
||||||
|
|
||||||
|
# .NET build artifacts
|
||||||
|
**/bin/
|
||||||
|
**/obj/
|
||||||
|
|
||||||
|
# Native runtime binaries — produced by `just nuget-build-native-*`
|
||||||
|
# and packaged into the .nupkg. Never commit.
|
||||||
|
bindings/dotnet/src/Dreport.Service/runtimes/
|
||||||
|
|
||||||
|
# Auto-generated nuspec (regenerated by justfile recipes)
|
||||||
|
**/.generated.nuspec
|
||||||
|
|
||||||
|
# Generated C header (regenerated on every dreport-ffi build)
|
||||||
|
dreport-ffi/include/
|
||||||
|
|||||||
164
Cargo.lock
generated
164
Cargo.lock
generated
@@ -248,6 +248,24 @@ version = "1.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cbindgen"
|
||||||
|
version = "0.28.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eadd868a2ce9ca38de7eeafdcec9c7065ef89b42b32f0839278d55f35c54d1ff"
|
||||||
|
dependencies = [
|
||||||
|
"heck 0.4.1",
|
||||||
|
"indexmap",
|
||||||
|
"log",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"syn 2.0.117",
|
||||||
|
"tempfile",
|
||||||
|
"toml",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.2.58"
|
version = "1.2.58"
|
||||||
@@ -397,9 +415,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dexpr"
|
name = "dexpr"
|
||||||
version = "0.1.0"
|
version = "0.3.0"
|
||||||
source = "sparse+https://gitea.duhanbalci.com/api/packages/duhanbalci/cargo/"
|
source = "sparse+https://gitea.duhanbalci.com/api/packages/duhanbalci/cargo/"
|
||||||
checksum = "37e0a98f2810bb770c76ef1e99d07066a15997086f9ead93917a82711274af25"
|
checksum = "e65e74adffaab8b52681e3e3e5006365f0f8c5e3e07870cbd58ca74769eb150a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bumpalo",
|
"bumpalo",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
@@ -421,12 +439,12 @@ version = "0.2.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
"dreport-core",
|
"dreport-service",
|
||||||
"dreport-layout",
|
"http-body-util",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -439,6 +457,15 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dreport-ffi"
|
||||||
|
version = "0.2.0"
|
||||||
|
dependencies = [
|
||||||
|
"cbindgen",
|
||||||
|
"dreport-service",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dreport-layout"
|
name = "dreport-layout"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -460,6 +487,18 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dreport-service"
|
||||||
|
version = "0.2.0"
|
||||||
|
dependencies = [
|
||||||
|
"dreport-core",
|
||||||
|
"dreport-layout",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "encoding_rs"
|
name = "encoding_rs"
|
||||||
version = "0.8.35"
|
version = "0.8.35"
|
||||||
@@ -505,6 +544,12 @@ dependencies = [
|
|||||||
"regex-syntax",
|
"regex-syntax",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastrand"
|
||||||
|
version = "2.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fdeflate"
|
name = "fdeflate"
|
||||||
version = "0.3.7"
|
version = "0.3.7"
|
||||||
@@ -725,6 +770,12 @@ version = "0.16.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "heck"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -971,6 +1022,12 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
|
checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lock_api"
|
name = "lock_api"
|
||||||
version = "0.4.14"
|
version = "0.4.14"
|
||||||
@@ -1287,7 +1344,7 @@ version = "3.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"toml_edit",
|
"toml_edit 0.25.10+spec-1.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1541,6 +1598,19 @@ version = "2.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "1.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.0",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.22"
|
version = "1.0.22"
|
||||||
@@ -1681,6 +1751,15 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_spanned"
|
||||||
|
version = "0.6.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_urlencoded"
|
name = "serde_urlencoded"
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
@@ -1889,6 +1968,19 @@ version = "1.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tempfile"
|
||||||
|
version = "3.27.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"getrandom 0.4.2",
|
||||||
|
"once_cell",
|
||||||
|
"rustix",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.18"
|
version = "2.0.18"
|
||||||
@@ -1963,6 +2055,27 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml"
|
||||||
|
version = "0.8.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime 0.6.11",
|
||||||
|
"toml_edit 0.22.27",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_datetime"
|
||||||
|
version = "0.6.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_datetime"
|
name = "toml_datetime"
|
||||||
version = "1.1.1+spec-1.1.0"
|
version = "1.1.1+spec-1.1.0"
|
||||||
@@ -1972,6 +2085,20 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_edit"
|
||||||
|
version = "0.22.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"serde",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime 0.6.11",
|
||||||
|
"toml_write",
|
||||||
|
"winnow 0.7.15",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_edit"
|
name = "toml_edit"
|
||||||
version = "0.25.10+spec-1.1.0"
|
version = "0.25.10+spec-1.1.0"
|
||||||
@@ -1979,9 +2106,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b"
|
checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"toml_datetime",
|
"toml_datetime 1.1.1+spec-1.1.0",
|
||||||
"toml_parser",
|
"toml_parser",
|
||||||
"winnow",
|
"winnow 1.0.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1990,9 +2117,15 @@ version = "1.1.2+spec-1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
|
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"winnow",
|
"winnow 1.0.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_write"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -2328,6 +2461,15 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "0.7.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winnow"
|
name = "winnow"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
@@ -2353,7 +2495,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"heck",
|
"heck 0.5.0",
|
||||||
"wit-parser",
|
"wit-parser",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2364,7 +2506,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"heck",
|
"heck 0.5.0",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"prettyplease",
|
"prettyplease",
|
||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = ["core", "backend", "layout-engine"]
|
members = ["core", "backend", "layout-engine", "dreport-service", "dreport-ffi"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|||||||
@@ -657,7 +657,7 @@ pub fn load_test_fonts() -> Vec<FontData> { ... }
|
|||||||
|
|
||||||
## 7. Yeni Ozellik Onerileri
|
## 7. Yeni Ozellik Onerileri
|
||||||
|
|
||||||
### 7.1 Conditional Rendering `[IMPLEMENTE EDILMEDI]`
|
### 7.1 Conditional Rendering `[IMPLEMENTE EDILDI]`
|
||||||
|
|
||||||
**Aciklama:**
|
**Aciklama:**
|
||||||
Template'te `v-if` benzeri kosullu gosterim. Data'daki bir alana gore eleman goster/gizle.
|
Template'te `v-if` benzeri kosullu gosterim. Data'daki bir alana gore eleman goster/gizle.
|
||||||
@@ -714,7 +714,7 @@ Tablo disinda array verisiyle tekrarlayan serbest-form container. Ornegin bir ka
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 7.5 Coklu Dil / Lokalizasyon Destegi `[IMPLEMENTE EDILMEDI]`
|
### 7.5 Coklu Dil / Lokalizasyon Destegi `[IMPLEMENTE EDILDI]`
|
||||||
|
|
||||||
**Aciklama:**
|
**Aciklama:**
|
||||||
Currency, date ve sayi formatlama icin lokalizasyon. Su an Turk lokali hardcoded.
|
Currency, date ve sayi formatlama icin lokalizasyon. Su an Turk lokali hardcoded.
|
||||||
@@ -745,7 +745,7 @@ Template'te header/footer tanimi icin `condition` alani:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 7.7 QR Code Eleman Tipi `[IMPLEMENTE EDILMEDI]`
|
### 7.7 QR Code Eleman Tipi `[bu var zaten, barcode özelliklerinden barkod tipi seçilebiliyor qr olarak]`
|
||||||
|
|
||||||
**Mevcut Durum:**
|
**Mevcut Durum:**
|
||||||
`rxing` crate'i barcode uretimi icin zaten kullaniliyor ve QR Code destegi var. Ancak UI tarafinda ayri bir QR Code eleman tipi tanimlanmamis.
|
`rxing` crate'i barcode uretimi icin zaten kullaniliyor ve QR Code destegi var. Ancak UI tarafinda ayri bir QR Code eleman tipi tanimlanmamis.
|
||||||
@@ -773,7 +773,7 @@ Hazir sablon galerisi — kullanici sifirdan tasarlamak yerine bir sablon secip
|
|||||||
|
|
||||||
## 8. Kucuk Ama Degerli Iyilestirmeler
|
## 8. Kucuk Ama Degerli Iyilestirmeler
|
||||||
|
|
||||||
### 8.1 Chart Legend Tek Seri Durumu `[IMPLEMENTE EDILMEDI]`
|
### 8.1 Chart Legend Tek Seri Durumu `[IMPLEMENTE EDILDI]`
|
||||||
|
|
||||||
**Dosya:** `layout-engine/src/chart_render.rs`
|
**Dosya:** `layout-engine/src/chart_render.rs`
|
||||||
|
|
||||||
@@ -781,7 +781,7 @@ Hazir sablon galerisi — kullanici sifirdan tasarlamak yerine bir sablon secip
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 8.2 Pie Chart Label Kontrolu `[IMPLEMENTE EDILMEDI]`
|
### 8.2 Pie Chart Label Kontrolu `[IMPLEMENTE EDILDI]`
|
||||||
|
|
||||||
**Dosya:** `layout-engine/src/chart_render.rs` (satirlar 521-551)
|
**Dosya:** `layout-engine/src/chart_render.rs` (satirlar 521-551)
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ edition = "2024"
|
|||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
dreport-core = { path = "../core" }
|
dreport-service = { path = "../dreport-service" }
|
||||||
dreport-layout = { path = "../layout-engine" }
|
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tower-http = { version = "0.6", features = ["cors"] }
|
tower-http = { version = "0.6", features = ["cors"] }
|
||||||
thiserror = "2"
|
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tower = { version = "0.5", features = ["util"] }
|
||||||
|
http-body-util = "0.1"
|
||||||
|
|||||||
18
backend/src/app.rs
Normal file
18
backend/src/app.rs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
//! Application bootstrap. Builds a fully-configured `DreportService` for the
|
||||||
|
//! HTTP layer (and tests) to share.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use dreport_service::DreportService;
|
||||||
|
|
||||||
|
/// Construct the service used by the running server. Loads embedded fonts
|
||||||
|
/// (compile-time defaults) and any extra fonts in `DREPORT_FONTS_DIR`.
|
||||||
|
pub fn build_service() -> Result<DreportService> {
|
||||||
|
let svc = DreportService::new();
|
||||||
|
if let Ok(dir) = std::env::var("DREPORT_FONTS_DIR") {
|
||||||
|
match svc.register_fonts_directory(&dir) {
|
||||||
|
Ok(n) => println!("DREPORT_FONTS_DIR'den {} font yüklendi: {}", n, dir),
|
||||||
|
Err(e) => eprintln!("DREPORT_FONTS_DIR yüklenemedi ({}): {}", dir, e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(svc)
|
||||||
|
}
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use dreport_layout::FontData;
|
|
||||||
use dreport_layout::font_meta::{self, FontFamilyInfo, FontVariantKey};
|
|
||||||
use dreport_layout::font_provider::FontProvider;
|
|
||||||
|
|
||||||
/// Font registry — manages all available fonts from embedded defaults + external directory.
|
|
||||||
pub struct FontRegistry {
|
|
||||||
/// family_lower -> variant_key -> FontData
|
|
||||||
families: HashMap<String, HashMap<FontVariantKey, FontData>>,
|
|
||||||
/// Original-case family names
|
|
||||||
family_names: HashMap<String, String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FontRegistry {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
let mut registry = Self {
|
|
||||||
families: HashMap::new(),
|
|
||||||
family_names: HashMap::new(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Load embedded default fonts
|
|
||||||
registry.load_embedded_defaults();
|
|
||||||
|
|
||||||
// Load fonts from DREPORT_FONTS_DIR if set
|
|
||||||
if let Ok(dir) = std::env::var("DREPORT_FONTS_DIR") {
|
|
||||||
registry.load_from_directory(&dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
registry
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_embedded_defaults(&mut self) {
|
|
||||||
let embedded: &[(&str, &[u8])] = &[
|
|
||||||
("NotoSans-Regular", include_bytes!("../fonts/NotoSans-Regular.ttf")),
|
|
||||||
("NotoSans-Bold", include_bytes!("../fonts/NotoSans-Bold.ttf")),
|
|
||||||
("NotoSans-Italic", include_bytes!("../fonts/NotoSans-Italic.ttf")),
|
|
||||||
("NotoSans-BoldItalic", include_bytes!("../fonts/NotoSans-BoldItalic.ttf")),
|
|
||||||
("NotoSansMono-Regular", include_bytes!("../fonts/NotoSansMono-Regular.ttf")),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (_name, data) in embedded {
|
|
||||||
self.register_font(data.to_vec());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_from_directory(&mut self, dir: &str) {
|
|
||||||
let path = std::path::Path::new(dir);
|
|
||||||
if !path.is_dir() {
|
|
||||||
eprintln!("DREPORT_FONTS_DIR dizini bulunamadı: {}", dir);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let entries = match std::fs::read_dir(path) {
|
|
||||||
Ok(e) => e,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("DREPORT_FONTS_DIR okunamadı: {}", e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let p = entry.path();
|
|
||||||
if p.extension().is_some_and(|e| e == "ttf" || e == "otf") {
|
|
||||||
if let Ok(data) = std::fs::read(&p) {
|
|
||||||
if self.register_font(data) {
|
|
||||||
println!(" Font yüklendi: {}", p.display());
|
|
||||||
} else {
|
|
||||||
eprintln!(" Font parse edilemedi: {}", p.display());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Register a font from raw bytes. Returns true if successful.
|
|
||||||
fn register_font(&mut self, data: Vec<u8>) -> bool {
|
|
||||||
let Some(meta) = font_meta::parse_font_meta(&data) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
let family_lower = meta.family.to_lowercase();
|
|
||||||
let variant_key = meta.variant_key();
|
|
||||||
|
|
||||||
self.family_names
|
|
||||||
.entry(family_lower.clone())
|
|
||||||
.or_insert_with(|| meta.family.clone());
|
|
||||||
|
|
||||||
let font_data = FontData::new(meta.family, meta.weight, meta.italic, data);
|
|
||||||
|
|
||||||
self.families
|
|
||||||
.entry(family_lower)
|
|
||||||
.or_default()
|
|
||||||
.insert(variant_key, font_data);
|
|
||||||
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a specific font's raw bytes
|
|
||||||
pub fn get_font_bytes(&self, family: &str, weight: u16, italic: bool) -> Option<&[u8]> {
|
|
||||||
let family_lower = family.to_lowercase();
|
|
||||||
let key = FontVariantKey { weight, italic };
|
|
||||||
self.families
|
|
||||||
.get(&family_lower)
|
|
||||||
.and_then(|variants| variants.get(&key))
|
|
||||||
.map(|fd| fd.data.as_slice())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get all FontData for given family names (for passing to layout engine)
|
|
||||||
pub fn fonts_for_families(&self, families: &[String]) -> Vec<FontData> {
|
|
||||||
let mut result = Vec::new();
|
|
||||||
let mut loaded = std::collections::HashSet::new();
|
|
||||||
|
|
||||||
// Always include default family
|
|
||||||
let default_lower = "noto sans".to_string();
|
|
||||||
let mut to_load: Vec<String> = vec![default_lower.clone()];
|
|
||||||
for f in families {
|
|
||||||
let fl = f.to_lowercase();
|
|
||||||
if !to_load.contains(&fl) {
|
|
||||||
to_load.push(fl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for family_lower in &to_load {
|
|
||||||
if loaded.contains(family_lower) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Some(variants) = self.families.get(family_lower) {
|
|
||||||
for fd in variants.values() {
|
|
||||||
result.push(fd.clone());
|
|
||||||
}
|
|
||||||
loaded.insert(family_lower.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FontProvider for FontRegistry {
|
|
||||||
fn list_families(&self) -> Vec<FontFamilyInfo> {
|
|
||||||
self.families
|
|
||||||
.iter()
|
|
||||||
.map(|(family_lower, variants)| {
|
|
||||||
let family = self.family_names
|
|
||||||
.get(family_lower)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| family_lower.clone());
|
|
||||||
FontFamilyInfo {
|
|
||||||
family,
|
|
||||||
variants: variants.keys().cloned().collect(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_font(&self, family: &str, weight: u16, italic: bool) -> Option<FontData> {
|
|
||||||
let family_lower = family.to_lowercase();
|
|
||||||
let key = FontVariantKey { weight, italic };
|
|
||||||
self.families
|
|
||||||
.get(&family_lower)
|
|
||||||
.and_then(|variants| variants.get(&key))
|
|
||||||
.cloned()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
28
backend/src/lib.rs
Normal file
28
backend/src/lib.rs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
//! dreport-backend
|
||||||
|
//!
|
||||||
|
//! Thin Axum HTTP adapter on top of `dreport-service`. The HTTP layer holds
|
||||||
|
//! no business logic — it only translates JSON requests into service calls
|
||||||
|
//! and maps `ServiceError` into HTTP status codes.
|
||||||
|
|
||||||
|
pub mod app;
|
||||||
|
mod routes;
|
||||||
|
|
||||||
|
use axum::Router;
|
||||||
|
use dreport_service::DreportService;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
|
|
||||||
|
pub use routes::AppState;
|
||||||
|
|
||||||
|
/// Build the full Axum `Router` with CORS, state and all `/api/*` endpoints.
|
||||||
|
pub fn build_router(service: Arc<DreportService>) -> Router {
|
||||||
|
let cors = CorsLayer::new()
|
||||||
|
.allow_origin(Any)
|
||||||
|
.allow_methods(Any)
|
||||||
|
.allow_headers(Any);
|
||||||
|
|
||||||
|
Router::new()
|
||||||
|
.merge(routes::router())
|
||||||
|
.layer(cors)
|
||||||
|
.with_state(service)
|
||||||
|
}
|
||||||
@@ -1,35 +1,19 @@
|
|||||||
use axum::{Router, serve};
|
use dreport_backend::{app, build_router};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tower_http::cors::{Any, CorsLayer};
|
|
||||||
|
|
||||||
mod font_registry;
|
|
||||||
mod models;
|
|
||||||
mod routes;
|
|
||||||
|
|
||||||
use font_registry::FontRegistry;
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
println!("Font registry başlatılıyor...");
|
let service = Arc::new(app::build_service()?);
|
||||||
let registry = Arc::new(FontRegistry::new());
|
println!(
|
||||||
|
"dreport-service hazır ({} font ailesi)",
|
||||||
let family_count = dreport_layout::font_provider::FontProvider::list_families(registry.as_ref()).len();
|
service.font_family_count()
|
||||||
println!("Font registry hazır ({} font ailesi)", family_count);
|
);
|
||||||
|
|
||||||
let cors = CorsLayer::new()
|
|
||||||
.allow_origin(Any)
|
|
||||||
.allow_methods(Any)
|
|
||||||
.allow_headers(Any);
|
|
||||||
|
|
||||||
let app = Router::new()
|
|
||||||
.merge(routes::router())
|
|
||||||
.layer(cors)
|
|
||||||
.with_state(registry);
|
|
||||||
|
|
||||||
|
let app = build_router(service);
|
||||||
let listener = TcpListener::bind("0.0.0.0:3001").await?;
|
let listener = TcpListener::bind("0.0.0.0:3001").await?;
|
||||||
println!("dreport backend listening on http://localhost:3001");
|
println!("dreport backend listening on http://localhost:3001");
|
||||||
serve(listener, app).await?;
|
axum::serve(listener, app).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
pub use dreport_core::models::*;
|
|
||||||
@@ -1,16 +1,13 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
Json, Router,
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::{StatusCode, header},
|
http::{StatusCode, header},
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
routing::get,
|
routing::get,
|
||||||
Json,
|
|
||||||
};
|
};
|
||||||
use dreport_layout::font_provider::FontProvider;
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use crate::font_registry::FontRegistry;
|
use super::AppState;
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct FontFamilyResponse {
|
struct FontFamilyResponse {
|
||||||
@@ -25,15 +22,14 @@ struct FontVariantResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/fonts — list all available font families
|
/// GET /api/fonts — list all available font families
|
||||||
async fn list_fonts(
|
async fn list_fonts(State(service): State<AppState>) -> Json<Vec<FontFamilyResponse>> {
|
||||||
State(registry): State<Arc<FontRegistry>>,
|
let response: Vec<FontFamilyResponse> = service
|
||||||
) -> Json<Vec<FontFamilyResponse>> {
|
.list_font_families()
|
||||||
let families = registry.list_families();
|
|
||||||
let response: Vec<FontFamilyResponse> = families
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|f| FontFamilyResponse {
|
.map(|f| FontFamilyResponse {
|
||||||
family: f.family,
|
family: f.family,
|
||||||
variants: f.variants
|
variants: f
|
||||||
|
.variants
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|v| FontVariantResponse {
|
.map(|v| FontVariantResponse {
|
||||||
weight: v.weight,
|
weight: v.weight,
|
||||||
@@ -47,16 +43,16 @@ async fn list_fonts(
|
|||||||
|
|
||||||
/// GET /api/fonts/:family/:weight/:italic — serve font binary
|
/// GET /api/fonts/:family/:weight/:italic — serve font binary
|
||||||
async fn get_font(
|
async fn get_font(
|
||||||
State(registry): State<Arc<FontRegistry>>,
|
State(service): State<AppState>,
|
||||||
Path((family, weight, italic)): Path<(String, u16, String)>,
|
Path((family, weight, italic)): Path<(String, u16, String)>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let is_italic = italic == "true" || italic == "1";
|
let is_italic = italic == "true" || italic == "1";
|
||||||
|
|
||||||
match registry.get_font_bytes(&family, weight, is_italic) {
|
match service.get_font_bytes(&family, weight, is_italic) {
|
||||||
Some(data) => (
|
Some(data) => (
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
[(header::CONTENT_TYPE, "font/ttf")],
|
[(header::CONTENT_TYPE, "font/ttf")],
|
||||||
data.to_vec(),
|
data,
|
||||||
)
|
)
|
||||||
.into_response(),
|
.into_response(),
|
||||||
None => (
|
None => (
|
||||||
@@ -70,7 +66,7 @@ async fn get_font(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router() -> Router<Arc<FontRegistry>> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/fonts", get(list_fonts))
|
.route("/api/fonts", get(list_fonts))
|
||||||
.route("/api/fonts/{family}/{weight}/{italic}", get(get_font))
|
.route("/api/fonts/{family}/{weight}/{italic}", get(get_font))
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
use axum::{Router, routing::get, Json};
|
use axum::{Json, Router, routing::get};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use crate::font_registry::FontRegistry;
|
use super::AppState;
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct HealthResponse {
|
struct HealthResponse {
|
||||||
@@ -17,6 +16,6 @@ async fn health() -> Json<HealthResponse> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router() -> Router<Arc<FontRegistry>> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new().route("/api/health", get(health))
|
Router::new().route("/api/health", get(health))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ mod health;
|
|||||||
mod render;
|
mod render;
|
||||||
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
use dreport_service::DreportService;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::font_registry::FontRegistry;
|
pub type AppState = Arc<DreportService>;
|
||||||
|
|
||||||
pub fn router() -> Router<Arc<FontRegistry>> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.merge(health::router())
|
.merge(health::router())
|
||||||
.merge(render::router())
|
.merge(render::router())
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
Json, Router,
|
||||||
extract::State,
|
extract::State,
|
||||||
http::{StatusCode, header},
|
http::{StatusCode, header},
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
routing::post,
|
routing::post,
|
||||||
Json,
|
|
||||||
};
|
};
|
||||||
|
use dreport_service::{ServiceError, Template};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use crate::font_registry::FontRegistry;
|
use super::AppState;
|
||||||
use crate::models::Template;
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct RenderRequest {
|
pub struct RenderRequest {
|
||||||
@@ -20,17 +18,12 @@ pub struct RenderRequest {
|
|||||||
|
|
||||||
/// POST /api/render — Template + Data → PDF
|
/// POST /api/render — Template + Data → PDF
|
||||||
pub async fn render(
|
pub async fn render(
|
||||||
State(registry): State<Arc<FontRegistry>>,
|
State(service): State<AppState>,
|
||||||
Json(payload): Json<RenderRequest>,
|
Json(payload): Json<RenderRequest>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
// CPU-intensive layout + PDF render'ı blocking thread'de çalıştır
|
// CPU-intensive layout + PDF render'ı blocking thread'de çalıştır
|
||||||
let result = tokio::task::spawn_blocking(move || {
|
let result =
|
||||||
// Template'in fonts alanına göre sadece gerekli fontları yükle
|
tokio::task::spawn_blocking(move || service.render_pdf(&payload.template, &payload.data))
|
||||||
let fonts = registry.fonts_for_families(&payload.template.fonts);
|
|
||||||
let layout = dreport_layout::compute_layout(&payload.template, &payload.data, &fonts)
|
|
||||||
.map_err(|e| format!("Layout error: {}", e))?;
|
|
||||||
dreport_layout::pdf_render::render_pdf(&layout, &fonts)
|
|
||||||
})
|
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
@@ -40,11 +33,7 @@ pub async fn render(
|
|||||||
pdf_bytes,
|
pdf_bytes,
|
||||||
)
|
)
|
||||||
.into_response(),
|
.into_response(),
|
||||||
Ok(Err(err)) => (
|
Ok(Err(err)) => (status_for(&err), err.to_string()).into_response(),
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
format!("PDF render hatası: {}", err),
|
|
||||||
)
|
|
||||||
.into_response(),
|
|
||||||
Err(err) => (
|
Err(err) => (
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
format!("Task hatası: {}", err),
|
format!("Task hatası: {}", err),
|
||||||
@@ -53,6 +42,15 @@ pub async fn render(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router() -> Router<Arc<FontRegistry>> {
|
fn status_for(err: &ServiceError) -> StatusCode {
|
||||||
|
match err {
|
||||||
|
ServiceError::InvalidTemplateJson(_) | ServiceError::InvalidDataJson(_) => {
|
||||||
|
StatusCode::BAD_REQUEST
|
||||||
|
}
|
||||||
|
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new().route("/api/render", post(render))
|
Router::new().route("/api/render", post(render))
|
||||||
}
|
}
|
||||||
|
|||||||
179
backend/tests/api.rs
Normal file
179
backend/tests/api.rs
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
//! End-to-end HTTP tests for the backend. Drives the real `Router` via
|
||||||
|
//! `tower::ServiceExt::oneshot`, so anything covered here protects the
|
||||||
|
//! contract that the editor and external clients rely on.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
http::{Request, StatusCode, header},
|
||||||
|
};
|
||||||
|
use dreport_backend::build_router;
|
||||||
|
use dreport_service::DreportService;
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
const TEMPLATE: &str = r#"{
|
||||||
|
"id": "test",
|
||||||
|
"name": "Test",
|
||||||
|
"page": { "width": 210, "height": 297 },
|
||||||
|
"fonts": ["Noto Sans"],
|
||||||
|
"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": "title",
|
||||||
|
"type": "static_text",
|
||||||
|
"position": { "type": "flow" },
|
||||||
|
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
|
||||||
|
"style": { "fontSize": 14, "fontWeight": "bold" },
|
||||||
|
"content": "Hello"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
fn router() -> axum::Router {
|
||||||
|
build_router(Arc::new(DreportService::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn body_bytes(resp: axum::response::Response) -> Vec<u8> {
|
||||||
|
resp.into_body().collect().await.unwrap().to_bytes().to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn health_returns_ok() {
|
||||||
|
let resp = router()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/health")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = body_bytes(resp).await;
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(json["status"], "ok");
|
||||||
|
assert!(json["version"].is_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_fonts_includes_noto_sans() {
|
||||||
|
let resp = router()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/fonts")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = body_bytes(resp).await;
|
||||||
|
let families: Vec<serde_json::Value> = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert!(
|
||||||
|
families
|
||||||
|
.iter()
|
||||||
|
.any(|f| f["family"].as_str().unwrap_or("").to_lowercase().contains("noto")),
|
||||||
|
"Noto Sans family should be listed: {:?}",
|
||||||
|
families
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_font_bytes_for_known_variant() {
|
||||||
|
let resp = router()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/fonts/Noto%20Sans/400/false")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
resp.headers()
|
||||||
|
.get(header::CONTENT_TYPE)
|
||||||
|
.map(|v| v.to_str().unwrap()),
|
||||||
|
Some("font/ttf")
|
||||||
|
);
|
||||||
|
let body = body_bytes(resp).await;
|
||||||
|
assert!(body.len() > 1000, "TTF body should be substantial");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_font_unknown_returns_404() {
|
||||||
|
let resp = router()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/fonts/DoesNotExist/400/false")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn render_returns_pdf_bytes() {
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"template": serde_json::from_str::<serde_json::Value>(TEMPLATE).unwrap(),
|
||||||
|
"data": {}
|
||||||
|
});
|
||||||
|
let resp = router()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/render")
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(serde_json::to_vec(&payload).unwrap()))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
resp.headers()
|
||||||
|
.get(header::CONTENT_TYPE)
|
||||||
|
.map(|v| v.to_str().unwrap()),
|
||||||
|
Some("application/pdf")
|
||||||
|
);
|
||||||
|
let body = body_bytes(resp).await;
|
||||||
|
assert!(body.starts_with(b"%PDF-"), "PDF magic header missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn render_with_invalid_template_field_returns_4xx_or_500() {
|
||||||
|
// Axum's Json extractor rejects malformed payloads with 4xx; a structurally
|
||||||
|
// valid but semantically invalid template would surface as 500. Either is
|
||||||
|
// acceptable, but the server must not panic and must produce a body.
|
||||||
|
let payload = serde_json::json!({ "template": "not an object", "data": {} });
|
||||||
|
let resp = router()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/render")
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(serde_json::to_vec(&payload).unwrap()))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
resp.status().is_client_error() || resp.status().is_server_error(),
|
||||||
|
"got unexpected status {}",
|
||||||
|
resp.status()
|
||||||
|
);
|
||||||
|
}
|
||||||
10
bindings/dotnet/Dreport.Service.slnx
Normal file
10
bindings/dotnet/Dreport.Service.slnx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<Solution>
|
||||||
|
<Folder Name="/src/">
|
||||||
|
<Project Path="src/Dreport.AspNetCore/Dreport.AspNetCore.csproj" />
|
||||||
|
<Project Path="src/Dreport.Service/Dreport.Service.csproj" />
|
||||||
|
</Folder>
|
||||||
|
<Folder Name="/tests/">
|
||||||
|
<Project Path="tests/Dreport.AspNetCore.Tests/Dreport.AspNetCore.Tests.csproj" />
|
||||||
|
<Project Path="tests/Dreport.Service.Tests/Dreport.Service.Tests.csproj" />
|
||||||
|
</Folder>
|
||||||
|
</Solution>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
|
<ProjectReference Include="..\Dreport.Service\Dreport.Service.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Dreport.Service;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Dreport.AspNetCore;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional sugar for hosts that just want the editor's stock HTTP API.
|
||||||
|
/// Mirrors the original Rust/Axum backend endpoint contract 1:1, so the Vue
|
||||||
|
/// editor frontend does not need any code changes.
|
||||||
|
///
|
||||||
|
/// Skip this entirely if you prefer to wire endpoints by hand — the
|
||||||
|
/// <see cref="LayoutEngine"/> registered by <c>AddDreport()</c> is fully
|
||||||
|
/// usable from your own controllers / minimal API handlers.
|
||||||
|
/// </summary>
|
||||||
|
public static class DreportEndpointRouteBuilderExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Mount the dreport HTTP API under the given prefix (defaults to <c>/api</c>).
|
||||||
|
/// Routes added:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description><c>GET {prefix}/health</c></description></item>
|
||||||
|
/// <item><description><c>POST {prefix}/render</c> — body <c>{ template, data }</c> → <c>application/pdf</c></description></item>
|
||||||
|
/// <item><description><c>POST {prefix}/layout</c> — body <c>{ template, data }</c> → LayoutResult JSON</description></item>
|
||||||
|
/// <item><description><c>GET {prefix}/fonts</c> — registered families</description></item>
|
||||||
|
/// <item><description><c>GET {prefix}/fonts/{family}/{weight}/{italic}</c> — raw font bytes</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public static IEndpointRouteBuilder MapDreportEndpoints(
|
||||||
|
this IEndpointRouteBuilder builder,
|
||||||
|
string prefix = "/api")
|
||||||
|
{
|
||||||
|
var p = prefix.TrimEnd('/');
|
||||||
|
|
||||||
|
builder.MapGet($"{p}/health", () => Results.Json(new { status = "ok", version = typeof(LayoutEngine).Assembly.GetName().Version?.ToString() ?? "unknown" }));
|
||||||
|
|
||||||
|
builder.MapPost($"{p}/render", async (HttpContext ctx, LayoutEngine engine) =>
|
||||||
|
{
|
||||||
|
var (template, data) = await ReadBodyAsync(ctx);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pdf = await Task.Run(() => engine.RenderPdf(template, data));
|
||||||
|
return Results.File(pdf, "application/pdf");
|
||||||
|
}
|
||||||
|
catch (DreportException ex)
|
||||||
|
{
|
||||||
|
return MapError(ex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.MapPost($"{p}/layout", async (HttpContext ctx, LayoutEngine engine) =>
|
||||||
|
{
|
||||||
|
var (template, data) = await ReadBodyAsync(ctx);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var layoutJson = await Task.Run(() => engine.ComputeLayout(template, data));
|
||||||
|
return Results.Content(layoutJson, "application/json");
|
||||||
|
}
|
||||||
|
catch (DreportException ex)
|
||||||
|
{
|
||||||
|
return MapError(ex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.MapGet($"{p}/fonts", (LayoutEngine engine) =>
|
||||||
|
Results.Json(engine.ListFontFamilies().Select(f => new
|
||||||
|
{
|
||||||
|
family = f.Family,
|
||||||
|
variants = f.Variants.Select(v => new { weight = v.Weight, italic = v.Italic }),
|
||||||
|
})));
|
||||||
|
|
||||||
|
builder.MapGet($"{p}/fonts/{{family}}/{{weight}}/{{italic}}",
|
||||||
|
(string family, ushort weight, string italic, LayoutEngine engine) =>
|
||||||
|
{
|
||||||
|
var isItalic = italic.Equals("true", StringComparison.OrdinalIgnoreCase) || italic == "1";
|
||||||
|
var bytes = engine.GetFontBytes(family, weight, isItalic);
|
||||||
|
return bytes is null
|
||||||
|
? Results.NotFound($"Font bulunamadı: {family} weight={weight} italic={isItalic}")
|
||||||
|
: Results.File(bytes, "font/ttf");
|
||||||
|
});
|
||||||
|
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(string Template, string Data)> ReadBodyAsync(HttpContext ctx)
|
||||||
|
{
|
||||||
|
using var doc = await JsonDocument.ParseAsync(ctx.Request.Body);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
var template = root.GetProperty("template").GetRawText();
|
||||||
|
var data = root.TryGetProperty("data", out var d) ? d.GetRawText() : "{}";
|
||||||
|
return (template, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult MapError(DreportException ex) => ex switch
|
||||||
|
{
|
||||||
|
InvalidTemplateException or Dreport.Service.InvalidDataException => Results.BadRequest(ex.Message),
|
||||||
|
_ => Results.Problem(ex.Message, statusCode: StatusCodes.Status500InternalServerError),
|
||||||
|
};
|
||||||
|
}
|
||||||
20
bindings/dotnet/src/Dreport.AspNetCore/DreportOptions.cs
Normal file
20
bindings/dotnet/src/Dreport.AspNetCore/DreportOptions.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
namespace Dreport.AspNetCore;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration for the dreport ASP.NET Core integration.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DreportOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Optional directory whose <c>.ttf</c> / <c>.otf</c> files are loaded into the
|
||||||
|
/// engine on startup, in addition to the embedded default fonts.
|
||||||
|
/// </summary>
|
||||||
|
public string? FontsDirectory { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When <c>true</c> (default), embedded default fonts (Noto Sans, Noto Sans Mono)
|
||||||
|
/// are registered. Set to <c>false</c> to start with an empty registry — useful
|
||||||
|
/// when the host wants to provide a fully custom font set.
|
||||||
|
/// </summary>
|
||||||
|
public bool LoadEmbeddedFonts { get; set; } = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using Dreport.Service;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Dreport.AspNetCore;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DI registration for <see cref="LayoutEngine"/>. Registers the engine as a
|
||||||
|
/// process-wide singleton so consumers can inject it into controllers,
|
||||||
|
/// endpoint handlers, background services, or test fixtures.
|
||||||
|
/// </summary>
|
||||||
|
public static class DreportServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a singleton <see cref="LayoutEngine"/>. Once added, you can:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description>Inject <see cref="LayoutEngine"/> into your own MVC controllers, minimal API handlers, or background services.</description></item>
|
||||||
|
/// <item><description>Call <c>app.MapDreportEndpoints()</c> to also mount the ready-made HTTP API the editor talks to.</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddDreport(
|
||||||
|
this IServiceCollection services,
|
||||||
|
Action<DreportOptions>? configure = null)
|
||||||
|
{
|
||||||
|
var options = new DreportOptions();
|
||||||
|
configure?.Invoke(options);
|
||||||
|
|
||||||
|
services.AddSingleton(options);
|
||||||
|
services.AddSingleton(_ => CreateEngine(options));
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LayoutEngine CreateEngine(DreportOptions options)
|
||||||
|
{
|
||||||
|
var engine = options.LoadEmbeddedFonts ? new LayoutEngine() : LayoutEngine.CreateEmpty();
|
||||||
|
if (!string.IsNullOrEmpty(options.FontsDirectory) && Directory.Exists(options.FontsDirectory))
|
||||||
|
{
|
||||||
|
engine.RegisterFontsDirectory(options.FontsDirectory);
|
||||||
|
}
|
||||||
|
return engine;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
bindings/dotnet/src/Dreport.Service/Dreport.Service.csproj
Normal file
38
bindings/dotnet/src/Dreport.Service/Dreport.Service.csproj
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="Dreport.Service.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
|
||||||
|
<!-- Packaging is driven from a hand-rolled .nuspec next to this csproj
|
||||||
|
(see pack.nuspec). MSBuild's pack pipeline silently drops the runtimes/
|
||||||
|
folder under several scenarios we hit during development; hand-feeding
|
||||||
|
nuget pack a nuspec sidesteps the issue and is what the just recipe uses. -->
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- Local-dev consumer copy: drop the host RID native binary next to the
|
||||||
|
referencing assembly so xUnit / dotnet run can dlopen it without going
|
||||||
|
through a published NuGet package. -->
|
||||||
|
<PropertyGroup>
|
||||||
|
<_DrHostExt Condition="$([MSBuild]::IsOSPlatform('OSX'))">.dylib</_DrHostExt>
|
||||||
|
<_DrHostExt Condition="$([MSBuild]::IsOSPlatform('Linux'))">.so</_DrHostExt>
|
||||||
|
<_DrHostExt Condition="$([MSBuild]::IsOSPlatform('Windows'))">.dll</_DrHostExt>
|
||||||
|
<_DrHostPrefix Condition="!$([MSBuild]::IsOSPlatform('Windows'))">lib</_DrHostPrefix>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="$(MSBuildThisFileDirectory)runtimes/**/native/$(_DrHostPrefix)dreport_ffi$(_DrHostExt)"
|
||||||
|
Link="$(_DrHostPrefix)dreport_ffi$(_DrHostExt)"
|
||||||
|
CopyToOutputDirectory="PreserveNewest"
|
||||||
|
Visible="false"
|
||||||
|
Pack="false" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
67
bindings/dotnet/src/Dreport.Service/Exceptions.cs
Normal file
67
bindings/dotnet/src/Dreport.Service/Exceptions.cs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
namespace Dreport.Service;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when the underlying dreport service returns an error. The numeric
|
||||||
|
/// <see cref="Code"/> mirrors the FFI return code (negative values).
|
||||||
|
/// </summary>
|
||||||
|
public class DreportException : Exception
|
||||||
|
{
|
||||||
|
public int Code { get; }
|
||||||
|
|
||||||
|
public DreportException(int code, string message) : base(message)
|
||||||
|
{
|
||||||
|
Code = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static DreportException FromCode(int code, string fallbackMessage)
|
||||||
|
{
|
||||||
|
var nativeMessage = Native.GetLastError();
|
||||||
|
var message = string.IsNullOrEmpty(nativeMessage) ? fallbackMessage : nativeMessage;
|
||||||
|
return code switch
|
||||||
|
{
|
||||||
|
Native.ERR_INVALID_TEMPLATE_JSON => new InvalidTemplateException(message),
|
||||||
|
Native.ERR_INVALID_DATA_JSON => new InvalidDataException(message),
|
||||||
|
Native.ERR_FONT_PARSE_FAILED => new FontParseException(message),
|
||||||
|
Native.ERR_FONT_DIR_NOT_FOUND => new FontDirectoryNotFoundException(message),
|
||||||
|
Native.ERR_FONT_DIR_READ => new FontDirectoryReadException(message),
|
||||||
|
Native.ERR_LAYOUT_FAILED => new LayoutException(message),
|
||||||
|
Native.ERR_PDF_FAILED => new PdfRenderException(message),
|
||||||
|
_ => new DreportException(code, message),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class InvalidTemplateException : DreportException
|
||||||
|
{
|
||||||
|
public InvalidTemplateException(string message) : base(Native.ERR_INVALID_TEMPLATE_JSON, message) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class InvalidDataException : DreportException
|
||||||
|
{
|
||||||
|
public InvalidDataException(string message) : base(Native.ERR_INVALID_DATA_JSON, message) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class FontParseException : DreportException
|
||||||
|
{
|
||||||
|
public FontParseException(string message) : base(Native.ERR_FONT_PARSE_FAILED, message) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class FontDirectoryNotFoundException : DreportException
|
||||||
|
{
|
||||||
|
public FontDirectoryNotFoundException(string message) : base(Native.ERR_FONT_DIR_NOT_FOUND, message) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class FontDirectoryReadException : DreportException
|
||||||
|
{
|
||||||
|
public FontDirectoryReadException(string message) : base(Native.ERR_FONT_DIR_READ, message) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class LayoutException : DreportException
|
||||||
|
{
|
||||||
|
public LayoutException(string message) : base(Native.ERR_LAYOUT_FAILED, message) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PdfRenderException : DreportException
|
||||||
|
{
|
||||||
|
public PdfRenderException(string message) : base(Native.ERR_PDF_FAILED, message) { }
|
||||||
|
}
|
||||||
13
bindings/dotnet/src/Dreport.Service/FontFamily.cs
Normal file
13
bindings/dotnet/src/Dreport.Service/FontFamily.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Dreport.Service;
|
||||||
|
|
||||||
|
/// <summary>One font family with its registered variants.</summary>
|
||||||
|
public sealed record FontFamily(
|
||||||
|
[property: JsonPropertyName("family")] string Family,
|
||||||
|
[property: JsonPropertyName("variants")] IReadOnlyList<FontVariant> Variants);
|
||||||
|
|
||||||
|
/// <summary>One weight/italic combination within a family.</summary>
|
||||||
|
public sealed record FontVariant(
|
||||||
|
[property: JsonPropertyName("weight")] ushort Weight,
|
||||||
|
[property: JsonPropertyName("italic")] bool Italic);
|
||||||
220
bindings/dotnet/src/Dreport.Service/LayoutEngine.cs
Normal file
220
bindings/dotnet/src/Dreport.Service/LayoutEngine.cs
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Dreport.Service;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Managed wrapper around a single dreport native engine handle.
|
||||||
|
///
|
||||||
|
/// Thread-safe: every operation goes through the underlying Rust service which
|
||||||
|
/// uses internal locking. You can keep one process-wide instance and call
|
||||||
|
/// concurrent <see cref="RenderPdf"/> from any number of threads.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LayoutEngine : IDisposable
|
||||||
|
{
|
||||||
|
private IntPtr _handle;
|
||||||
|
private readonly object _disposeLock = new();
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
/// <summary>Create an engine with the embedded default fonts loaded.</summary>
|
||||||
|
public LayoutEngine() : this(Native.dreport_new())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
private LayoutEngine(IntPtr handle)
|
||||||
|
{
|
||||||
|
if (handle == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("dreport_new returned a null handle");
|
||||||
|
}
|
||||||
|
_handle = handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Create an engine with no fonts pre-loaded.</summary>
|
||||||
|
public static LayoutEngine CreateEmpty() => new(Native.dreport_new_empty());
|
||||||
|
|
||||||
|
/// <summary>Native crate version, e.g. "0.2.0".</summary>
|
||||||
|
public static string NativeVersion
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var ptr = Native.dreport_version();
|
||||||
|
return ptr == IntPtr.Zero ? string.Empty : System.Runtime.InteropServices.Marshal.PtrToStringAnsi(ptr) ?? string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Font registry
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Number of distinct font families currently registered.</summary>
|
||||||
|
public int FontFamilyCount
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
EnsureNotDisposed();
|
||||||
|
var count = Native.dreport_font_family_count(_handle);
|
||||||
|
if (count < 0)
|
||||||
|
{
|
||||||
|
throw DreportException.FromCode((int)count, "dreport_font_family_count failed");
|
||||||
|
}
|
||||||
|
return (int)count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Register a font from raw TTF/OTF bytes.</summary>
|
||||||
|
public unsafe void RegisterFont(ReadOnlySpan<byte> data)
|
||||||
|
{
|
||||||
|
EnsureNotDisposed();
|
||||||
|
if (data.IsEmpty)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("font bytes empty", nameof(data));
|
||||||
|
}
|
||||||
|
fixed (byte* ptr = data)
|
||||||
|
{
|
||||||
|
var rc = Native.dreport_register_font(_handle, ptr, (nuint)data.Length);
|
||||||
|
if (rc != Native.OK)
|
||||||
|
{
|
||||||
|
throw DreportException.FromCode(rc, "dreport_register_font failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Register every <c>.ttf</c>/<c>.otf</c> file in <paramref name="directory"/>.</summary>
|
||||||
|
/// <returns>Number of fonts that registered successfully.</returns>
|
||||||
|
public unsafe int RegisterFontsDirectory(string directory)
|
||||||
|
{
|
||||||
|
EnsureNotDisposed();
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(directory);
|
||||||
|
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(directory);
|
||||||
|
nuint count;
|
||||||
|
int rc;
|
||||||
|
fixed (byte* ptr = bytes)
|
||||||
|
{
|
||||||
|
rc = Native.dreport_register_fonts_dir(_handle, ptr, (nuint)bytes.Length, out count);
|
||||||
|
}
|
||||||
|
if (rc != Native.OK)
|
||||||
|
{
|
||||||
|
throw DreportException.FromCode(rc, $"dreport_register_fonts_dir failed for '{directory}'");
|
||||||
|
}
|
||||||
|
return (int)count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Get raw bytes for a specific font variant. Returns null when unknown.</summary>
|
||||||
|
public unsafe byte[]? GetFontBytes(string family, ushort weight, bool italic)
|
||||||
|
{
|
||||||
|
EnsureNotDisposed();
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(family);
|
||||||
|
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(family);
|
||||||
|
Native.DreportBuffer buffer;
|
||||||
|
int rc;
|
||||||
|
fixed (byte* ptr = bytes)
|
||||||
|
{
|
||||||
|
rc = Native.dreport_get_font_bytes(_handle, ptr, (nuint)bytes.Length, weight, italic, out buffer);
|
||||||
|
}
|
||||||
|
if (rc != Native.OK)
|
||||||
|
{
|
||||||
|
throw DreportException.FromCode(rc, "dreport_get_font_bytes failed");
|
||||||
|
}
|
||||||
|
var data = Native.ConsumeBuffer(buffer);
|
||||||
|
return data.Length == 0 ? null : data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>List every registered font family with its variants.</summary>
|
||||||
|
public IReadOnlyList<FontFamily> ListFontFamilies()
|
||||||
|
{
|
||||||
|
EnsureNotDisposed();
|
||||||
|
var rc = Native.dreport_list_fonts_json(_handle, out var buffer);
|
||||||
|
if (rc != Native.OK)
|
||||||
|
{
|
||||||
|
throw DreportException.FromCode(rc, "dreport_list_fonts_json failed");
|
||||||
|
}
|
||||||
|
var json = Native.ConsumeBuffer(buffer);
|
||||||
|
if (json.Length == 0)
|
||||||
|
{
|
||||||
|
return Array.Empty<FontFamily>();
|
||||||
|
}
|
||||||
|
var families = JsonSerializer.Deserialize<List<FontFamily>>(json);
|
||||||
|
return families ?? new List<FontFamily>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Render pipeline
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Compute layout from JSON inputs. Returns the LayoutResult JSON string.</summary>
|
||||||
|
public unsafe string ComputeLayout(string templateJson, string dataJson)
|
||||||
|
{
|
||||||
|
EnsureNotDisposed();
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(templateJson);
|
||||||
|
ArgumentNullException.ThrowIfNull(dataJson);
|
||||||
|
|
||||||
|
var tplBytes = Encoding.UTF8.GetBytes(templateJson);
|
||||||
|
var dataBytes = Encoding.UTF8.GetBytes(dataJson);
|
||||||
|
Native.DreportBuffer buffer;
|
||||||
|
int rc;
|
||||||
|
fixed (byte* tplPtr = tplBytes)
|
||||||
|
fixed (byte* dataPtr = dataBytes)
|
||||||
|
{
|
||||||
|
rc = Native.dreport_compute_layout(
|
||||||
|
_handle, tplPtr, (nuint)tplBytes.Length, dataPtr, (nuint)dataBytes.Length, out buffer);
|
||||||
|
}
|
||||||
|
if (rc != Native.OK)
|
||||||
|
{
|
||||||
|
throw DreportException.FromCode(rc, "dreport_compute_layout failed");
|
||||||
|
}
|
||||||
|
return Encoding.UTF8.GetString(Native.ConsumeBuffer(buffer));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Render a PDF document. Returns the raw PDF bytes.</summary>
|
||||||
|
public unsafe byte[] RenderPdf(string templateJson, string dataJson)
|
||||||
|
{
|
||||||
|
EnsureNotDisposed();
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(templateJson);
|
||||||
|
ArgumentNullException.ThrowIfNull(dataJson);
|
||||||
|
|
||||||
|
var tplBytes = Encoding.UTF8.GetBytes(templateJson);
|
||||||
|
var dataBytes = Encoding.UTF8.GetBytes(dataJson);
|
||||||
|
Native.DreportBuffer buffer;
|
||||||
|
int rc;
|
||||||
|
fixed (byte* tplPtr = tplBytes)
|
||||||
|
fixed (byte* dataPtr = dataBytes)
|
||||||
|
{
|
||||||
|
rc = Native.dreport_render_pdf(
|
||||||
|
_handle, tplPtr, (nuint)tplBytes.Length, dataPtr, (nuint)dataBytes.Length, out buffer);
|
||||||
|
}
|
||||||
|
if (rc != Native.OK)
|
||||||
|
{
|
||||||
|
throw DreportException.FromCode(rc, "dreport_render_pdf failed");
|
||||||
|
}
|
||||||
|
return Native.ConsumeBuffer(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Disposal
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
lock (_disposeLock)
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
if (_handle != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
Native.dreport_free(_handle);
|
||||||
|
_handle = IntPtr.Zero;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureNotDisposed()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
throw new ObjectDisposedException(nameof(LayoutEngine));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
145
bindings/dotnet/src/Dreport.Service/Native.cs
Normal file
145
bindings/dotnet/src/Dreport.Service/Native.cs
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
// P/Invoke surface for libdreport_ffi. Mirrors dreport-ffi/include/dreport.h
|
||||||
|
// 1:1. Higher-level wrappers live in LayoutEngine.cs.
|
||||||
|
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace Dreport.Service;
|
||||||
|
|
||||||
|
internal static class Native
|
||||||
|
{
|
||||||
|
// The shared library is named libdreport_ffi.{dylib,so} or dreport_ffi.dll.
|
||||||
|
// .NET's runtime resolves it via the runtimes/<rid>/native/ pattern when the
|
||||||
|
// package is consumed; for local development the file lives next to the test
|
||||||
|
// assembly under bin/<config>/<tfm>/runtimes/<rid>/native/.
|
||||||
|
internal const string Lib = "dreport_ffi";
|
||||||
|
|
||||||
|
// ----- Return codes (mirror dreport_ffi::error_code) -------------------
|
||||||
|
|
||||||
|
public const int OK = 0;
|
||||||
|
public const int NULL_HANDLE = -100;
|
||||||
|
public const int NULL_POINTER = -101;
|
||||||
|
public const int INVALID_UTF8 = -102;
|
||||||
|
public const int PANIC = -103;
|
||||||
|
|
||||||
|
// Service-level error codes are returned as the negation of
|
||||||
|
// ServiceError::code(), e.g. FontParseFailed (3) → -3.
|
||||||
|
public const int ERR_INVALID_TEMPLATE_JSON = -1;
|
||||||
|
public const int ERR_INVALID_DATA_JSON = -2;
|
||||||
|
public const int ERR_FONT_PARSE_FAILED = -3;
|
||||||
|
public const int ERR_FONT_DIR_NOT_FOUND = -4;
|
||||||
|
public const int ERR_FONT_DIR_READ = -5;
|
||||||
|
public const int ERR_LAYOUT_FAILED = -6;
|
||||||
|
public const int ERR_PDF_FAILED = -7;
|
||||||
|
public const int ERR_SERIALIZATION_FAILED = -8;
|
||||||
|
|
||||||
|
// ----- ByteBuffer ------------------------------------------------------
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
public struct DreportBuffer
|
||||||
|
{
|
||||||
|
public IntPtr Data;
|
||||||
|
public nuint Len;
|
||||||
|
public nuint Cap;
|
||||||
|
|
||||||
|
public static DreportBuffer Empty => default;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Lifecycle -------------------------------------------------------
|
||||||
|
|
||||||
|
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
|
||||||
|
public static extern IntPtr dreport_new();
|
||||||
|
|
||||||
|
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
|
||||||
|
public static extern IntPtr dreport_new_empty();
|
||||||
|
|
||||||
|
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
|
||||||
|
public static extern void dreport_free(IntPtr handle);
|
||||||
|
|
||||||
|
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
|
||||||
|
public static extern void dreport_buffer_free(DreportBuffer buffer);
|
||||||
|
|
||||||
|
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
|
||||||
|
public static extern IntPtr dreport_version();
|
||||||
|
|
||||||
|
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
|
||||||
|
public static extern int dreport_last_error(out DreportBuffer buffer);
|
||||||
|
|
||||||
|
// ----- Font registry ---------------------------------------------------
|
||||||
|
|
||||||
|
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
|
||||||
|
public static extern unsafe int dreport_register_font(IntPtr handle, byte* data, nuint len);
|
||||||
|
|
||||||
|
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
|
||||||
|
public static extern unsafe int dreport_register_fonts_dir(
|
||||||
|
IntPtr handle,
|
||||||
|
byte* path,
|
||||||
|
nuint pathLen,
|
||||||
|
out nuint outCount);
|
||||||
|
|
||||||
|
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
|
||||||
|
public static extern int dreport_list_fonts_json(IntPtr handle, out DreportBuffer outBuffer);
|
||||||
|
|
||||||
|
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
|
||||||
|
public static extern unsafe int dreport_get_font_bytes(
|
||||||
|
IntPtr handle,
|
||||||
|
byte* family,
|
||||||
|
nuint familyLen,
|
||||||
|
ushort weight,
|
||||||
|
[MarshalAs(UnmanagedType.U1)] bool italic,
|
||||||
|
out DreportBuffer outBuffer);
|
||||||
|
|
||||||
|
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
|
||||||
|
public static extern nint dreport_font_family_count(IntPtr handle);
|
||||||
|
|
||||||
|
// ----- Render pipeline -------------------------------------------------
|
||||||
|
|
||||||
|
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
|
||||||
|
public static extern unsafe int dreport_compute_layout(
|
||||||
|
IntPtr handle,
|
||||||
|
byte* template_,
|
||||||
|
nuint templateLen,
|
||||||
|
byte* data,
|
||||||
|
nuint dataLen,
|
||||||
|
out DreportBuffer outBuffer);
|
||||||
|
|
||||||
|
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
|
||||||
|
public static extern unsafe int dreport_render_pdf(
|
||||||
|
IntPtr handle,
|
||||||
|
byte* template_,
|
||||||
|
nuint templateLen,
|
||||||
|
byte* data,
|
||||||
|
nuint dataLen,
|
||||||
|
out DreportBuffer outBuffer);
|
||||||
|
|
||||||
|
// ----- Helpers ---------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Copy a native buffer into a managed byte[] and free the native side.</summary>
|
||||||
|
public static byte[] ConsumeBuffer(DreportBuffer buffer)
|
||||||
|
{
|
||||||
|
if (buffer.Data == IntPtr.Zero || buffer.Len == 0)
|
||||||
|
{
|
||||||
|
// Still free the buffer in case cap > 0 (defensive — current FFI never returns this).
|
||||||
|
if (buffer.Cap > 0)
|
||||||
|
{
|
||||||
|
dreport_buffer_free(buffer);
|
||||||
|
}
|
||||||
|
return Array.Empty<byte>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var bytes = new byte[buffer.Len];
|
||||||
|
Marshal.Copy(buffer.Data, bytes, 0, (int)buffer.Len);
|
||||||
|
dreport_buffer_free(buffer);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Read the most recent FFI error message for the current thread.</summary>
|
||||||
|
public static string GetLastError()
|
||||||
|
{
|
||||||
|
if (dreport_last_error(out var buffer) != OK)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
var bytes = ConsumeBuffer(buffer);
|
||||||
|
return bytes.Length == 0 ? string.Empty : System.Text.Encoding.UTF8.GetString(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.0" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\Dreport.AspNetCore\Dreport.AspNetCore.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
173
bindings/dotnet/tests/Dreport.AspNetCore.Tests/EndpointTests.cs
Normal file
173
bindings/dotnet/tests/Dreport.AspNetCore.Tests/EndpointTests.cs
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Dreport.AspNetCore;
|
||||||
|
using Dreport.Service;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Microsoft.AspNetCore.TestHost;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Dreport.AspNetCore.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Spins up an in-memory ASP.NET Core host for each test using TestServer
|
||||||
|
/// directly, so we don't need a Program.cs entry point. Verifies the
|
||||||
|
/// stock /api endpoints behave the same as the original Axum backend.
|
||||||
|
/// </summary>
|
||||||
|
public class EndpointTests
|
||||||
|
{
|
||||||
|
private const string Template = """
|
||||||
|
{
|
||||||
|
"id": "aspnet-test",
|
||||||
|
"name": "AspNet Test",
|
||||||
|
"page": { "width": 210, "height": 297 },
|
||||||
|
"fonts": ["Noto Sans"],
|
||||||
|
"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": "title",
|
||||||
|
"type": "static_text",
|
||||||
|
"position": { "type": "flow" },
|
||||||
|
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
|
||||||
|
"style": { "fontSize": 14, "fontWeight": "bold" },
|
||||||
|
"content": "Hello"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static HttpClient Build(string prefix = "/api")
|
||||||
|
{
|
||||||
|
var builder = new WebHostBuilder()
|
||||||
|
.ConfigureServices(s => s.AddRouting().AddDreport())
|
||||||
|
.Configure(app =>
|
||||||
|
{
|
||||||
|
app.UseRouting();
|
||||||
|
app.UseEndpoints(e => e.MapDreportEndpoints(prefix));
|
||||||
|
});
|
||||||
|
var server = new TestServer(builder);
|
||||||
|
return server.CreateClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Health_Returns_Ok()
|
||||||
|
{
|
||||||
|
var client = Build();
|
||||||
|
var resp = await client.GetAsync("/api/health");
|
||||||
|
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||||
|
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
Assert.Equal("ok", json.GetProperty("status").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Render_ReturnsPdf()
|
||||||
|
{
|
||||||
|
var client = Build();
|
||||||
|
var payload = new { template = JsonDocument.Parse(Template).RootElement, data = new { } };
|
||||||
|
var resp = await client.PostAsJsonAsync("/api/render", payload);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||||
|
Assert.Equal("application/pdf", resp.Content.Headers.ContentType?.MediaType);
|
||||||
|
var bytes = await resp.Content.ReadAsByteArrayAsync();
|
||||||
|
Assert.True(bytes.Length > 100);
|
||||||
|
Assert.Equal((byte)'%', bytes[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Render_InvalidTemplate_Returns400()
|
||||||
|
{
|
||||||
|
var client = Build();
|
||||||
|
var payload = new { template = "not a template", data = new { } };
|
||||||
|
var resp = await client.PostAsJsonAsync("/api/render", payload);
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Layout_ReturnsJson()
|
||||||
|
{
|
||||||
|
var client = Build();
|
||||||
|
var payload = new { template = JsonDocument.Parse(Template).RootElement, data = new { } };
|
||||||
|
var resp = await client.PostAsJsonAsync("/api/layout", payload);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||||
|
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
Assert.True(json.TryGetProperty("pages", out var pages));
|
||||||
|
Assert.Equal(JsonValueKind.Array, pages.ValueKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ListFonts_IncludesNotoSans()
|
||||||
|
{
|
||||||
|
var client = Build();
|
||||||
|
var resp = await client.GetAsync("/api/fonts");
|
||||||
|
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||||
|
var families = await resp.Content.ReadFromJsonAsync<JsonElement[]>();
|
||||||
|
Assert.NotNull(families);
|
||||||
|
Assert.Contains(families!, f => f.GetProperty("family").GetString()!.ToLowerInvariant().Contains("noto"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetFontBytes_KnownVariant_ReturnsTtf()
|
||||||
|
{
|
||||||
|
var client = Build();
|
||||||
|
var resp = await client.GetAsync("/api/fonts/Noto%20Sans/400/false");
|
||||||
|
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||||
|
Assert.Equal("font/ttf", resp.Content.Headers.ContentType?.MediaType);
|
||||||
|
var bytes = await resp.Content.ReadAsByteArrayAsync();
|
||||||
|
Assert.True(bytes.Length > 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetFontBytes_Unknown_Returns404()
|
||||||
|
{
|
||||||
|
var client = Build();
|
||||||
|
var resp = await client.GetAsync("/api/fonts/DoesNotExist/400/false");
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CustomPrefix_RemapsAllEndpoints()
|
||||||
|
{
|
||||||
|
var client = Build("/dreport/api");
|
||||||
|
var resp = await client.GetAsync("/dreport/api/health");
|
||||||
|
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||||
|
var oldRoute = await client.GetAsync("/api/health");
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, oldRoute.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ManualUsage_LayoutEngine_FromDi()
|
||||||
|
{
|
||||||
|
// Sanity: AddDreport without MapDreportEndpoints still hands the engine
|
||||||
|
// out via DI so users can plug it into their own controllers.
|
||||||
|
var builder = new WebHostBuilder()
|
||||||
|
.ConfigureServices(s => s.AddRouting().AddDreport())
|
||||||
|
.Configure(app =>
|
||||||
|
{
|
||||||
|
app.UseRouting();
|
||||||
|
app.UseEndpoints(e => e.MapGet("/custom",
|
||||||
|
(LayoutEngine engine) => Results.Json(new { count = engine.FontFamilyCount })));
|
||||||
|
});
|
||||||
|
using var server = new TestServer(builder);
|
||||||
|
var client = server.CreateClient();
|
||||||
|
var resp = await client.GetAsync("/custom");
|
||||||
|
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||||
|
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
Assert.True(json.GetProperty("count").GetInt32() >= 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
|
<!-- Tests run on the host RID; ensure native dylibs ship next to the test asm. -->
|
||||||
|
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\Dreport.Service\Dreport.Service.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Sample TTF for FontParseException tests; copied from the workspace assets dir. -->
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="..\..\..\..\dreport-service\assets\fonts\NotoSans-Regular.ttf"
|
||||||
|
Link="fixtures/NotoSans-Regular.ttf"
|
||||||
|
CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
236
bindings/dotnet/tests/Dreport.Service.Tests/LayoutEngineTests.cs
Normal file
236
bindings/dotnet/tests/Dreport.Service.Tests/LayoutEngineTests.cs
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Dreport.Service;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Dreport.Service.Tests;
|
||||||
|
|
||||||
|
public class LayoutEngineTests
|
||||||
|
{
|
||||||
|
private const string Template = """
|
||||||
|
{
|
||||||
|
"id": "csharp",
|
||||||
|
"name": "C# Test",
|
||||||
|
"page": { "width": 210, "height": 297 },
|
||||||
|
"fonts": ["Noto Sans"],
|
||||||
|
"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": "title",
|
||||||
|
"type": "static_text",
|
||||||
|
"position": { "type": "flow" },
|
||||||
|
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
|
||||||
|
"style": { "fontSize": 14, "fontWeight": "bold" },
|
||||||
|
"content": "Hello from C#"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
private const string Data = "{}";
|
||||||
|
|
||||||
|
private static byte[] LoadFixtureFont() =>
|
||||||
|
File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "fixtures", "NotoSans-Regular.ttf"));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Lifecycle
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Construct_DefaultEngine_HasEmbeddedFonts()
|
||||||
|
{
|
||||||
|
using var engine = new LayoutEngine();
|
||||||
|
Assert.True(engine.FontFamilyCount >= 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateEmpty_StartsWithoutFonts()
|
||||||
|
{
|
||||||
|
using var engine = LayoutEngine.CreateEmpty();
|
||||||
|
Assert.Equal(0, engine.FontFamilyCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NativeVersion_ReturnsNonEmpty()
|
||||||
|
{
|
||||||
|
var v = LayoutEngine.NativeVersion;
|
||||||
|
Assert.False(string.IsNullOrEmpty(v));
|
||||||
|
Assert.Contains('.', v);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Dispose_TwiceIsSafe()
|
||||||
|
{
|
||||||
|
var engine = new LayoutEngine();
|
||||||
|
engine.Dispose();
|
||||||
|
engine.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Operations_AfterDispose_Throw()
|
||||||
|
{
|
||||||
|
var engine = new LayoutEngine();
|
||||||
|
engine.Dispose();
|
||||||
|
Assert.Throws<ObjectDisposedException>(() => engine.RenderPdf(Template, Data));
|
||||||
|
Assert.Throws<ObjectDisposedException>(() => engine.ListFontFamilies());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Font registry
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RegisterFont_ValidBytes_IncreasesCount()
|
||||||
|
{
|
||||||
|
using var engine = LayoutEngine.CreateEmpty();
|
||||||
|
engine.RegisterFont(LoadFixtureFont());
|
||||||
|
Assert.Equal(1, engine.FontFamilyCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RegisterFont_InvalidBytes_ThrowsFontParseException()
|
||||||
|
{
|
||||||
|
using var engine = LayoutEngine.CreateEmpty();
|
||||||
|
var ex = Assert.Throws<FontParseException>(() =>
|
||||||
|
engine.RegisterFont(new byte[] { 1, 2, 3, 4 }));
|
||||||
|
Assert.Equal(Native.ERR_FONT_PARSE_FAILED, ex.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RegisterFontsDirectory_NonExisting_ThrowsFontDirectoryNotFound()
|
||||||
|
{
|
||||||
|
using var engine = LayoutEngine.CreateEmpty();
|
||||||
|
Assert.Throws<FontDirectoryNotFoundException>(() =>
|
||||||
|
engine.RegisterFontsDirectory("/no/such/dreport/test/path/xyz"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RegisterFontsDirectory_ValidDir_LoadsCount()
|
||||||
|
{
|
||||||
|
var fixturesDir = Path.Combine(AppContext.BaseDirectory, "fixtures");
|
||||||
|
Assert.True(Directory.Exists(fixturesDir));
|
||||||
|
|
||||||
|
using var engine = LayoutEngine.CreateEmpty();
|
||||||
|
var count = engine.RegisterFontsDirectory(fixturesDir);
|
||||||
|
Assert.True(count >= 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetFontBytes_KnownVariant_ReturnsBytes()
|
||||||
|
{
|
||||||
|
using var engine = new LayoutEngine();
|
||||||
|
var bytes = engine.GetFontBytes("Noto Sans", 400, false);
|
||||||
|
Assert.NotNull(bytes);
|
||||||
|
Assert.True(bytes!.Length > 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetFontBytes_UnknownVariant_ReturnsNull()
|
||||||
|
{
|
||||||
|
using var engine = new LayoutEngine();
|
||||||
|
Assert.Null(engine.GetFontBytes("DoesNotExist", 400, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ListFontFamilies_ContainsNotoSans()
|
||||||
|
{
|
||||||
|
using var engine = new LayoutEngine();
|
||||||
|
var families = engine.ListFontFamilies();
|
||||||
|
Assert.Contains(families, f => f.Family.ToLowerInvariant().Contains("noto"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Render pipeline
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ComputeLayout_ValidInputs_ReturnsLayoutJson()
|
||||||
|
{
|
||||||
|
using var engine = new LayoutEngine();
|
||||||
|
var json = engine.ComputeLayout(Template, Data);
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
Assert.True(doc.RootElement.TryGetProperty("pages", out var pages));
|
||||||
|
Assert.Equal(JsonValueKind.Array, pages.ValueKind);
|
||||||
|
Assert.True(pages.GetArrayLength() >= 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ComputeLayout_InvalidTemplate_ThrowsInvalidTemplate()
|
||||||
|
{
|
||||||
|
using var engine = new LayoutEngine();
|
||||||
|
Assert.Throws<InvalidTemplateException>(() => engine.ComputeLayout("{not json", Data));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ComputeLayout_InvalidData_ThrowsInvalidData()
|
||||||
|
{
|
||||||
|
using var engine = new LayoutEngine();
|
||||||
|
Assert.Throws<InvalidDataException>(() => engine.ComputeLayout(Template, "{not json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RenderPdf_ValidInputs_ReturnsPdfBytes()
|
||||||
|
{
|
||||||
|
using var engine = new LayoutEngine();
|
||||||
|
var pdf = engine.RenderPdf(Template, Data);
|
||||||
|
Assert.True(pdf.Length > 100);
|
||||||
|
Assert.Equal((byte)'%', pdf[0]);
|
||||||
|
Assert.Equal((byte)'P', pdf[1]);
|
||||||
|
Assert.Equal((byte)'D', pdf[2]);
|
||||||
|
Assert.Equal((byte)'F', pdf[3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RenderPdf_InvalidTemplate_ThrowsInvalidTemplate()
|
||||||
|
{
|
||||||
|
using var engine = new LayoutEngine();
|
||||||
|
Assert.Throws<InvalidTemplateException>(() => engine.RenderPdf("{not json", Data));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Concurrency
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RenderPdf_Parallel_ProducesPdfs()
|
||||||
|
{
|
||||||
|
using var engine = new LayoutEngine();
|
||||||
|
var success = 0;
|
||||||
|
Parallel.For(0, 16, _ =>
|
||||||
|
{
|
||||||
|
var pdf = engine.RenderPdf(Template, Data);
|
||||||
|
if (pdf.Length > 100 && pdf[0] == (byte)'%')
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref success);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Assert.Equal(16, success);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Error code stability (matches Rust ServiceError::code() contract)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ErrorCode_InvalidTemplate_IsMinusOne()
|
||||||
|
{
|
||||||
|
var ex = new InvalidTemplateException("x");
|
||||||
|
Assert.Equal(-1, ex.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ErrorCode_FontParseFailed_IsMinusThree()
|
||||||
|
{
|
||||||
|
var ex = new FontParseException("x");
|
||||||
|
Assert.Equal(-3, ex.Code);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,6 +97,20 @@ pub struct ContainerStyle {
|
|||||||
pub border_style: Option<String>,
|
pub border_style: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Condition (v-if benzeri koşullu gösterim) ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Condition {
|
||||||
|
/// Data JSON'daki alan yolu (ör: "toplamlar.iskonto")
|
||||||
|
pub path: String,
|
||||||
|
/// Karşılaştırma operatörü: eq, neq, gt, gte, lt, lte, empty, not_empty
|
||||||
|
pub operator: String,
|
||||||
|
/// Karşılaştırılacak değer (empty/not_empty için gerekmez)
|
||||||
|
#[serde(default)]
|
||||||
|
pub value: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
// --- Binding ---
|
// --- Binding ---
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -216,6 +230,26 @@ pub struct ChartAxis {
|
|||||||
pub y_label: Option<String>,
|
pub y_label: Option<String>,
|
||||||
pub show_grid: Option<bool>,
|
pub show_grid: Option<bool>,
|
||||||
pub grid_color: Option<String>,
|
pub grid_color: Option<String>,
|
||||||
|
/// Show vertical grid lines at each category (line charts). Defaults to true.
|
||||||
|
pub show_vertical_grid: Option<bool>,
|
||||||
|
pub vertical_grid_color: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub reference_lines: Vec<ChartReferenceLine>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ChartReferenceLine {
|
||||||
|
/// Category index (0-based) where the vertical line is drawn
|
||||||
|
pub category_index: usize,
|
||||||
|
#[serde(default)]
|
||||||
|
pub color: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub width: Option<f64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub label: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub dash: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
@@ -233,9 +267,8 @@ pub struct ChartStyle {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ChartElement {
|
pub struct ChartElement {
|
||||||
pub id: String,
|
#[serde(flatten)]
|
||||||
pub position: PositionMode,
|
pub base: ElementBase,
|
||||||
pub size: SizeConstraint,
|
|
||||||
pub chart_type: ChartType,
|
pub chart_type: ChartType,
|
||||||
pub data_source: ArrayBinding,
|
pub data_source: ArrayBinding,
|
||||||
pub category_field: String,
|
pub category_field: String,
|
||||||
@@ -256,6 +289,138 @@ pub struct ChartElement {
|
|||||||
pub style: ChartStyle,
|
pub style: ChartStyle,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Element Base (ortak alanlar) ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ElementBase {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub condition: Option<Condition>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub position: PositionMode,
|
||||||
|
#[serde(default)]
|
||||||
|
pub size: SizeConstraint,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ElementBase {
|
||||||
|
/// Flow pozisyonlu, condition'sız, verilen size ile base oluştur
|
||||||
|
pub fn flow(id: String, size: SizeConstraint) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
condition: None,
|
||||||
|
position: PositionMode::Flow,
|
||||||
|
size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait HasBase {
|
||||||
|
fn base(&self) -> &ElementBase;
|
||||||
|
fn base_mut(&mut self) -> &mut ElementBase;
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! impl_has_base {
|
||||||
|
($($t:ty),+ $(,)?) => {
|
||||||
|
$(impl HasBase for $t {
|
||||||
|
fn base(&self) -> &ElementBase { &self.base }
|
||||||
|
fn base_mut(&mut self) -> &mut ElementBase { &mut self.base }
|
||||||
|
})+
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
impl_has_base!(
|
||||||
|
ContainerElement,
|
||||||
|
StaticTextElement,
|
||||||
|
TextElement,
|
||||||
|
LineElement,
|
||||||
|
ImageElement,
|
||||||
|
PageNumberElement,
|
||||||
|
BarcodeElement,
|
||||||
|
RepeatingTableElement,
|
||||||
|
PageBreakElement,
|
||||||
|
CurrentDateElement,
|
||||||
|
ShapeElement,
|
||||||
|
CheckboxElement,
|
||||||
|
CalculatedTextElement,
|
||||||
|
RichTextElement,
|
||||||
|
ChartElement,
|
||||||
|
);
|
||||||
|
|
||||||
|
pub trait ElementTypeStr {
|
||||||
|
fn type_str(&self) -> &'static str;
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! impl_type_str {
|
||||||
|
($($t:ty => $s:literal),+ $(,)?) => {
|
||||||
|
$(impl ElementTypeStr for $t {
|
||||||
|
fn type_str(&self) -> &'static str { $s }
|
||||||
|
})+
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
impl_type_str!(
|
||||||
|
ContainerElement => "container",
|
||||||
|
StaticTextElement => "static_text",
|
||||||
|
TextElement => "text",
|
||||||
|
LineElement => "line",
|
||||||
|
ImageElement => "image",
|
||||||
|
PageNumberElement => "page_number",
|
||||||
|
BarcodeElement => "barcode",
|
||||||
|
RepeatingTableElement => "repeating_table",
|
||||||
|
PageBreakElement => "page_break",
|
||||||
|
CurrentDateElement => "current_date",
|
||||||
|
ShapeElement => "shape",
|
||||||
|
CheckboxElement => "checkbox",
|
||||||
|
CalculatedTextElement => "calculated_text",
|
||||||
|
RichTextElement => "rich_text",
|
||||||
|
ChartElement => "chart",
|
||||||
|
);
|
||||||
|
|
||||||
|
pub trait HasTextStyle {
|
||||||
|
fn text_style(&self) -> &TextStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! impl_has_text_style {
|
||||||
|
($($t:ty),+ $(,)?) => {
|
||||||
|
$(impl HasTextStyle for $t {
|
||||||
|
fn text_style(&self) -> &TextStyle { &self.style }
|
||||||
|
})+
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
impl_has_text_style!(
|
||||||
|
StaticTextElement,
|
||||||
|
TextElement,
|
||||||
|
PageNumberElement,
|
||||||
|
CurrentDateElement,
|
||||||
|
CalculatedTextElement,
|
||||||
|
RichTextElement,
|
||||||
|
);
|
||||||
|
|
||||||
|
pub trait HasOptionalBinding {
|
||||||
|
fn binding(&self) -> Option<&ScalarBinding>;
|
||||||
|
fn static_value(&self) -> Option<&str>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HasOptionalBinding for ImageElement {
|
||||||
|
fn binding(&self) -> Option<&ScalarBinding> {
|
||||||
|
self.binding.as_ref()
|
||||||
|
}
|
||||||
|
fn static_value(&self) -> Option<&str> {
|
||||||
|
self.src.as_deref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HasOptionalBinding for BarcodeElement {
|
||||||
|
fn binding(&self) -> Option<&ScalarBinding> {
|
||||||
|
self.binding.as_ref()
|
||||||
|
}
|
||||||
|
fn static_value(&self) -> Option<&str> {
|
||||||
|
self.value.as_deref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Element tipleri ---
|
// --- Element tipleri ---
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
@@ -296,75 +461,63 @@ pub enum TemplateElement {
|
|||||||
#[serde(rename = "rich_text")]
|
#[serde(rename = "rich_text")]
|
||||||
RichText(RichTextElement),
|
RichText(RichTextElement),
|
||||||
#[serde(rename = "chart")]
|
#[serde(rename = "chart")]
|
||||||
Chart(ChartElement),
|
Chart(Box<ChartElement>),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TemplateElement {
|
impl TemplateElement {
|
||||||
pub fn id(&self) -> &str {
|
fn inner_base(&self) -> &ElementBase {
|
||||||
match self {
|
match self {
|
||||||
Self::Container(e) => &e.id,
|
Self::Container(e) => e.base(),
|
||||||
Self::StaticText(e) => &e.id,
|
Self::StaticText(e) => e.base(),
|
||||||
Self::Text(e) => &e.id,
|
Self::Text(e) => e.base(),
|
||||||
Self::Line(e) => &e.id,
|
Self::Line(e) => e.base(),
|
||||||
Self::RepeatingTable(e) => &e.id,
|
Self::RepeatingTable(e) => e.base(),
|
||||||
Self::Image(e) => &e.id,
|
Self::Image(e) => e.base(),
|
||||||
Self::PageNumber(e) => &e.id,
|
Self::PageNumber(e) => e.base(),
|
||||||
Self::Barcode(e) => &e.id,
|
Self::Barcode(e) => e.base(),
|
||||||
Self::PageBreak(e) => &e.id,
|
Self::PageBreak(e) => e.base(),
|
||||||
Self::CurrentDate(e) => &e.id,
|
Self::CurrentDate(e) => e.base(),
|
||||||
Self::Shape(e) => &e.id,
|
Self::Shape(e) => e.base(),
|
||||||
Self::Checkbox(e) => &e.id,
|
Self::Checkbox(e) => e.base(),
|
||||||
Self::CalculatedText(e) => &e.id,
|
Self::CalculatedText(e) => e.base(),
|
||||||
Self::RichText(e) => &e.id,
|
Self::RichText(e) => e.base(),
|
||||||
Self::Chart(e) => &e.id,
|
Self::Chart(e) => e.base(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn id(&self) -> &str {
|
||||||
|
&self.inner_base().id
|
||||||
|
}
|
||||||
|
|
||||||
pub fn position(&self) -> &PositionMode {
|
pub fn position(&self) -> &PositionMode {
|
||||||
match self {
|
&self.inner_base().position
|
||||||
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,
|
|
||||||
Self::PageBreak(_) => &PositionMode::Flow,
|
|
||||||
Self::CurrentDate(e) => &e.position,
|
|
||||||
Self::Shape(e) => &e.position,
|
|
||||||
Self::Checkbox(e) => &e.position,
|
|
||||||
Self::CalculatedText(e) => &e.position,
|
|
||||||
Self::RichText(e) => &e.position,
|
|
||||||
Self::Chart(e) => &e.position,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn condition(&self) -> Option<&Condition> {
|
||||||
|
self.inner_base().condition.as_ref()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn size(&self) -> &SizeConstraint {
|
pub fn size(&self) -> &SizeConstraint {
|
||||||
static DEFAULT_SIZE: SizeConstraint = SizeConstraint {
|
&self.inner_base().size
|
||||||
width: SizeValue::Auto,
|
}
|
||||||
height: SizeValue::Auto,
|
|
||||||
min_width: None,
|
pub fn type_str(&self) -> &'static str {
|
||||||
min_height: None,
|
|
||||||
max_width: None,
|
|
||||||
max_height: None,
|
|
||||||
};
|
|
||||||
match self {
|
match self {
|
||||||
Self::Container(e) => &e.size,
|
Self::Container(e) => e.type_str(),
|
||||||
Self::StaticText(e) => &e.size,
|
Self::StaticText(e) => e.type_str(),
|
||||||
Self::Text(e) => &e.size,
|
Self::Text(e) => e.type_str(),
|
||||||
Self::Line(e) => &e.size,
|
Self::Line(e) => e.type_str(),
|
||||||
Self::RepeatingTable(e) => &e.size,
|
Self::RepeatingTable(e) => e.type_str(),
|
||||||
Self::Image(e) => &e.size,
|
Self::Image(e) => e.type_str(),
|
||||||
Self::PageNumber(e) => &e.size,
|
Self::PageNumber(e) => e.type_str(),
|
||||||
Self::Barcode(e) => &e.size,
|
Self::Barcode(e) => e.type_str(),
|
||||||
Self::PageBreak(_) => &DEFAULT_SIZE,
|
Self::PageBreak(e) => e.type_str(),
|
||||||
Self::CurrentDate(e) => &e.size,
|
Self::CurrentDate(e) => e.type_str(),
|
||||||
Self::Shape(e) => &e.size,
|
Self::Shape(e) => e.type_str(),
|
||||||
Self::Checkbox(e) => &e.size,
|
Self::Checkbox(e) => e.type_str(),
|
||||||
Self::CalculatedText(e) => &e.size,
|
Self::CalculatedText(e) => e.type_str(),
|
||||||
Self::RichText(e) => &e.size,
|
Self::RichText(e) => e.type_str(),
|
||||||
Self::Chart(e) => &e.size,
|
Self::Chart(e) => e.type_str(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -372,9 +525,8 @@ impl TemplateElement {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct RichTextElement {
|
pub struct RichTextElement {
|
||||||
pub id: String,
|
#[serde(flatten)]
|
||||||
pub position: PositionMode,
|
pub base: ElementBase,
|
||||||
pub size: SizeConstraint,
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub style: TextStyle, // varsayilan stil (span'lar override edebilir)
|
pub style: TextStyle, // varsayilan stil (span'lar override edebilir)
|
||||||
pub content: Vec<RichTextSpan>,
|
pub content: Vec<RichTextSpan>,
|
||||||
@@ -383,11 +535,8 @@ pub struct RichTextElement {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ContainerElement {
|
pub struct ContainerElement {
|
||||||
pub id: String,
|
#[serde(flatten)]
|
||||||
#[serde(default)]
|
pub base: ElementBase,
|
||||||
pub position: PositionMode,
|
|
||||||
#[serde(default)]
|
|
||||||
pub size: SizeConstraint,
|
|
||||||
#[serde(default = "default_column")]
|
#[serde(default = "default_column")]
|
||||||
pub direction: String,
|
pub direction: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -406,18 +555,25 @@ pub struct ContainerElement {
|
|||||||
pub break_inside: String,
|
pub break_inside: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_auto() -> String { "auto".to_string() }
|
fn default_auto() -> String {
|
||||||
|
"auto".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
fn default_column() -> String { "column".to_string() }
|
fn default_column() -> String {
|
||||||
fn default_stretch() -> String { "stretch".to_string() }
|
"column".to_string()
|
||||||
fn default_start() -> String { "start".to_string() }
|
}
|
||||||
|
fn default_stretch() -> String {
|
||||||
|
"stretch".to_string()
|
||||||
|
}
|
||||||
|
fn default_start() -> String {
|
||||||
|
"start".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct StaticTextElement {
|
pub struct StaticTextElement {
|
||||||
pub id: String,
|
#[serde(flatten)]
|
||||||
pub position: PositionMode,
|
pub base: ElementBase,
|
||||||
pub size: SizeConstraint,
|
|
||||||
pub style: TextStyle,
|
pub style: TextStyle,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
}
|
}
|
||||||
@@ -425,9 +581,8 @@ pub struct StaticTextElement {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct TextElement {
|
pub struct TextElement {
|
||||||
pub id: String,
|
#[serde(flatten)]
|
||||||
pub position: PositionMode,
|
pub base: ElementBase,
|
||||||
pub size: SizeConstraint,
|
|
||||||
pub style: TextStyle,
|
pub style: TextStyle,
|
||||||
pub content: Option<String>,
|
pub content: Option<String>,
|
||||||
pub binding: ScalarBinding,
|
pub binding: ScalarBinding,
|
||||||
@@ -436,18 +591,16 @@ pub struct TextElement {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct LineElement {
|
pub struct LineElement {
|
||||||
pub id: String,
|
#[serde(flatten)]
|
||||||
pub position: PositionMode,
|
pub base: ElementBase,
|
||||||
pub size: SizeConstraint,
|
|
||||||
pub style: LineStyle,
|
pub style: LineStyle,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ImageElement {
|
pub struct ImageElement {
|
||||||
pub id: String,
|
#[serde(flatten)]
|
||||||
pub position: PositionMode,
|
pub base: ElementBase,
|
||||||
pub size: SizeConstraint,
|
|
||||||
pub src: Option<String>,
|
pub src: Option<String>,
|
||||||
pub binding: Option<ScalarBinding>,
|
pub binding: Option<ScalarBinding>,
|
||||||
pub style: ImageStyle,
|
pub style: ImageStyle,
|
||||||
@@ -456,9 +609,8 @@ pub struct ImageElement {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct PageNumberElement {
|
pub struct PageNumberElement {
|
||||||
pub id: String,
|
#[serde(flatten)]
|
||||||
pub position: PositionMode,
|
pub base: ElementBase,
|
||||||
pub size: SizeConstraint,
|
|
||||||
pub style: TextStyle,
|
pub style: TextStyle,
|
||||||
pub format: Option<String>,
|
pub format: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -466,9 +618,8 @@ pub struct PageNumberElement {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct BarcodeElement {
|
pub struct BarcodeElement {
|
||||||
pub id: String,
|
#[serde(flatten)]
|
||||||
pub position: PositionMode,
|
pub base: ElementBase,
|
||||||
pub size: SizeConstraint,
|
|
||||||
pub format: String, // qr, ean13, ean8, code128, code39
|
pub format: String, // qr, ean13, ean8, code128, code39
|
||||||
pub value: Option<String>,
|
pub value: Option<String>,
|
||||||
pub binding: Option<ScalarBinding>,
|
pub binding: Option<ScalarBinding>,
|
||||||
@@ -478,9 +629,8 @@ pub struct BarcodeElement {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct RepeatingTableElement {
|
pub struct RepeatingTableElement {
|
||||||
pub id: String,
|
#[serde(flatten)]
|
||||||
pub position: PositionMode,
|
pub base: ElementBase,
|
||||||
pub size: SizeConstraint,
|
|
||||||
pub data_source: ArrayBinding,
|
pub data_source: ArrayBinding,
|
||||||
pub columns: Vec<TableColumn>,
|
pub columns: Vec<TableColumn>,
|
||||||
pub style: TableStyle,
|
pub style: TableStyle,
|
||||||
@@ -488,20 +638,22 @@ pub struct RepeatingTableElement {
|
|||||||
pub repeat_header: Option<bool>,
|
pub repeat_header: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_true() -> Option<bool> { Some(true) }
|
fn default_true() -> Option<bool> {
|
||||||
|
Some(true)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct PageBreakElement {
|
pub struct PageBreakElement {
|
||||||
pub id: String,
|
#[serde(flatten)]
|
||||||
|
pub base: ElementBase,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct CurrentDateElement {
|
pub struct CurrentDateElement {
|
||||||
pub id: String,
|
#[serde(flatten)]
|
||||||
pub position: PositionMode,
|
pub base: ElementBase,
|
||||||
pub size: SizeConstraint,
|
|
||||||
pub style: TextStyle,
|
pub style: TextStyle,
|
||||||
pub format: Option<String>,
|
pub format: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -509,9 +661,8 @@ pub struct CurrentDateElement {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ShapeElement {
|
pub struct ShapeElement {
|
||||||
pub id: String,
|
#[serde(flatten)]
|
||||||
pub position: PositionMode,
|
pub base: ElementBase,
|
||||||
pub size: SizeConstraint,
|
|
||||||
pub shape_type: String, // rectangle, ellipse, rounded_rectangle
|
pub shape_type: String, // rectangle, ellipse, rounded_rectangle
|
||||||
pub style: ContainerStyle,
|
pub style: ContainerStyle,
|
||||||
}
|
}
|
||||||
@@ -528,9 +679,8 @@ pub struct CheckboxStyle {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct CheckboxElement {
|
pub struct CheckboxElement {
|
||||||
pub id: String,
|
#[serde(flatten)]
|
||||||
pub position: PositionMode,
|
pub base: ElementBase,
|
||||||
pub size: SizeConstraint,
|
|
||||||
pub checked: Option<bool>, // statik değer
|
pub checked: Option<bool>, // statik değer
|
||||||
pub binding: Option<ScalarBinding>, // dinamik boolean binding
|
pub binding: Option<ScalarBinding>, // dinamik boolean binding
|
||||||
pub style: CheckboxStyle,
|
pub style: CheckboxStyle,
|
||||||
@@ -539,9 +689,8 @@ pub struct CheckboxElement {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct CalculatedTextElement {
|
pub struct CalculatedTextElement {
|
||||||
pub id: String,
|
#[serde(flatten)]
|
||||||
pub position: PositionMode,
|
pub base: ElementBase,
|
||||||
pub size: SizeConstraint,
|
|
||||||
pub style: TextStyle,
|
pub style: TextStyle,
|
||||||
pub expression: String,
|
pub expression: String,
|
||||||
pub format: Option<String>,
|
pub format: Option<String>,
|
||||||
@@ -562,6 +711,10 @@ pub struct Template {
|
|||||||
pub root: ContainerElement,
|
pub root: ContainerElement,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub format_config: Option<FormatConfig>,
|
pub format_config: Option<FormatConfig>,
|
||||||
|
/// Lokalizasyon: "tr-TR", "en-US", "de-DE", "fr-FR" vb.
|
||||||
|
/// Belirtilirse ve format_config yoksa, locale'den FormatConfig türetilir.
|
||||||
|
#[serde(default)]
|
||||||
|
pub locale: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sayı/para birimi formatlama ayarları.
|
/// Sayı/para birimi formatlama ayarları.
|
||||||
@@ -583,10 +736,18 @@ pub struct FormatConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl FormatConfig {
|
impl FormatConfig {
|
||||||
fn default_thousands_sep() -> String { ".".to_string() }
|
fn default_thousands_sep() -> String {
|
||||||
fn default_decimal_sep() -> String { ",".to_string() }
|
".".to_string()
|
||||||
fn default_currency_symbol() -> String { "₺".to_string() }
|
}
|
||||||
fn default_currency_position() -> String { "suffix".to_string() }
|
fn default_decimal_sep() -> String {
|
||||||
|
",".to_string()
|
||||||
|
}
|
||||||
|
fn default_currency_symbol() -> String {
|
||||||
|
"₺".to_string()
|
||||||
|
}
|
||||||
|
fn default_currency_position() -> String {
|
||||||
|
"suffix".to_string()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for FormatConfig {
|
impl Default for FormatConfig {
|
||||||
@@ -599,3 +760,53 @@ impl Default for FormatConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl FormatConfig {
|
||||||
|
/// Locale string'inden FormatConfig türet.
|
||||||
|
/// Desteklenen locale'ler: tr-TR, en-US, de-DE, fr-FR.
|
||||||
|
/// Bilinmeyen locale → Türk formatı (varsayılan).
|
||||||
|
pub fn from_locale(locale: &str) -> Self {
|
||||||
|
match locale {
|
||||||
|
"en-US" | "en" => Self {
|
||||||
|
thousands_separator: ",".to_string(),
|
||||||
|
decimal_separator: ".".to_string(),
|
||||||
|
currency_symbol: "$".to_string(),
|
||||||
|
currency_position: "prefix".to_string(),
|
||||||
|
},
|
||||||
|
"de-DE" | "de" => Self {
|
||||||
|
thousands_separator: ".".to_string(),
|
||||||
|
decimal_separator: ",".to_string(),
|
||||||
|
currency_symbol: "€".to_string(),
|
||||||
|
currency_position: "suffix".to_string(),
|
||||||
|
},
|
||||||
|
"fr-FR" | "fr" => Self {
|
||||||
|
thousands_separator: " ".to_string(),
|
||||||
|
decimal_separator: ",".to_string(),
|
||||||
|
currency_symbol: "€".to_string(),
|
||||||
|
currency_position: "suffix".to_string(),
|
||||||
|
},
|
||||||
|
"en-GB" | "gb" => Self {
|
||||||
|
thousands_separator: ",".to_string(),
|
||||||
|
decimal_separator: ".".to_string(),
|
||||||
|
currency_symbol: "£".to_string(),
|
||||||
|
currency_position: "prefix".to_string(),
|
||||||
|
},
|
||||||
|
// tr-TR veya bilinmeyen → Türk formatı
|
||||||
|
_ => Self::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Template {
|
||||||
|
/// Template'in etkin FormatConfig'ini döndür.
|
||||||
|
/// Öncelik: format_config > locale > varsayılan (tr-TR).
|
||||||
|
pub fn effective_format_config(&self) -> FormatConfig {
|
||||||
|
if let Some(ref fc) = self.format_config {
|
||||||
|
fc.clone()
|
||||||
|
} else if let Some(ref locale) = self.locale {
|
||||||
|
FormatConfig::from_locale(locale)
|
||||||
|
} else {
|
||||||
|
FormatConfig::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
17
dreport-ffi/Cargo.toml
Normal file
17
dreport-ffi/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "dreport-ffi"
|
||||||
|
version = "0.2.0"
|
||||||
|
edition = "2024"
|
||||||
|
description = "C ABI for dreport-service (consumed by NuGet, native hosts, etc.)"
|
||||||
|
license = "MIT"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib", "rlib", "staticlib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
dreport-service = { path = "../dreport-service" }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
cbindgen = { version = "0.28", default-features = false }
|
||||||
46
dreport-ffi/build.rs
Normal file
46
dreport-ffi/build.rs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
//! Generates `include/dreport.h` from the public C ABI on every build.
|
||||||
|
//! Header is checked into the repo so consumers (NuGet wrapper, manual C use)
|
||||||
|
//! don't need a Rust toolchain.
|
||||||
|
|
||||||
|
use std::env;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println!("cargo:rerun-if-changed=src/lib.rs");
|
||||||
|
println!("cargo:rerun-if-changed=cbindgen.toml");
|
||||||
|
println!("cargo:rerun-if-changed=build.rs");
|
||||||
|
|
||||||
|
// Skip generation if explicitly disabled (e.g. when cross-compiling without
|
||||||
|
// host tools, or in cargo publish dry runs).
|
||||||
|
if env::var("DREPORT_FFI_SKIP_CBINDGEN").is_ok() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let crate_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
|
||||||
|
let out_path = PathBuf::from(&crate_dir).join("include").join("dreport.h");
|
||||||
|
if let Some(parent) = out_path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
let config_path = PathBuf::from(&crate_dir).join("cbindgen.toml");
|
||||||
|
let config = if config_path.exists() {
|
||||||
|
cbindgen::Config::from_file(&config_path).expect("invalid cbindgen.toml")
|
||||||
|
} else {
|
||||||
|
cbindgen::Config::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
match cbindgen::Builder::new()
|
||||||
|
.with_crate(crate_dir)
|
||||||
|
.with_config(config)
|
||||||
|
.generate()
|
||||||
|
{
|
||||||
|
Ok(bindings) => {
|
||||||
|
bindings.write_to_file(&out_path);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Don't fail the build on header generation problems — the cdylib
|
||||||
|
// is still usable; the header is a developer convenience.
|
||||||
|
println!("cargo:warning=cbindgen header generation failed: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
dreport-ffi/cbindgen.toml
Normal file
20
dreport-ffi/cbindgen.toml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
language = "C"
|
||||||
|
header = "/* Auto-generated by cbindgen — do not edit. */"
|
||||||
|
include_guard = "DREPORT_H"
|
||||||
|
pragma_once = true
|
||||||
|
no_includes = false
|
||||||
|
sys_includes = ["stdint.h", "stddef.h", "stdbool.h"]
|
||||||
|
cpp_compat = true
|
||||||
|
documentation = true
|
||||||
|
documentation_style = "doxy"
|
||||||
|
style = "type"
|
||||||
|
|
||||||
|
[export]
|
||||||
|
prefix = ""
|
||||||
|
|
||||||
|
[export.rename]
|
||||||
|
"DreportHandle" = "DreportHandle"
|
||||||
|
"DreportBuffer" = "DreportBuffer"
|
||||||
|
|
||||||
|
[parse]
|
||||||
|
parse_deps = false
|
||||||
386
dreport-ffi/src/lib.rs
Normal file
386
dreport-ffi/src/lib.rs
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
//! dreport-ffi
|
||||||
|
//!
|
||||||
|
//! C ABI exposing `dreport_service::DreportService` to non-Rust hosts
|
||||||
|
//! (.NET / NuGet, Node N-API, Python ctypes, etc.).
|
||||||
|
//!
|
||||||
|
//! ## Conventions
|
||||||
|
//!
|
||||||
|
//! - All exported symbols are prefixed `dreport_`.
|
||||||
|
//! - Functions return `i32`: `0 == success`, negative values are error codes.
|
||||||
|
//! See [`error_code`] constants. The detailed message for the most recent
|
||||||
|
//! error on the calling thread is retrievable via [`dreport_last_error`].
|
||||||
|
//! - Outbound dynamic data is returned as a [`DreportBuffer`] (ptr + len + cap).
|
||||||
|
//! The caller MUST hand the buffer back to [`dreport_buffer_free`] to release it.
|
||||||
|
//! - Inbound strings are passed as `(ptr, len)` byte pairs and interpreted as UTF-8.
|
||||||
|
//! - Handles ([`DreportHandle`]) are opaque pointers. Pass them to
|
||||||
|
//! [`dreport_free`] exactly once when done. Never use after free.
|
||||||
|
//! - All exported functions are safe to call from any thread; the underlying
|
||||||
|
//! service is `Sync`.
|
||||||
|
|
||||||
|
#![allow(clippy::missing_safety_doc)] // safety contract documented at module level
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::ffi::c_char;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use dreport_service::{DreportService, ServiceError};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Return codes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub mod error_code {
|
||||||
|
pub const OK: i32 = 0;
|
||||||
|
pub const NULL_HANDLE: i32 = -100;
|
||||||
|
pub const NULL_POINTER: i32 = -101;
|
||||||
|
pub const INVALID_UTF8: i32 = -102;
|
||||||
|
pub const PANIC: i32 = -103;
|
||||||
|
|
||||||
|
// Service-level errors are exposed as the negation of `ServiceError::code()`.
|
||||||
|
// E.g. ServiceError::FontParseFailed (3) → -3 here.
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Opaque handle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Opaque handle backing a `DreportService` shared across the FFI boundary.
|
||||||
|
/// Internally an `Arc<DreportService>`, so the same engine can be cloned and
|
||||||
|
/// driven from multiple threads.
|
||||||
|
pub struct DreportHandle {
|
||||||
|
inner: Arc<DreportService>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Outbound buffer
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Owned byte buffer returned across the FFI boundary. Released with
|
||||||
|
/// [`dreport_buffer_free`].
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct DreportBuffer {
|
||||||
|
pub data: *mut u8,
|
||||||
|
pub len: usize,
|
||||||
|
pub cap: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DreportBuffer {
|
||||||
|
fn empty() -> Self {
|
||||||
|
Self {
|
||||||
|
data: std::ptr::null_mut(),
|
||||||
|
len: 0,
|
||||||
|
cap: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_vec(mut v: Vec<u8>) -> Self {
|
||||||
|
v.shrink_to_fit();
|
||||||
|
let buf = Self {
|
||||||
|
data: v.as_mut_ptr(),
|
||||||
|
len: v.len(),
|
||||||
|
cap: v.capacity(),
|
||||||
|
};
|
||||||
|
std::mem::forget(v);
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Thread-local error state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static LAST_ERROR: RefCell<Option<String>> = const { RefCell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_last_error(msg: impl Into<String>) {
|
||||||
|
LAST_ERROR.with(|cell| *cell.borrow_mut() = Some(msg.into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_last_error() {
|
||||||
|
LAST_ERROR.with(|cell| *cell.borrow_mut() = None);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_service_error(err: ServiceError) -> i32 {
|
||||||
|
let code = -err.code();
|
||||||
|
set_last_error(err.to_string());
|
||||||
|
code
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
unsafe fn handle_ref<'a>(handle: *const DreportHandle) -> Option<&'a DreportHandle> {
|
||||||
|
if handle.is_null() {
|
||||||
|
set_last_error("null handle");
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(unsafe { &*handle })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn slice_from_raw<'a>(ptr: *const u8, len: usize) -> Option<&'a [u8]> {
|
||||||
|
if ptr.is_null() {
|
||||||
|
set_last_error("null pointer for input slice");
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(unsafe { std::slice::from_raw_parts(ptr, len) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn str_from_raw<'a>(ptr: *const u8, len: usize) -> Result<&'a str, i32> {
|
||||||
|
if ptr.is_null() {
|
||||||
|
set_last_error("null pointer for input string");
|
||||||
|
return Err(error_code::NULL_POINTER);
|
||||||
|
}
|
||||||
|
let bytes = unsafe { std::slice::from_raw_parts(ptr, len) };
|
||||||
|
std::str::from_utf8(bytes).map_err(|e| {
|
||||||
|
set_last_error(format!("invalid utf-8: {}", e));
|
||||||
|
error_code::INVALID_UTF8
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn write_buffer(out: *mut DreportBuffer, buffer: DreportBuffer) -> i32 {
|
||||||
|
if out.is_null() {
|
||||||
|
set_last_error("null out buffer pointer");
|
||||||
|
return error_code::NULL_POINTER;
|
||||||
|
}
|
||||||
|
unsafe { *out = buffer };
|
||||||
|
error_code::OK
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Lifecycle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Allocate a new service handle with default embedded fonts.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn dreport_new() -> *mut DreportHandle {
|
||||||
|
clear_last_error();
|
||||||
|
Box::into_raw(Box::new(DreportHandle {
|
||||||
|
inner: Arc::new(DreportService::new()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allocate an empty service handle (no embedded fonts).
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn dreport_new_empty() -> *mut DreportHandle {
|
||||||
|
clear_last_error();
|
||||||
|
Box::into_raw(Box::new(DreportHandle {
|
||||||
|
inner: Arc::new(DreportService::empty()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Release a service handle previously returned by `dreport_new` /
|
||||||
|
/// `dreport_new_empty`. Calling with `NULL` is a no-op.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn dreport_free(handle: *mut DreportHandle) {
|
||||||
|
if handle.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
drop(unsafe { Box::from_raw(handle) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Release a buffer previously produced by an FFI call. Calling with a buffer
|
||||||
|
/// whose `data` is NULL or whose `cap` is 0 is a no-op.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn dreport_buffer_free(buffer: DreportBuffer) {
|
||||||
|
if buffer.data.is_null() || buffer.cap == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
drop(unsafe { Vec::from_raw_parts(buffer.data, buffer.len, buffer.cap) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the static crate version string. Pointer remains valid for the
|
||||||
|
/// lifetime of the loaded library.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn dreport_version() -> *const c_char {
|
||||||
|
static VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), "\0");
|
||||||
|
VERSION.as_ptr() as *const c_char
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy the most recent error message produced on this thread into `out`.
|
||||||
|
/// Returns `error_code::OK` on success (even if there is no error — the buffer
|
||||||
|
/// will simply be empty). The buffer must be released with `dreport_buffer_free`.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn dreport_last_error(out: *mut DreportBuffer) -> i32 {
|
||||||
|
let msg = LAST_ERROR.with(|cell| cell.borrow().clone()).unwrap_or_default();
|
||||||
|
let buf = if msg.is_empty() {
|
||||||
|
DreportBuffer::empty()
|
||||||
|
} else {
|
||||||
|
DreportBuffer::from_vec(msg.into_bytes())
|
||||||
|
};
|
||||||
|
unsafe { write_buffer(out, buf) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Font registry operations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Register a font from raw TTF/OTF bytes.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn dreport_register_font(
|
||||||
|
handle: *const DreportHandle,
|
||||||
|
data: *const u8,
|
||||||
|
len: usize,
|
||||||
|
) -> i32 {
|
||||||
|
clear_last_error();
|
||||||
|
let Some(h) = (unsafe { handle_ref(handle) }) else {
|
||||||
|
return error_code::NULL_HANDLE;
|
||||||
|
};
|
||||||
|
let Some(bytes) = (unsafe { slice_from_raw(data, len) }) else {
|
||||||
|
return error_code::NULL_POINTER;
|
||||||
|
};
|
||||||
|
match h.inner.register_font_bytes(bytes.to_vec()) {
|
||||||
|
Ok(_) => error_code::OK,
|
||||||
|
Err(e) => map_service_error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register every font file in `path` (UTF-8 directory path).
|
||||||
|
/// Returns the count via `out_count` (negative on error).
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn dreport_register_fonts_dir(
|
||||||
|
handle: *const DreportHandle,
|
||||||
|
path: *const u8,
|
||||||
|
path_len: usize,
|
||||||
|
out_count: *mut usize,
|
||||||
|
) -> i32 {
|
||||||
|
clear_last_error();
|
||||||
|
let Some(h) = (unsafe { handle_ref(handle) }) else {
|
||||||
|
return error_code::NULL_HANDLE;
|
||||||
|
};
|
||||||
|
let p = match unsafe { str_from_raw(path, path_len) } {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(rc) => return rc,
|
||||||
|
};
|
||||||
|
match h.inner.register_fonts_directory(p) {
|
||||||
|
Ok(n) => {
|
||||||
|
if !out_count.is_null() {
|
||||||
|
unsafe { *out_count = n };
|
||||||
|
}
|
||||||
|
error_code::OK
|
||||||
|
}
|
||||||
|
Err(e) => map_service_error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all registered font families as a JSON array
|
||||||
|
/// `[{"family":"Noto Sans","variants":[{"weight":400,"italic":false}, ...]}]`.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn dreport_list_fonts_json(
|
||||||
|
handle: *const DreportHandle,
|
||||||
|
out: *mut DreportBuffer,
|
||||||
|
) -> i32 {
|
||||||
|
clear_last_error();
|
||||||
|
let Some(h) = (unsafe { handle_ref(handle) }) else {
|
||||||
|
return error_code::NULL_HANDLE;
|
||||||
|
};
|
||||||
|
let families = h.inner.list_font_families();
|
||||||
|
match serde_json::to_vec(&families) {
|
||||||
|
Ok(v) => unsafe { write_buffer(out, DreportBuffer::from_vec(v)) },
|
||||||
|
Err(e) => {
|
||||||
|
set_last_error(format!("serialize fonts: {}", e));
|
||||||
|
-ServiceError::SerializationFailed(String::new()).code()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the raw bytes for a specific font variant. Sets `out` to an empty buffer
|
||||||
|
/// (data=NULL,len=0) and returns OK if the variant does not exist; this lets
|
||||||
|
/// the caller distinguish "missing" from "error" by inspecting `out.data`.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn dreport_get_font_bytes(
|
||||||
|
handle: *const DreportHandle,
|
||||||
|
family: *const u8,
|
||||||
|
family_len: usize,
|
||||||
|
weight: u16,
|
||||||
|
italic: bool,
|
||||||
|
out: *mut DreportBuffer,
|
||||||
|
) -> i32 {
|
||||||
|
clear_last_error();
|
||||||
|
let Some(h) = (unsafe { handle_ref(handle) }) else {
|
||||||
|
return error_code::NULL_HANDLE;
|
||||||
|
};
|
||||||
|
let fam = match unsafe { str_from_raw(family, family_len) } {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(rc) => return rc,
|
||||||
|
};
|
||||||
|
let buf = match h.inner.get_font_bytes(fam, weight, italic) {
|
||||||
|
Some(v) => DreportBuffer::from_vec(v),
|
||||||
|
None => DreportBuffer::empty(),
|
||||||
|
};
|
||||||
|
unsafe { write_buffer(out, buf) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Render pipeline
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Compute layout. Returns the LayoutResult JSON via `out`.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn dreport_compute_layout(
|
||||||
|
handle: *const DreportHandle,
|
||||||
|
template: *const u8,
|
||||||
|
template_len: usize,
|
||||||
|
data: *const u8,
|
||||||
|
data_len: usize,
|
||||||
|
out: *mut DreportBuffer,
|
||||||
|
) -> i32 {
|
||||||
|
clear_last_error();
|
||||||
|
let Some(h) = (unsafe { handle_ref(handle) }) else {
|
||||||
|
return error_code::NULL_HANDLE;
|
||||||
|
};
|
||||||
|
let tpl = match unsafe { str_from_raw(template, template_len) } {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(rc) => return rc,
|
||||||
|
};
|
||||||
|
let d = match unsafe { str_from_raw(data, data_len) } {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(rc) => return rc,
|
||||||
|
};
|
||||||
|
match h.inner.compute_layout_json(tpl, d) {
|
||||||
|
Ok(json) => unsafe { write_buffer(out, DreportBuffer::from_vec(json.into_bytes())) },
|
||||||
|
Err(e) => map_service_error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render PDF. Returns PDF bytes via `out`.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn dreport_render_pdf(
|
||||||
|
handle: *const DreportHandle,
|
||||||
|
template: *const u8,
|
||||||
|
template_len: usize,
|
||||||
|
data: *const u8,
|
||||||
|
data_len: usize,
|
||||||
|
out: *mut DreportBuffer,
|
||||||
|
) -> i32 {
|
||||||
|
clear_last_error();
|
||||||
|
let Some(h) = (unsafe { handle_ref(handle) }) else {
|
||||||
|
return error_code::NULL_HANDLE;
|
||||||
|
};
|
||||||
|
let tpl = match unsafe { str_from_raw(template, template_len) } {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(rc) => return rc,
|
||||||
|
};
|
||||||
|
let d = match unsafe { str_from_raw(data, data_len) } {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(rc) => return rc,
|
||||||
|
};
|
||||||
|
match h.inner.render_pdf_json(tpl, d) {
|
||||||
|
Ok(pdf) => unsafe { write_buffer(out, DreportBuffer::from_vec(pdf)) },
|
||||||
|
Err(e) => map_service_error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of distinct font families currently registered. Returns a negative
|
||||||
|
/// value if the handle is null.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn dreport_font_family_count(handle: *const DreportHandle) -> isize {
|
||||||
|
clear_last_error();
|
||||||
|
let Some(h) = (unsafe { handle_ref(handle) }) else {
|
||||||
|
return error_code::NULL_HANDLE as isize;
|
||||||
|
};
|
||||||
|
h.inner.font_family_count() as isize
|
||||||
|
}
|
||||||
436
dreport-ffi/tests/ffi.rs
Normal file
436
dreport-ffi/tests/ffi.rs
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
//! Integration tests that drive the C ABI directly. These tests treat the FFI
|
||||||
|
//! crate exactly the way a foreign-language host (NuGet, P/Invoke) would —
|
||||||
|
//! through opaque pointers, byte buffers, and return codes. They are the
|
||||||
|
//! contract test suite for non-Rust consumers.
|
||||||
|
|
||||||
|
use dreport_ffi::*;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
const TEMPLATE: &str = r#"{
|
||||||
|
"id": "ffi",
|
||||||
|
"name": "FFI Test",
|
||||||
|
"page": { "width": 210, "height": 297 },
|
||||||
|
"fonts": ["Noto Sans"],
|
||||||
|
"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": "title",
|
||||||
|
"type": "static_text",
|
||||||
|
"position": { "type": "flow" },
|
||||||
|
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
|
||||||
|
"style": { "fontSize": 14, "fontWeight": "bold" },
|
||||||
|
"content": "FFI"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
const DATA: &str = "{}";
|
||||||
|
const NOTO_SANS_REGULAR: &[u8] =
|
||||||
|
include_bytes!("../../dreport-service/assets/fonts/NotoSans-Regular.ttf");
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Small RAII wrappers around the raw FFI types so each test stays terse.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct Handle(*mut DreportHandle);
|
||||||
|
impl Handle {
|
||||||
|
fn new() -> Self {
|
||||||
|
let h = dreport_new();
|
||||||
|
assert!(!h.is_null(), "dreport_new must succeed");
|
||||||
|
Self(h)
|
||||||
|
}
|
||||||
|
fn empty() -> Self {
|
||||||
|
let h = dreport_new_empty();
|
||||||
|
assert!(!h.is_null());
|
||||||
|
Self(h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Drop for Handle {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe { dreport_free(self.0) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// SAFETY: Underlying handle wraps an Arc<DreportService>; the service is Sync.
|
||||||
|
unsafe impl Send for Handle {}
|
||||||
|
unsafe impl Sync for Handle {}
|
||||||
|
|
||||||
|
struct OwnedBuffer(DreportBuffer);
|
||||||
|
impl OwnedBuffer {
|
||||||
|
fn empty() -> Self {
|
||||||
|
Self(DreportBuffer {
|
||||||
|
data: std::ptr::null_mut(),
|
||||||
|
len: 0,
|
||||||
|
cap: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn as_slice(&self) -> &[u8] {
|
||||||
|
if self.0.data.is_null() {
|
||||||
|
&[]
|
||||||
|
} else {
|
||||||
|
unsafe { std::slice::from_raw_parts(self.0.data, self.0.len) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn as_str(&self) -> &str {
|
||||||
|
std::str::from_utf8(self.as_slice()).expect("valid utf8")
|
||||||
|
}
|
||||||
|
fn ptr(&mut self) -> *mut DreportBuffer {
|
||||||
|
&mut self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Drop for OwnedBuffer {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let buf = std::mem::replace(
|
||||||
|
&mut self.0,
|
||||||
|
DreportBuffer {
|
||||||
|
data: std::ptr::null_mut(),
|
||||||
|
len: 0,
|
||||||
|
cap: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
unsafe { dreport_buffer_free(buf) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn last_error() -> String {
|
||||||
|
let mut buf = OwnedBuffer::empty();
|
||||||
|
let rc = unsafe { dreport_last_error(buf.ptr()) };
|
||||||
|
assert_eq!(rc, error_code::OK);
|
||||||
|
buf.as_str().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Lifecycle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_and_free_round_trips() {
|
||||||
|
let h = dreport_new();
|
||||||
|
assert!(!h.is_null());
|
||||||
|
unsafe { dreport_free(h) };
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn free_null_is_safe() {
|
||||||
|
unsafe { dreport_free(std::ptr::null_mut()) };
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn buffer_free_null_is_safe() {
|
||||||
|
unsafe {
|
||||||
|
dreport_buffer_free(DreportBuffer {
|
||||||
|
data: std::ptr::null_mut(),
|
||||||
|
len: 0,
|
||||||
|
cap: 0,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn version_returns_valid_c_string() {
|
||||||
|
let ptr = dreport_version();
|
||||||
|
assert!(!ptr.is_null());
|
||||||
|
let cstr = unsafe { std::ffi::CStr::from_ptr(ptr) };
|
||||||
|
let s = cstr.to_str().unwrap();
|
||||||
|
assert!(!s.is_empty());
|
||||||
|
assert!(s.chars().next().unwrap().is_ascii_digit());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn embedded_default_handle_has_fonts() {
|
||||||
|
let h = Handle::new();
|
||||||
|
let count = unsafe { dreport_font_family_count(h.0) };
|
||||||
|
assert!(count >= 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_handle_has_no_fonts() {
|
||||||
|
let h = Handle::empty();
|
||||||
|
let count = unsafe { dreport_font_family_count(h.0) };
|
||||||
|
assert_eq!(count, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Null-handle guard rails
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn null_handle_returns_null_handle_code() {
|
||||||
|
let mut buf = OwnedBuffer::empty();
|
||||||
|
let rc = unsafe { dreport_list_fonts_json(std::ptr::null(), buf.ptr()) };
|
||||||
|
assert_eq!(rc, error_code::NULL_HANDLE);
|
||||||
|
assert!(!last_error().is_empty(), "error message must be set");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn null_handle_count_returns_negative() {
|
||||||
|
let count = unsafe { dreport_font_family_count(std::ptr::null()) };
|
||||||
|
assert!(count < 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_with_null_template_returns_null_pointer_code() {
|
||||||
|
let h = Handle::new();
|
||||||
|
let mut out = OwnedBuffer::empty();
|
||||||
|
let rc = unsafe {
|
||||||
|
dreport_render_pdf(
|
||||||
|
h.0,
|
||||||
|
std::ptr::null(),
|
||||||
|
0,
|
||||||
|
DATA.as_ptr(),
|
||||||
|
DATA.len(),
|
||||||
|
out.ptr(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
assert_eq!(rc, error_code::NULL_POINTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Font registration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn register_font_valid_bytes() {
|
||||||
|
let h = Handle::empty();
|
||||||
|
let rc = unsafe { dreport_register_font(h.0, NOTO_SANS_REGULAR.as_ptr(), NOTO_SANS_REGULAR.len()) };
|
||||||
|
assert_eq!(rc, error_code::OK);
|
||||||
|
assert!(unsafe { dreport_font_family_count(h.0) } >= 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn register_font_invalid_bytes_returns_negative_service_code() {
|
||||||
|
let h = Handle::empty();
|
||||||
|
let garbage = b"not a font";
|
||||||
|
let rc = unsafe { dreport_register_font(h.0, garbage.as_ptr(), garbage.len()) };
|
||||||
|
assert_eq!(rc, -3, "ServiceError::FontParseFailed code is 3 → -3 over FFI");
|
||||||
|
let msg = last_error();
|
||||||
|
assert!(msg.to_lowercase().contains("font"), "error msg: {}", msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn register_fonts_dir_invalid_path_sets_error() {
|
||||||
|
let h = Handle::empty();
|
||||||
|
let path = "/zzz/no/such/dreport/path";
|
||||||
|
let mut out_count: usize = 0;
|
||||||
|
let rc = unsafe {
|
||||||
|
dreport_register_fonts_dir(h.0, path.as_ptr(), path.len(), &mut out_count)
|
||||||
|
};
|
||||||
|
assert!(rc < 0);
|
||||||
|
assert_eq!(out_count, 0);
|
||||||
|
assert!(!last_error().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn register_fonts_dir_valid_path_loads_count() {
|
||||||
|
let h = Handle::empty();
|
||||||
|
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("../dreport-service/assets/fonts");
|
||||||
|
let path_str = path.to_string_lossy().into_owned();
|
||||||
|
let mut out_count: usize = 0;
|
||||||
|
let rc = unsafe {
|
||||||
|
dreport_register_fonts_dir(h.0, path_str.as_ptr(), path_str.len(), &mut out_count)
|
||||||
|
};
|
||||||
|
assert_eq!(rc, error_code::OK, "{}", last_error());
|
||||||
|
assert!(out_count >= 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_fonts_json_is_valid_array() {
|
||||||
|
let h = Handle::new();
|
||||||
|
let mut out = OwnedBuffer::empty();
|
||||||
|
let rc = unsafe { dreport_list_fonts_json(h.0, out.ptr()) };
|
||||||
|
assert_eq!(rc, error_code::OK);
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(out.as_str()).unwrap();
|
||||||
|
assert!(parsed.is_array());
|
||||||
|
assert!(!parsed.as_array().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_font_bytes_existing_returns_data() {
|
||||||
|
let h = Handle::new();
|
||||||
|
let family = "Noto Sans";
|
||||||
|
let mut out = OwnedBuffer::empty();
|
||||||
|
let rc = unsafe {
|
||||||
|
dreport_get_font_bytes(h.0, family.as_ptr(), family.len(), 400, false, out.ptr())
|
||||||
|
};
|
||||||
|
assert_eq!(rc, error_code::OK);
|
||||||
|
assert!(out.as_slice().len() > 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_font_bytes_missing_returns_ok_with_empty_buffer() {
|
||||||
|
let h = Handle::new();
|
||||||
|
let family = "DoesNotExist";
|
||||||
|
let mut out = OwnedBuffer::empty();
|
||||||
|
let rc = unsafe {
|
||||||
|
dreport_get_font_bytes(h.0, family.as_ptr(), family.len(), 400, false, out.ptr())
|
||||||
|
};
|
||||||
|
assert_eq!(rc, error_code::OK);
|
||||||
|
assert!(out.as_slice().is_empty());
|
||||||
|
assert!(out.0.data.is_null());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Render pipeline
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compute_layout_round_trip() {
|
||||||
|
let h = Handle::new();
|
||||||
|
let mut out = OwnedBuffer::empty();
|
||||||
|
let rc = unsafe {
|
||||||
|
dreport_compute_layout(
|
||||||
|
h.0,
|
||||||
|
TEMPLATE.as_ptr(),
|
||||||
|
TEMPLATE.len(),
|
||||||
|
DATA.as_ptr(),
|
||||||
|
DATA.len(),
|
||||||
|
out.ptr(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
assert_eq!(rc, error_code::OK, "{}", last_error());
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(out.as_str()).unwrap();
|
||||||
|
assert!(parsed["pages"].is_array());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_pdf_returns_pdf_magic_header() {
|
||||||
|
let h = Handle::new();
|
||||||
|
let mut out = OwnedBuffer::empty();
|
||||||
|
let rc = unsafe {
|
||||||
|
dreport_render_pdf(
|
||||||
|
h.0,
|
||||||
|
TEMPLATE.as_ptr(),
|
||||||
|
TEMPLATE.len(),
|
||||||
|
DATA.as_ptr(),
|
||||||
|
DATA.len(),
|
||||||
|
out.ptr(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
assert_eq!(rc, error_code::OK, "{}", last_error());
|
||||||
|
let bytes = out.as_slice();
|
||||||
|
assert!(bytes.starts_with(b"%PDF-"), "missing magic header");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_with_invalid_template_json_sets_error() {
|
||||||
|
let h = Handle::new();
|
||||||
|
let bad = b"{not json";
|
||||||
|
let mut out = OwnedBuffer::empty();
|
||||||
|
let rc = unsafe {
|
||||||
|
dreport_render_pdf(
|
||||||
|
h.0,
|
||||||
|
bad.as_ptr(),
|
||||||
|
bad.len(),
|
||||||
|
DATA.as_ptr(),
|
||||||
|
DATA.len(),
|
||||||
|
out.ptr(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
assert_eq!(rc, -1, "ServiceError::InvalidTemplateJson → -1");
|
||||||
|
assert!(!last_error().is_empty());
|
||||||
|
assert!(out.as_slice().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Concurrency
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn concurrent_independent_handles() {
|
||||||
|
let success = Arc::new(AtomicUsize::new(0));
|
||||||
|
let mut threads = Vec::new();
|
||||||
|
for _ in 0..6 {
|
||||||
|
let s = Arc::clone(&success);
|
||||||
|
threads.push(thread::spawn(move || {
|
||||||
|
let h = Handle::new();
|
||||||
|
let mut out = OwnedBuffer::empty();
|
||||||
|
let rc = unsafe {
|
||||||
|
dreport_render_pdf(
|
||||||
|
h.0,
|
||||||
|
TEMPLATE.as_ptr(),
|
||||||
|
TEMPLATE.len(),
|
||||||
|
DATA.as_ptr(),
|
||||||
|
DATA.len(),
|
||||||
|
out.ptr(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if rc == error_code::OK && out.as_slice().starts_with(b"%PDF-") {
|
||||||
|
s.fetch_add(1, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for t in threads {
|
||||||
|
t.join().unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(success.load(Ordering::SeqCst), 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn concurrent_shared_handle() {
|
||||||
|
// The handle itself is owned by one thread, but the underlying service is
|
||||||
|
// an Arc<DreportService>, so internally a shared engine is fine. To test
|
||||||
|
// the most realistic NuGet scenario (one process-wide engine) we instead
|
||||||
|
// create per-thread handles backed by parallel `dreport_new` calls.
|
||||||
|
let success = Arc::new(AtomicUsize::new(0));
|
||||||
|
let mut threads = Vec::new();
|
||||||
|
for _ in 0..4 {
|
||||||
|
let s = Arc::clone(&success);
|
||||||
|
threads.push(thread::spawn(move || {
|
||||||
|
for _ in 0..4 {
|
||||||
|
let h = Handle::new();
|
||||||
|
let mut out = OwnedBuffer::empty();
|
||||||
|
let rc = unsafe {
|
||||||
|
dreport_render_pdf(
|
||||||
|
h.0,
|
||||||
|
TEMPLATE.as_ptr(),
|
||||||
|
TEMPLATE.len(),
|
||||||
|
DATA.as_ptr(),
|
||||||
|
DATA.len(),
|
||||||
|
out.ptr(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if rc == 0 {
|
||||||
|
s.fetch_add(1, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for t in threads {
|
||||||
|
t.join().unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(success.load(Ordering::SeqCst), 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Last-error semantics
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn successful_call_clears_previous_error() {
|
||||||
|
let h = Handle::new();
|
||||||
|
|
||||||
|
// Provoke an error first.
|
||||||
|
let rc = unsafe { dreport_register_font(h.0, b"x".as_ptr(), 1) };
|
||||||
|
assert!(rc < 0);
|
||||||
|
assert!(!last_error().is_empty());
|
||||||
|
|
||||||
|
// A subsequent successful call must clear it.
|
||||||
|
let count = unsafe { dreport_font_family_count(h.0) };
|
||||||
|
assert!(count >= 1);
|
||||||
|
assert!(
|
||||||
|
last_error().is_empty(),
|
||||||
|
"successful call should clear last_error"
|
||||||
|
);
|
||||||
|
}
|
||||||
21
dreport-service/Cargo.toml
Normal file
21
dreport-service/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
[package]
|
||||||
|
name = "dreport-service"
|
||||||
|
version = "0.2.0"
|
||||||
|
edition = "2024"
|
||||||
|
description = "High-level orchestration service for dreport (font registry + render pipeline)"
|
||||||
|
license = "MIT"
|
||||||
|
publish = ["gitea"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
dreport-core = { version = "0.2.0", path = "../core", registry = "gitea" }
|
||||||
|
dreport-layout = { version = "0.2.0", path = "../layout-engine", registry = "gitea" }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
thiserror = "2"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["embedded-fonts"]
|
||||||
|
embedded-fonts = []
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
48
dreport-service/src/error.rs
Normal file
48
dreport-service/src/error.rs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// dreport-service üzerinden yapılan tüm operasyonların hata tipi.
|
||||||
|
/// FFI ve HTTP adapter'ları bu enum'u kendi error formatlarına map'ler.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ServiceError {
|
||||||
|
#[error("invalid template JSON: {0}")]
|
||||||
|
InvalidTemplateJson(String),
|
||||||
|
|
||||||
|
#[error("invalid data JSON: {0}")]
|
||||||
|
InvalidDataJson(String),
|
||||||
|
|
||||||
|
#[error("font parse failed: bytes do not contain a valid TTF/OTF face")]
|
||||||
|
FontParseFailed,
|
||||||
|
|
||||||
|
#[error("font directory not found: {0}")]
|
||||||
|
FontDirNotFound(String),
|
||||||
|
|
||||||
|
#[error("font directory read error: {0}")]
|
||||||
|
FontDirRead(String),
|
||||||
|
|
||||||
|
#[error("layout computation failed: {0}")]
|
||||||
|
LayoutFailed(String),
|
||||||
|
|
||||||
|
#[error("pdf rendering failed: {0}")]
|
||||||
|
PdfFailed(String),
|
||||||
|
|
||||||
|
#[error("layout result serialization failed: {0}")]
|
||||||
|
SerializationFailed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServiceError {
|
||||||
|
/// Stable numeric code for FFI consumers.
|
||||||
|
pub fn code(&self) -> i32 {
|
||||||
|
match self {
|
||||||
|
Self::InvalidTemplateJson(_) => 1,
|
||||||
|
Self::InvalidDataJson(_) => 2,
|
||||||
|
Self::FontParseFailed => 3,
|
||||||
|
Self::FontDirNotFound(_) => 4,
|
||||||
|
Self::FontDirRead(_) => 5,
|
||||||
|
Self::LayoutFailed(_) => 6,
|
||||||
|
Self::PdfFailed(_) => 7,
|
||||||
|
Self::SerializationFailed(_) => 8,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type ServiceResult<T> = Result<T, ServiceError>;
|
||||||
164
dreport-service/src/font_registry.rs
Normal file
164
dreport-service/src/font_registry.rs
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
use dreport_layout::FontData;
|
||||||
|
use dreport_layout::font_meta::{self, FontFamilyInfo, FontVariantKey};
|
||||||
|
use dreport_layout::font_provider::FontProvider;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::error::{ServiceError, ServiceResult};
|
||||||
|
|
||||||
|
/// Default font family that is always included in the layout font set when
|
||||||
|
/// available. Matches the engine's fallback behaviour.
|
||||||
|
pub(crate) const DEFAULT_FAMILY: &str = "noto sans";
|
||||||
|
|
||||||
|
/// Internal font registry. Manages parsed TTF/OTF faces indexed by family + variant.
|
||||||
|
/// Not exported directly — accessed through `DreportService`.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct FontRegistry {
|
||||||
|
/// family_lower -> variant_key -> FontData
|
||||||
|
families: HashMap<String, HashMap<FontVariantKey, FontData>>,
|
||||||
|
/// Original-case family names for display (`list_families`).
|
||||||
|
family_names: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FontRegistry {
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a font from raw bytes. Returns parsed family info on success.
|
||||||
|
pub(crate) fn register_bytes(&mut self, data: Vec<u8>) -> ServiceResult<RegisteredFont> {
|
||||||
|
let meta = font_meta::parse_font_meta(&data).ok_or(ServiceError::FontParseFailed)?;
|
||||||
|
let family_lower = meta.family.to_lowercase();
|
||||||
|
let variant_key = meta.variant_key();
|
||||||
|
|
||||||
|
self.family_names
|
||||||
|
.entry(family_lower.clone())
|
||||||
|
.or_insert_with(|| meta.family.clone());
|
||||||
|
|
||||||
|
let font_data = FontData::new(meta.family.clone(), meta.weight, meta.italic, data);
|
||||||
|
self.families
|
||||||
|
.entry(family_lower)
|
||||||
|
.or_default()
|
||||||
|
.insert(variant_key.clone(), font_data);
|
||||||
|
|
||||||
|
Ok(RegisteredFont {
|
||||||
|
family: meta.family,
|
||||||
|
weight: variant_key.weight,
|
||||||
|
italic: variant_key.italic,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register all `.ttf`/`.otf` files in the given directory.
|
||||||
|
/// Returns the count of successfully registered files; per-file parse
|
||||||
|
/// failures are silently skipped to mirror the previous backend behaviour.
|
||||||
|
pub(crate) fn register_directory(&mut self, dir: &Path) -> ServiceResult<usize> {
|
||||||
|
if !dir.exists() {
|
||||||
|
return Err(ServiceError::FontDirNotFound(dir.display().to_string()));
|
||||||
|
}
|
||||||
|
if !dir.is_dir() {
|
||||||
|
return Err(ServiceError::FontDirNotFound(dir.display().to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries = std::fs::read_dir(dir).map_err(|e| ServiceError::FontDirRead(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut count = 0_usize;
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
let is_font = path
|
||||||
|
.extension()
|
||||||
|
.is_some_and(|e| e == "ttf" || e == "otf" || e == "TTF" || e == "OTF");
|
||||||
|
if !is_font {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(data) = std::fs::read(&path)
|
||||||
|
&& self.register_bytes(data).is_ok()
|
||||||
|
{
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn get_font_bytes(
|
||||||
|
&self,
|
||||||
|
family: &str,
|
||||||
|
weight: u16,
|
||||||
|
italic: bool,
|
||||||
|
) -> Option<&[u8]> {
|
||||||
|
let family_lower = family.to_lowercase();
|
||||||
|
let key = FontVariantKey { weight, italic };
|
||||||
|
self.families
|
||||||
|
.get(&family_lower)
|
||||||
|
.and_then(|variants| variants.get(&key))
|
||||||
|
.map(|fd| fd.data.as_slice())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the FontData set for a template. Always includes the default
|
||||||
|
/// family (Noto Sans) plus any explicitly requested families.
|
||||||
|
pub(crate) fn fonts_for_families(&self, families: &[String]) -> Vec<FontData> {
|
||||||
|
let mut result = Vec::new();
|
||||||
|
let mut loaded: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||||
|
|
||||||
|
let mut to_load: Vec<String> = vec![DEFAULT_FAMILY.to_string()];
|
||||||
|
for f in families {
|
||||||
|
let fl = f.to_lowercase();
|
||||||
|
if !to_load.contains(&fl) {
|
||||||
|
to_load.push(fl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for family_lower in &to_load {
|
||||||
|
if !loaded.insert(family_lower.clone()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(variants) = self.families.get(family_lower) {
|
||||||
|
for fd in variants.values() {
|
||||||
|
result.push(fd.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn family_count(&self) -> usize {
|
||||||
|
self.families.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FontProvider for FontRegistry {
|
||||||
|
fn list_families(&self) -> Vec<FontFamilyInfo> {
|
||||||
|
self.families
|
||||||
|
.iter()
|
||||||
|
.map(|(family_lower, variants)| {
|
||||||
|
let family = self
|
||||||
|
.family_names
|
||||||
|
.get(family_lower)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| family_lower.clone());
|
||||||
|
FontFamilyInfo {
|
||||||
|
family,
|
||||||
|
variants: variants.keys().cloned().collect(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_font(&self, family: &str, weight: u16, italic: bool) -> Option<FontData> {
|
||||||
|
let family_lower = family.to_lowercase();
|
||||||
|
let key = FontVariantKey { weight, italic };
|
||||||
|
self.families
|
||||||
|
.get(&family_lower)
|
||||||
|
.and_then(|variants| variants.get(&key))
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of registering a single font, returned to callers that need to
|
||||||
|
/// confirm what variant was actually parsed.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct RegisteredFont {
|
||||||
|
pub family: String,
|
||||||
|
pub weight: u16,
|
||||||
|
pub italic: bool,
|
||||||
|
}
|
||||||
203
dreport-service/src/lib.rs
Normal file
203
dreport-service/src/lib.rs
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
//! dreport-service
|
||||||
|
//!
|
||||||
|
//! High-level orchestration layer that sits on top of `dreport-layout`.
|
||||||
|
//! Responsible for:
|
||||||
|
//! - Font registry management (embedded defaults + external loading)
|
||||||
|
//! - Template + data → LayoutResult JSON
|
||||||
|
//! - Template + data → PDF bytes
|
||||||
|
//!
|
||||||
|
//! Consumed by:
|
||||||
|
//! - `dreport-backend` (Axum HTTP adapter)
|
||||||
|
//! - `dreport-ffi` (C ABI for NuGet etc.)
|
||||||
|
//! - Any other Rust host (CLI, gRPC, ...)
|
||||||
|
|
||||||
|
mod error;
|
||||||
|
mod font_registry;
|
||||||
|
|
||||||
|
pub use dreport_core::models::Template;
|
||||||
|
pub use dreport_layout::FontData;
|
||||||
|
pub use dreport_layout::LayoutResult;
|
||||||
|
pub use dreport_layout::font_meta::{FontFamilyInfo, FontVariantKey};
|
||||||
|
pub use dreport_layout::font_provider::FontProvider;
|
||||||
|
pub use error::{ServiceError, ServiceResult};
|
||||||
|
pub use font_registry::RegisteredFont;
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::RwLock;
|
||||||
|
|
||||||
|
use font_registry::FontRegistry;
|
||||||
|
|
||||||
|
/// Embedded default fonts compiled into the binary when the
|
||||||
|
/// `embedded-fonts` feature is enabled (default).
|
||||||
|
#[cfg(feature = "embedded-fonts")]
|
||||||
|
const EMBEDDED_FONTS: &[(&str, &[u8])] = &[
|
||||||
|
(
|
||||||
|
"NotoSans-Regular",
|
||||||
|
include_bytes!("../assets/fonts/NotoSans-Regular.ttf"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"NotoSans-Bold",
|
||||||
|
include_bytes!("../assets/fonts/NotoSans-Bold.ttf"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"NotoSans-Italic",
|
||||||
|
include_bytes!("../assets/fonts/NotoSans-Italic.ttf"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"NotoSans-BoldItalic",
|
||||||
|
include_bytes!("../assets/fonts/NotoSans-BoldItalic.ttf"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"NotoSansMono-Regular",
|
||||||
|
include_bytes!("../assets/fonts/NotoSansMono-Regular.ttf"),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Main service handle. Thread-safe; share across threads via `Arc`.
|
||||||
|
///
|
||||||
|
/// Holds the font registry and exposes layout + PDF rendering operations.
|
||||||
|
/// All mutating operations (font registration) take `&self` and use internal
|
||||||
|
/// synchronization, so multiple readers (renders) and writers (font loads)
|
||||||
|
/// can coexist safely.
|
||||||
|
pub struct DreportService {
|
||||||
|
registry: RwLock<FontRegistry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DreportService {
|
||||||
|
/// Create a new service. Embedded default fonts are loaded automatically
|
||||||
|
/// when the `embedded-fonts` feature is on (default).
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let mut reg = FontRegistry::new();
|
||||||
|
#[cfg(feature = "embedded-fonts")]
|
||||||
|
for (_name, bytes) in EMBEDDED_FONTS {
|
||||||
|
// Embedded fonts must parse — failure is a build-time bug.
|
||||||
|
let _ = reg.register_bytes(bytes.to_vec());
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
registry: RwLock::new(reg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a service without the embedded defaults, regardless of feature
|
||||||
|
/// flags. Useful for tests and minimal embedders.
|
||||||
|
pub fn empty() -> Self {
|
||||||
|
Self {
|
||||||
|
registry: RwLock::new(FontRegistry::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// Font registry operations
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Register a single font from raw TTF/OTF bytes.
|
||||||
|
pub fn register_font_bytes(&self, data: Vec<u8>) -> ServiceResult<RegisteredFont> {
|
||||||
|
let mut reg = self.registry.write().expect("font registry poisoned");
|
||||||
|
reg.register_bytes(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register every `.ttf` / `.otf` file in `dir` (non-recursive).
|
||||||
|
/// Returns the number of fonts successfully registered.
|
||||||
|
pub fn register_fonts_directory<P: AsRef<Path>>(&self, dir: P) -> ServiceResult<usize> {
|
||||||
|
let mut reg = self.registry.write().expect("font registry poisoned");
|
||||||
|
reg.register_directory(dir.as_ref())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all currently-registered font families with their available variants.
|
||||||
|
pub fn list_font_families(&self) -> Vec<FontFamilyInfo> {
|
||||||
|
let reg = self.registry.read().expect("font registry poisoned");
|
||||||
|
reg.list_families()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the raw bytes for a specific font variant.
|
||||||
|
pub fn get_font_bytes(&self, family: &str, weight: u16, italic: bool) -> Option<Vec<u8>> {
|
||||||
|
let reg = self.registry.read().expect("font registry poisoned");
|
||||||
|
reg.get_font_bytes(family, weight, italic).map(<[u8]>::to_vec)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of distinct font families currently registered.
|
||||||
|
pub fn font_family_count(&self) -> usize {
|
||||||
|
let reg = self.registry.read().expect("font registry poisoned");
|
||||||
|
reg.family_count()
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// Render pipeline
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Compute layout from JSON inputs. Returns the LayoutResult serialized as JSON.
|
||||||
|
pub fn compute_layout_json(
|
||||||
|
&self,
|
||||||
|
template_json: &str,
|
||||||
|
data_json: &str,
|
||||||
|
) -> ServiceResult<String> {
|
||||||
|
let template: Template = serde_json::from_str(template_json)
|
||||||
|
.map_err(|e| ServiceError::InvalidTemplateJson(e.to_string()))?;
|
||||||
|
let data: serde_json::Value = serde_json::from_str(data_json)
|
||||||
|
.map_err(|e| ServiceError::InvalidDataJson(e.to_string()))?;
|
||||||
|
let layout = self.compute_layout(&template, &data)?;
|
||||||
|
serde_json::to_string(&layout).map_err(|e| ServiceError::SerializationFailed(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Typed layout computation for Rust callers.
|
||||||
|
pub fn compute_layout(
|
||||||
|
&self,
|
||||||
|
template: &Template,
|
||||||
|
data: &serde_json::Value,
|
||||||
|
) -> ServiceResult<LayoutResult> {
|
||||||
|
let fonts = self.fonts_for_template(&template.fonts);
|
||||||
|
dreport_layout::compute_layout(template, data, &fonts)
|
||||||
|
.map_err(|e| ServiceError::LayoutFailed(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a PDF from JSON inputs.
|
||||||
|
pub fn render_pdf_json(
|
||||||
|
&self,
|
||||||
|
template_json: &str,
|
||||||
|
data_json: &str,
|
||||||
|
) -> ServiceResult<Vec<u8>> {
|
||||||
|
let template: Template = serde_json::from_str(template_json)
|
||||||
|
.map_err(|e| ServiceError::InvalidTemplateJson(e.to_string()))?;
|
||||||
|
let data: serde_json::Value = serde_json::from_str(data_json)
|
||||||
|
.map_err(|e| ServiceError::InvalidDataJson(e.to_string()))?;
|
||||||
|
self.render_pdf(&template, &data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Typed PDF rendering for Rust callers.
|
||||||
|
pub fn render_pdf(
|
||||||
|
&self,
|
||||||
|
template: &Template,
|
||||||
|
data: &serde_json::Value,
|
||||||
|
) -> ServiceResult<Vec<u8>> {
|
||||||
|
let fonts = self.fonts_for_template(&template.fonts);
|
||||||
|
let layout = dreport_layout::compute_layout(template, data, &fonts)
|
||||||
|
.map_err(|e| ServiceError::LayoutFailed(e.to_string()))?;
|
||||||
|
dreport_layout::pdf_render::render_pdf(&layout, &fonts)
|
||||||
|
.map_err(ServiceError::PdfFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot the FontData set required for the given template families.
|
||||||
|
/// Held briefly under read lock then released — the resulting Vec is owned.
|
||||||
|
fn fonts_for_template(&self, families: &[String]) -> Vec<FontData> {
|
||||||
|
let reg = self.registry.read().expect("font registry poisoned");
|
||||||
|
reg.fonts_for_families(families)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DreportService {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allow consumers to use `&DreportService` wherever a `FontProvider` is expected.
|
||||||
|
impl FontProvider for DreportService {
|
||||||
|
fn list_families(&self) -> Vec<FontFamilyInfo> {
|
||||||
|
self.list_font_families()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_font(&self, family: &str, weight: u16, italic: bool) -> Option<FontData> {
|
||||||
|
let reg = self.registry.read().expect("font registry poisoned");
|
||||||
|
reg.load_font(family, weight, italic)
|
||||||
|
}
|
||||||
|
}
|
||||||
297
dreport-service/tests/service.rs
Normal file
297
dreport-service/tests/service.rs
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
//! Integration tests for `DreportService`.
|
||||||
|
//!
|
||||||
|
//! These tests exercise the public API as it would be consumed by the Axum
|
||||||
|
//! adapter, the FFI layer, and any other host. Anything that breaks here
|
||||||
|
//! breaks behaviour for every consumer simultaneously, so failures should
|
||||||
|
//! be treated as a contract change.
|
||||||
|
|
||||||
|
use dreport_service::{DreportService, ServiceError};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
const VALID_TEMPLATE: &str = r#"{
|
||||||
|
"id": "test",
|
||||||
|
"name": "Test",
|
||||||
|
"page": { "width": 210, "height": 297 },
|
||||||
|
"fonts": ["Noto Sans"],
|
||||||
|
"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": "title",
|
||||||
|
"type": "static_text",
|
||||||
|
"position": { "type": "flow" },
|
||||||
|
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
|
||||||
|
"style": { "fontSize": 14, "fontWeight": "bold" },
|
||||||
|
"content": "Hello dreport"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
const VALID_DATA: &str = r#"{}"#;
|
||||||
|
|
||||||
|
const NOTO_SANS_REGULAR: &[u8] = include_bytes!("../assets/fonts/NotoSans-Regular.ttf");
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Service initialization
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_loads_embedded_fonts() {
|
||||||
|
let svc = DreportService::new();
|
||||||
|
assert!(
|
||||||
|
svc.font_family_count() >= 1,
|
||||||
|
"embedded-fonts feature should provide at least one family"
|
||||||
|
);
|
||||||
|
let names: Vec<String> = svc
|
||||||
|
.list_font_families()
|
||||||
|
.into_iter()
|
||||||
|
.map(|f| f.family.to_lowercase())
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
names.iter().any(|n| n.contains("noto")),
|
||||||
|
"Noto Sans family expected, got {:?}",
|
||||||
|
names
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_starts_with_no_fonts() {
|
||||||
|
let svc = DreportService::empty();
|
||||||
|
assert_eq!(svc.font_family_count(), 0);
|
||||||
|
assert!(svc.list_font_families().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Font registration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn register_font_bytes_valid_ttf() {
|
||||||
|
let svc = DreportService::empty();
|
||||||
|
let registered = svc
|
||||||
|
.register_font_bytes(NOTO_SANS_REGULAR.to_vec())
|
||||||
|
.expect("valid TTF should register");
|
||||||
|
assert!(registered.family.to_lowercase().contains("noto"));
|
||||||
|
assert_eq!(svc.font_family_count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn register_font_bytes_invalid_returns_parse_error() {
|
||||||
|
let svc = DreportService::empty();
|
||||||
|
let err = svc
|
||||||
|
.register_font_bytes(b"not a font".to_vec())
|
||||||
|
.expect_err("garbage bytes must not parse");
|
||||||
|
assert!(matches!(err, ServiceError::FontParseFailed));
|
||||||
|
assert_eq!(err.code(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn register_fonts_directory_loads_files() {
|
||||||
|
let svc = DreportService::empty();
|
||||||
|
let fonts_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/fonts");
|
||||||
|
let count = svc
|
||||||
|
.register_fonts_directory(&fonts_dir)
|
||||||
|
.expect("assets/fonts must be readable");
|
||||||
|
assert!(count >= 1, "at least one font expected in assets/fonts");
|
||||||
|
assert!(svc.font_family_count() >= 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn register_fonts_directory_missing_returns_error() {
|
||||||
|
let svc = DreportService::empty();
|
||||||
|
let err = svc
|
||||||
|
.register_fonts_directory("/no/such/dreport/fonts/path/zzz")
|
||||||
|
.expect_err("missing directory must error");
|
||||||
|
assert!(matches!(err, ServiceError::FontDirNotFound(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn register_fonts_directory_skips_non_font_files() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(dir.path().join("readme.txt"), b"hi").unwrap();
|
||||||
|
std::fs::write(dir.path().join("font.ttf"), NOTO_SANS_REGULAR).unwrap();
|
||||||
|
|
||||||
|
let svc = DreportService::empty();
|
||||||
|
let count = svc.register_fonts_directory(dir.path()).unwrap();
|
||||||
|
assert_eq!(count, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn register_fonts_directory_skips_invalid_font_silently() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(dir.path().join("broken.ttf"), b"not a font").unwrap();
|
||||||
|
std::fs::write(dir.path().join("good.ttf"), NOTO_SANS_REGULAR).unwrap();
|
||||||
|
|
||||||
|
let svc = DreportService::empty();
|
||||||
|
let count = svc.register_fonts_directory(dir.path()).unwrap();
|
||||||
|
assert_eq!(count, 1, "only the good font should register");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Font lookup
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_font_bytes_returns_data_for_known_variant() {
|
||||||
|
let svc = DreportService::new();
|
||||||
|
let bytes = svc
|
||||||
|
.get_font_bytes("Noto Sans", 400, false)
|
||||||
|
.expect("regular variant should exist");
|
||||||
|
assert!(!bytes.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_font_bytes_case_insensitive() {
|
||||||
|
let svc = DreportService::new();
|
||||||
|
let lower = svc.get_font_bytes("noto sans", 400, false);
|
||||||
|
let mixed = svc.get_font_bytes("NoTo SaNs", 400, false);
|
||||||
|
assert!(lower.is_some());
|
||||||
|
assert!(mixed.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_font_bytes_unknown_returns_none() {
|
||||||
|
let svc = DreportService::new();
|
||||||
|
assert!(svc.get_font_bytes("DoesNotExist", 400, false).is_none());
|
||||||
|
assert!(svc.get_font_bytes("Noto Sans", 1234, false).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Layout + render pipeline
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compute_layout_json_valid_template_returns_pages() {
|
||||||
|
let svc = DreportService::new();
|
||||||
|
let json = svc
|
||||||
|
.compute_layout_json(VALID_TEMPLATE, VALID_DATA)
|
||||||
|
.expect("layout should compute");
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||||
|
let pages = parsed
|
||||||
|
.get("pages")
|
||||||
|
.and_then(|p| p.as_array())
|
||||||
|
.expect("LayoutResult must contain pages array");
|
||||||
|
assert!(!pages.is_empty(), "at least one page expected");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compute_layout_json_invalid_template_returns_typed_error() {
|
||||||
|
let svc = DreportService::new();
|
||||||
|
let err = svc
|
||||||
|
.compute_layout_json("{not json", VALID_DATA)
|
||||||
|
.expect_err("malformed template must error");
|
||||||
|
assert!(matches!(err, ServiceError::InvalidTemplateJson(_)));
|
||||||
|
assert_eq!(err.code(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compute_layout_json_invalid_data_returns_typed_error() {
|
||||||
|
let svc = DreportService::new();
|
||||||
|
let err = svc
|
||||||
|
.compute_layout_json(VALID_TEMPLATE, "{not json")
|
||||||
|
.expect_err("malformed data must error");
|
||||||
|
assert!(matches!(err, ServiceError::InvalidDataJson(_)));
|
||||||
|
assert_eq!(err.code(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_pdf_json_produces_pdf_with_magic_header() {
|
||||||
|
let svc = DreportService::new();
|
||||||
|
let pdf = svc
|
||||||
|
.render_pdf_json(VALID_TEMPLATE, VALID_DATA)
|
||||||
|
.expect("render must succeed");
|
||||||
|
assert!(
|
||||||
|
pdf.starts_with(b"%PDF-"),
|
||||||
|
"PDF magic header missing; got {:?}",
|
||||||
|
&pdf[..pdf.len().min(8)]
|
||||||
|
);
|
||||||
|
assert!(pdf.len() > 100, "PDF unexpectedly small");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_pdf_typed_matches_render_pdf_json() {
|
||||||
|
let svc = DreportService::new();
|
||||||
|
let from_json = svc
|
||||||
|
.render_pdf_json(VALID_TEMPLATE, VALID_DATA)
|
||||||
|
.expect("json render");
|
||||||
|
let template = serde_json::from_str(VALID_TEMPLATE).unwrap();
|
||||||
|
let data = serde_json::from_str(VALID_DATA).unwrap();
|
||||||
|
let from_typed = svc.render_pdf(&template, &data).expect("typed render");
|
||||||
|
// Producer headers vary on time; magic header + non-trivial size sufficient.
|
||||||
|
assert!(from_json.starts_with(b"%PDF-"));
|
||||||
|
assert!(from_typed.starts_with(b"%PDF-"));
|
||||||
|
assert_eq!(from_json.len(), from_typed.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Concurrency
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn concurrent_renders_share_service_safely() {
|
||||||
|
let svc = Arc::new(DreportService::new());
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
for _ in 0..8 {
|
||||||
|
let s = Arc::clone(&svc);
|
||||||
|
handles.push(thread::spawn(move || {
|
||||||
|
let pdf = s.render_pdf_json(VALID_TEMPLATE, VALID_DATA).unwrap();
|
||||||
|
assert!(pdf.starts_with(b"%PDF-"));
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for h in handles {
|
||||||
|
h.join().expect("worker panic");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn concurrent_register_and_render() {
|
||||||
|
let svc = Arc::new(DreportService::new());
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
|
||||||
|
let writer_svc = Arc::clone(&svc);
|
||||||
|
handles.push(thread::spawn(move || {
|
||||||
|
for _ in 0..4 {
|
||||||
|
let _ = writer_svc.register_font_bytes(NOTO_SANS_REGULAR.to_vec());
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
for _ in 0..4 {
|
||||||
|
let s = Arc::clone(&svc);
|
||||||
|
handles.push(thread::spawn(move || {
|
||||||
|
let pdf = s.render_pdf_json(VALID_TEMPLATE, VALID_DATA).unwrap();
|
||||||
|
assert!(pdf.starts_with(b"%PDF-"));
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
for h in handles {
|
||||||
|
h.join().expect("worker panic");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Error display
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn service_error_codes_are_stable() {
|
||||||
|
// FFI consumers depend on these — changing them is a breaking change.
|
||||||
|
assert_eq!(ServiceError::InvalidTemplateJson("x".into()).code(), 1);
|
||||||
|
assert_eq!(ServiceError::InvalidDataJson("x".into()).code(), 2);
|
||||||
|
assert_eq!(ServiceError::FontParseFailed.code(), 3);
|
||||||
|
assert_eq!(ServiceError::FontDirNotFound("x".into()).code(), 4);
|
||||||
|
assert_eq!(ServiceError::FontDirRead("x".into()).code(), 5);
|
||||||
|
assert_eq!(ServiceError::LayoutFailed("x".into()).code(), 6);
|
||||||
|
assert_eq!(ServiceError::PdfFailed("x".into()).code(), 7);
|
||||||
|
assert_eq!(ServiceError::SerializationFailed("x".into()).code(), 8);
|
||||||
|
}
|
||||||
1
frontend/.npmrc
Normal file
1
frontend/.npmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@duhanbalci:registry=https://gitea.duhanbalci.com/api/packages/duhanbalci/npm/
|
||||||
@@ -94,9 +94,13 @@ if (savedSchema) {
|
|||||||
currentSchema.value = savedSchema
|
currentSchema.value = savedSchema
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(currentSchema, (val) => {
|
watch(
|
||||||
|
currentSchema,
|
||||||
|
(val) => {
|
||||||
localStorage.setItem(SCHEMA_STORAGE_KEY, JSON.stringify(val))
|
localStorage.setItem(SCHEMA_STORAGE_KEY, JSON.stringify(val))
|
||||||
}, { deep: true })
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
// --- Sample Invoice Data ---
|
// --- Sample Invoice Data ---
|
||||||
|
|
||||||
@@ -130,12 +134,19 @@ const sampleData: Record<string, unknown> = {
|
|||||||
{ siraNo: 3, adi: 'UI/UX Tasarim Hizmeti', miktar: 40, birim: 'Saat', birimFiyat: 750, tutar: 30000 },
|
{ siraNo: 3, adi: 'UI/UX Tasarim Hizmeti', miktar: 40, birim: 'Saat', birimFiyat: 750, tutar: 30000 },
|
||||||
{ siraNo: 4, adi: 'Sunucu Bakim Sozlesmesi (Yillik)', miktar: 1, birim: 'Adet', birimFiyat: 12000, tutar: 12000 },
|
{ siraNo: 4, adi: 'Sunucu Bakim Sozlesmesi (Yillik)', miktar: 1, birim: 'Adet', birimFiyat: 12000, tutar: 12000 },
|
||||||
{ siraNo: 5, adi: 'SSL Sertifikasi', miktar: 3, birim: 'Adet', birimFiyat: 500, tutar: 1500 },
|
{ siraNo: 5, adi: 'SSL Sertifikasi', miktar: 3, birim: 'Adet', birimFiyat: 500, tutar: 1500 },
|
||||||
|
{ siraNo: 6, adi: 'Veritabani Yonetimi', miktar: 12, birim: 'Ay', birimFiyat: 2000, tutar: 24000 },
|
||||||
|
{ siraNo: 7, adi: 'API Entegrasyon Hizmeti', miktar: 1, birim: 'Adet', birimFiyat: 18000, tutar: 18000 },
|
||||||
|
{ siraNo: 8, adi: 'Bulut Altyapi Kurulumu', miktar: 1, birim: 'Adet', birimFiyat: 8000, tutar: 8000 },
|
||||||
|
{ siraNo: 9, adi: 'Siber Guvenlik Danismanligi', miktar: 20, birim: 'Saat', birimFiyat: 900, tutar: 18000 },
|
||||||
|
{ siraNo: 10, adi: 'E-posta Sunucu Yapilandirmasi', miktar: 1, birim: 'Adet', birimFiyat: 3500, tutar: 3500 },
|
||||||
|
{ siraNo: 11, adi: 'Yedekleme Sistemi Kurulumu', miktar: 1, birim: 'Adet', birimFiyat: 5000, tutar: 5000 },
|
||||||
|
{ siraNo: 12, adi: 'SEO Optimizasyonu', miktar: 1, birim: 'Adet', birimFiyat: 7500, tutar: 7500 },
|
||||||
|
{ siraNo: 13, adi: 'Egitim ve Dokumantasyon', miktar: 8, birim: 'Saat', birimFiyat: 600, tutar: 4800 },
|
||||||
|
{ siraNo: 14, adi: 'Performans Testi ve Raporlama', miktar: 1, birim: 'Adet', birimFiyat: 6000, tutar: 6000 },
|
||||||
|
{ siraNo: 15, adi: 'Teknik Destek Paketi (6 Ay)', miktar: 1, birim: 'Adet', birimFiyat: 9000, tutar: 9000 },
|
||||||
],
|
],
|
||||||
toplamlar: {
|
toplamlar: {
|
||||||
araToplam: 123500,
|
|
||||||
kdvOrani: 20,
|
kdvOrani: 20,
|
||||||
kdv: 24700,
|
|
||||||
genelToplam: 148200,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,10 +381,30 @@ const defaultInvoiceTemplate: Template = {
|
|||||||
columns: [
|
columns: [
|
||||||
{ id: 'col_sira', field: 'siraNo', title: '#', width: sz.fixed(10), align: 'center' },
|
{ id: 'col_sira', field: 'siraNo', title: '#', width: sz.fixed(10), align: 'center' },
|
||||||
{ id: 'col_adi', field: 'adi', title: 'Urun / Hizmet', width: sz.fr(), align: 'left' },
|
{ id: 'col_adi', field: 'adi', title: 'Urun / Hizmet', width: sz.fr(), align: 'left' },
|
||||||
{ id: 'col_miktar', field: 'miktar', title: 'Miktar', width: sz.fixed(18), align: 'right' },
|
{
|
||||||
|
id: 'col_miktar',
|
||||||
|
field: 'miktar',
|
||||||
|
title: 'Miktar',
|
||||||
|
width: sz.fixed(18),
|
||||||
|
align: 'right',
|
||||||
|
},
|
||||||
{ id: 'col_birim', field: 'birim', title: 'Birim', width: sz.fixed(18), align: 'center' },
|
{ id: 'col_birim', field: 'birim', title: 'Birim', width: sz.fixed(18), align: 'center' },
|
||||||
{ id: 'col_fiyat', field: 'birimFiyat', title: 'Birim Fiyat', width: sz.fixed(28), align: 'right', format: 'currency' as const },
|
{
|
||||||
{ id: 'col_tutar', field: 'tutar', title: 'Tutar', width: sz.fixed(28), align: 'right', format: 'currency' as const },
|
id: 'col_fiyat',
|
||||||
|
field: 'birimFiyat',
|
||||||
|
title: 'Birim Fiyat',
|
||||||
|
width: sz.fixed(28),
|
||||||
|
align: 'right',
|
||||||
|
format: 'currency' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'col_tutar',
|
||||||
|
field: 'tutar',
|
||||||
|
title: 'Tutar',
|
||||||
|
width: sz.fixed(28),
|
||||||
|
align: 'right',
|
||||||
|
format: 'currency' as const,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
style: {
|
style: {
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
@@ -427,23 +458,67 @@ const defaultInvoiceTemplate: Template = {
|
|||||||
justify: 'start',
|
justify: 'start',
|
||||||
style: { borderColor: '#e2e8f0', borderWidth: 0.5 },
|
style: { borderColor: '#e2e8f0', borderWidth: 0.5 },
|
||||||
children: [
|
children: [
|
||||||
|
{
|
||||||
|
id: 'c_ara_toplam_row',
|
||||||
|
type: 'container',
|
||||||
|
position: { type: 'flow' },
|
||||||
|
size: { width: sz.fr(), height: sz.auto() },
|
||||||
|
direction: 'row',
|
||||||
|
gap: 2,
|
||||||
|
padding: { top: 0, right: 0, bottom: 0, left: 0 },
|
||||||
|
align: 'center',
|
||||||
|
justify: 'space-between',
|
||||||
|
style: {},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 'el_ara_toplam_label',
|
||||||
|
type: 'static_text',
|
||||||
|
position: { type: 'flow' },
|
||||||
|
size: { width: sz.auto(), height: sz.auto() },
|
||||||
|
style: { fontSize: 10, color: '#333333' },
|
||||||
|
content: 'Ara Toplam:',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'el_ara_toplam',
|
id: 'el_ara_toplam',
|
||||||
type: 'text',
|
type: 'calculated_text',
|
||||||
position: { type: 'flow' },
|
position: { type: 'flow' },
|
||||||
size: { width: sz.auto(), height: sz.auto() },
|
size: { width: sz.auto(), height: sz.auto() },
|
||||||
style: { fontSize: 10, color: '#333333', align: 'right' },
|
style: { fontSize: 10, color: '#333333', align: 'right' },
|
||||||
content: 'Ara Toplam: ',
|
expression: 'kalemler.tutar.sum()',
|
||||||
binding: { type: 'scalar', path: 'toplamlar.araToplam' },
|
format: 'currency',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'c_kdv_row',
|
||||||
|
type: 'container',
|
||||||
|
position: { type: 'flow' },
|
||||||
|
size: { width: sz.fr(), height: sz.auto() },
|
||||||
|
direction: 'row',
|
||||||
|
gap: 2,
|
||||||
|
padding: { top: 0, right: 0, bottom: 0, left: 0 },
|
||||||
|
align: 'center',
|
||||||
|
justify: 'space-between',
|
||||||
|
style: {},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 'el_kdv_label',
|
||||||
|
type: 'static_text',
|
||||||
|
position: { type: 'flow' },
|
||||||
|
size: { width: sz.auto(), height: sz.auto() },
|
||||||
|
style: { fontSize: 10, color: '#333333' },
|
||||||
|
content: 'KDV (%20):',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'el_kdv',
|
id: 'el_kdv',
|
||||||
type: 'text',
|
type: 'calculated_text',
|
||||||
position: { type: 'flow' },
|
position: { type: 'flow' },
|
||||||
size: { width: sz.auto(), height: sz.auto() },
|
size: { width: sz.auto(), height: sz.auto() },
|
||||||
style: { fontSize: 10, color: '#333333', align: 'right' },
|
style: { fontSize: 10, color: '#333333', align: 'right' },
|
||||||
content: 'KDV (%20): ',
|
expression: 'kalemler.tutar.sum() * toplamlar.kdvOrani / 100',
|
||||||
binding: { type: 'scalar', path: 'toplamlar.kdv' },
|
format: 'currency',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'el_cizgi_2',
|
id: 'el_cizgi_2',
|
||||||
@@ -452,14 +527,36 @@ const defaultInvoiceTemplate: Template = {
|
|||||||
size: { width: sz.fr(), height: sz.auto() },
|
size: { width: sz.fr(), height: sz.auto() },
|
||||||
style: { strokeColor: '#1e293b', strokeWidth: 1 },
|
style: { strokeColor: '#1e293b', strokeWidth: 1 },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'c_genel_toplam_row',
|
||||||
|
type: 'container',
|
||||||
|
position: { type: 'flow' },
|
||||||
|
size: { width: sz.fr(), height: sz.auto() },
|
||||||
|
direction: 'row',
|
||||||
|
gap: 2,
|
||||||
|
padding: { top: 0, right: 0, bottom: 0, left: 0 },
|
||||||
|
align: 'center',
|
||||||
|
justify: 'space-between',
|
||||||
|
style: {},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 'el_genel_toplam_label',
|
||||||
|
type: 'static_text',
|
||||||
|
position: { type: 'flow' },
|
||||||
|
size: { width: sz.auto(), height: sz.auto() },
|
||||||
|
style: { fontSize: 12, fontWeight: 'bold', color: '#1a1a1a' },
|
||||||
|
content: 'GENEL TOPLAM:',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'el_genel_toplam',
|
id: 'el_genel_toplam',
|
||||||
type: 'text',
|
type: 'calculated_text',
|
||||||
position: { type: 'flow' },
|
position: { type: 'flow' },
|
||||||
size: { width: sz.auto(), height: sz.auto() },
|
size: { width: sz.auto(), height: sz.auto() },
|
||||||
style: { fontSize: 12, fontWeight: 'bold', color: '#1a1a1a', align: 'right' },
|
style: { fontSize: 12, fontWeight: 'bold', color: '#1a1a1a', align: 'right' },
|
||||||
content: 'GENEL TOPLAM: ',
|
expression: 'kalemler.tutar.sum() * (1 + toplamlar.kdvOrani / 100)',
|
||||||
binding: { type: 'scalar', path: 'toplamlar.genelToplam' },
|
format: 'currency',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -486,12 +583,16 @@ function loadFromLocalStorage(): Template | null {
|
|||||||
const template = ref<Template>(loadFromLocalStorage() ?? structuredClone(defaultInvoiceTemplate))
|
const template = ref<Template>(loadFromLocalStorage() ?? structuredClone(defaultInvoiceTemplate))
|
||||||
|
|
||||||
let saveTimeout: ReturnType<typeof setTimeout> | null = null
|
let saveTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
watch(template, (val) => {
|
watch(
|
||||||
|
template,
|
||||||
|
(val) => {
|
||||||
if (saveTimeout) clearTimeout(saveTimeout)
|
if (saveTimeout) clearTimeout(saveTimeout)
|
||||||
saveTimeout = setTimeout(() => {
|
saveTimeout = setTimeout(() => {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(val))
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(val))
|
||||||
}, 500)
|
}, 500)
|
||||||
}, { deep: true })
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
// --- Editor ref ---
|
// --- Editor ref ---
|
||||||
|
|
||||||
@@ -626,36 +727,120 @@ function resetTemplate() {
|
|||||||
<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>
|
<div style="flex: 1"></div>
|
||||||
<input ref="fileInputRef" type="file" accept=".json" style="display: none" @change="onImportFile" />
|
<input
|
||||||
<input ref="schemaFileInputRef" type="file" accept=".json" style="display: none" @change="onSchemaImportFile" />
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
style="display: none"
|
||||||
|
@change="onImportFile"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref="schemaFileInputRef"
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
style="display: none"
|
||||||
|
@change="onSchemaImportFile"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Template operations -->
|
<!-- Template operations -->
|
||||||
<button class="header-btn header-btn--secondary" @click="resetTemplate" title="Sifirla">
|
<button class="header-btn header-btn--secondary" @click="resetTemplate" title="Sifirla">
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 8a6 6 0 0 1 10.2-4.3L14 2v4h-4l1.7-1.7A4.5 4.5 0 1 0 12.5 8" /><path d="M12.5 8a4.5 4.5 0 0 1-8.2 2.5" /></svg>
|
<svg
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M2 8a6 6 0 0 1 10.2-4.3L14 2v4h-4l1.7-1.7A4.5 4.5 0 1 0 12.5 8" />
|
||||||
|
<path d="M12.5 8a4.5 4.5 0 0 1-8.2 2.5" />
|
||||||
|
</svg>
|
||||||
Sifirla
|
Sifirla
|
||||||
</button>
|
</button>
|
||||||
<button class="header-btn header-btn--secondary" @click="triggerImport" title="Sablon Yukle">
|
<button class="header-btn header-btn--secondary" @click="triggerImport" title="Sablon Yukle">
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 10V2m0 0L5 5m3-3 3 3" /><path d="M2 10v2a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-2" /></svg>
|
<svg
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M8 10V2m0 0L5 5m3-3 3 3" />
|
||||||
|
<path d="M2 10v2a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-2" />
|
||||||
|
</svg>
|
||||||
Yukle
|
Yukle
|
||||||
</button>
|
</button>
|
||||||
<button class="header-btn header-btn--secondary" @click="exportTemplate" title="Sablon Kaydet">
|
<button
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v8m0 0 3-3m-3 3L5 7" /><path d="M2 10v2a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-2" /></svg>
|
class="header-btn header-btn--secondary"
|
||||||
|
@click="exportTemplate"
|
||||||
|
title="Sablon Kaydet"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M8 2v8m0 0 3-3m-3 3L5 7" />
|
||||||
|
<path d="M2 10v2a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-2" />
|
||||||
|
</svg>
|
||||||
Kaydet
|
Kaydet
|
||||||
</button>
|
</button>
|
||||||
<button class="header-btn header-btn--secondary" @click="exportBundle" title="Sablon + Schema Birlikte Kaydet">
|
<button
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="1" width="12" height="14" rx="1.5" /><path d="M5 4h6M5 7h6M5 10h4" /></svg>
|
class="header-btn header-btn--secondary"
|
||||||
|
@click="exportBundle"
|
||||||
|
title="Sablon + Schema Birlikte Kaydet"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<rect x="2" y="1" width="12" height="14" rx="1.5" />
|
||||||
|
<path d="M5 4h6M5 7h6M5 10h4" />
|
||||||
|
</svg>
|
||||||
Paket
|
Paket
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="header-divider"></div>
|
<div class="header-divider"></div>
|
||||||
|
|
||||||
<!-- Schema operations -->
|
<!-- Schema operations -->
|
||||||
<button class="header-btn header-btn--secondary" @click="triggerSchemaImport" title="Schema Yukle">
|
<button
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 10V2m0 0L5 5m3-3 3 3" /><path d="M2 10v2a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-2" /></svg>
|
class="header-btn header-btn--secondary"
|
||||||
|
@click="triggerSchemaImport"
|
||||||
|
title="Schema Yukle"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M8 10V2m0 0L5 5m3-3 3 3" />
|
||||||
|
<path d="M2 10v2a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-2" />
|
||||||
|
</svg>
|
||||||
Schema
|
Schema
|
||||||
</button>
|
</button>
|
||||||
<button class="header-btn header-btn--secondary" @click="exportSchema" title="Schema Kaydet">
|
<button class="header-btn header-btn--secondary" @click="exportSchema" title="Schema Kaydet">
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v8m0 0 3-3m-3 3L5 7" /><path d="M2 10v2a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-2" /></svg>
|
<svg
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M8 2v8m0 0 3-3m-3 3L5 7" />
|
||||||
|
<path d="M2 10v2a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-2" />
|
||||||
|
</svg>
|
||||||
Schema
|
Schema
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -663,7 +848,17 @@ function resetTemplate() {
|
|||||||
|
|
||||||
<!-- Output -->
|
<!-- Output -->
|
||||||
<button class="header-btn" :disabled="pdfLoading" @click="downloadPdf">
|
<button class="header-btn" :disabled="pdfLoading" @click="downloadPdf">
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="1" width="10" height="14" rx="1.5" /><path d="M6 5h4M6 8h4M6 11h2" /></svg>
|
<svg
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<rect x="3" y="1" width="10" height="14" rx="1.5" />
|
||||||
|
<path d="M6 5h4M6 8h4M6 11h2" />
|
||||||
|
</svg>
|
||||||
{{ pdfLoading ? 'Hazirlaniyor...' : 'PDF Onizle' }}
|
{{ pdfLoading ? 'Hazirlaniyor...' : 'PDF Onizle' }}
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'
|
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'
|
||||||
import { EditorView, lineNumbers } from '@codemirror/view'
|
import { EditorView, lineNumbers } from '@codemirror/view'
|
||||||
import { EditorState } from '@codemirror/state'
|
import { EditorState } from '@codemirror/state'
|
||||||
import { dexpr } from 'codemirror-lang-dexpr'
|
import { dexpr } from '@duhanbalci/codemirror-lang-dexpr'
|
||||||
import type { DexprLanguageInfo } from 'codemirror-lang-dexpr'
|
import type { DexprLanguageInfo } from '@duhanbalci/codemirror-lang-dexpr'
|
||||||
import { useSchemaStore } from '../../stores/schema'
|
import { useSchemaStore } from '../../stores/schema'
|
||||||
import type { SchemaNode } from '../../core/schema-parser'
|
import type { SchemaNode } from '../../core/schema-parser'
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ function schemaToLanguageInfo(): DexprLanguageInfo {
|
|||||||
const tree = schemaStore.schemaTree
|
const tree = schemaStore.schemaTree
|
||||||
for (const child of tree.children) {
|
for (const child of tree.children) {
|
||||||
if (child.type === 'object') {
|
if (child.type === 'object') {
|
||||||
const fields = child.children.map(f => ({
|
const fields = child.children.map((f) => ({
|
||||||
name: f.key,
|
name: f.key,
|
||||||
type: schemaToDexprType(f),
|
type: schemaToDexprType(f),
|
||||||
}))
|
}))
|
||||||
@@ -112,7 +112,9 @@ function schemaToLanguageInfo(): DexprLanguageInfo {
|
|||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
function schemaToDexprType(node: SchemaNode): 'String' | 'Number' | 'Boolean' | 'Object' | 'NumberList' | 'StringList' {
|
function schemaToDexprType(
|
||||||
|
node: SchemaNode,
|
||||||
|
): 'String' | 'Number' | 'Boolean' | 'Object' | 'NumberList' | 'StringList' {
|
||||||
switch (node.type) {
|
switch (node.type) {
|
||||||
case 'number':
|
case 'number':
|
||||||
case 'integer':
|
case 'integer':
|
||||||
@@ -134,7 +136,7 @@ function createState(doc: string): EditorState {
|
|||||||
return EditorState.create({
|
return EditorState.create({
|
||||||
doc,
|
doc,
|
||||||
extensions: [
|
extensions: [
|
||||||
EditorView.updateListener.of(update => {
|
EditorView.updateListener.of((update) => {
|
||||||
if (update.docChanged) {
|
if (update.docChanged) {
|
||||||
const val = update.state.doc.toString()
|
const val = update.state.doc.toString()
|
||||||
if (val !== props.modelValue) {
|
if (val !== props.modelValue) {
|
||||||
@@ -207,7 +209,9 @@ onBeforeUnmount(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Disaridan gelen deger degisikligi (undo/redo vs.)
|
// Disaridan gelen deger degisikligi (undo/redo vs.)
|
||||||
watch(() => props.modelValue, (newVal) => {
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(newVal) => {
|
||||||
if (!view) return
|
if (!view) return
|
||||||
const current = view.state.doc.toString()
|
const current = view.state.doc.toString()
|
||||||
if (current !== newVal) {
|
if (current !== newVal) {
|
||||||
@@ -215,14 +219,19 @@ watch(() => props.modelValue, (newVal) => {
|
|||||||
changes: { from: 0, to: current.length, insert: newVal ?? '' },
|
changes: { from: 0, to: current.length, insert: newVal ?? '' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// Schema degisince editor'u yeniden olustur (autocomplete guncellenmeli)
|
// Schema degisince editor'u yeniden olustur (autocomplete guncellenmeli)
|
||||||
watch(langInfo, () => {
|
watch(
|
||||||
|
langInfo,
|
||||||
|
() => {
|
||||||
if (!view) return
|
if (!view) return
|
||||||
const doc = view.state.doc.toString()
|
const doc = view.state.doc.toString()
|
||||||
view.setState(createState(doc))
|
view.setState(createState(doc))
|
||||||
}, { deep: true })
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -7,12 +7,16 @@ import { useLayoutEngine } from '../../composables/useLayoutEngine'
|
|||||||
import LayoutRenderer from './LayoutRenderer.vue'
|
import LayoutRenderer from './LayoutRenderer.vue'
|
||||||
import InteractionOverlay from './InteractionOverlay.vue'
|
import InteractionOverlay from './InteractionOverlay.vue'
|
||||||
import RulerBar from './RulerBar.vue'
|
import RulerBar from './RulerBar.vue'
|
||||||
|
import MinimapOverlay from './MinimapOverlay.vue'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
handleErrors?: boolean
|
handleErrors?: boolean
|
||||||
}>(), {
|
}>(),
|
||||||
|
{
|
||||||
handleErrors: true,
|
handleErrors: true,
|
||||||
})
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const templateStore = useTemplateStore()
|
const templateStore = useTemplateStore()
|
||||||
const editorStore = useEditorStore()
|
const editorStore = useEditorStore()
|
||||||
@@ -20,22 +24,44 @@ const { template, mockData, layoutVersion } = storeToRefs(templateStore)
|
|||||||
|
|
||||||
const containerRef = ref<HTMLElement | null>(null)
|
const containerRef = ref<HTMLElement | null>(null)
|
||||||
const containerWidth = ref(800)
|
const containerWidth = ref(800)
|
||||||
|
const containerHeight = ref(600)
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'compile-error': [error: string | null]
|
'compile-error': [error: string | null]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// Layout engine — template + data'yı worker'a gönderir, WASM ile layout hesaplar
|
// Layout engine — template + data'yı worker'a gönderir, WASM ile layout hesaplar
|
||||||
const { layout, layoutMap, error, computing: compiling, generateBarcode, dispose } = useLayoutEngine(template, mockData, layoutVersion)
|
const {
|
||||||
|
layout,
|
||||||
|
layoutMap,
|
||||||
|
error,
|
||||||
|
computing: compiling,
|
||||||
|
generateBarcode,
|
||||||
|
dispose,
|
||||||
|
} = useLayoutEngine(template, mockData, layoutVersion)
|
||||||
|
|
||||||
// LayoutRenderer'ın barcode üretmek için kullanabileceği fonksiyon
|
// LayoutRenderer'ın barcode üretmek için kullanabileceği fonksiyon
|
||||||
provide('generateBarcode', generateBarcode)
|
provide('generateBarcode', generateBarcode)
|
||||||
|
|
||||||
watch(error, (val) => emit('compile-error', val))
|
watch(error, (val) => emit('compile-error', val))
|
||||||
|
|
||||||
// mm → px dönüşüm katsayısı
|
// ============================================================
|
||||||
|
// Zoom gesture: CSS transform ile anlık geri bildirim,
|
||||||
|
// debounce ile gerçek scale commit
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// committedZoom: son commit edilen zoom seviyesi (bu değer scale'i belirler)
|
||||||
|
const committedZoom = ref(editorStore.zoom)
|
||||||
|
// Gesture sırasında hedef zoom/pan (henüz commit edilmedi)
|
||||||
|
const gestureZoom = ref(editorStore.zoom)
|
||||||
|
const gesturePanX = ref(editorStore.panX)
|
||||||
|
const gesturePanY = ref(editorStore.panY)
|
||||||
|
const isZoomGesture = ref(false)
|
||||||
|
let zoomCommitTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
// mm → px dönüşüm katsayısı (committed zoom'a bağlı)
|
||||||
const scale = computed(() => {
|
const scale = computed(() => {
|
||||||
return (containerWidth.value / templateStore.template.page.width) * editorStore.zoom
|
return (containerWidth.value / templateStore.template.page.width) * committedZoom.value
|
||||||
})
|
})
|
||||||
|
|
||||||
// Layout sayfaları
|
// Layout sayfaları
|
||||||
@@ -44,7 +70,50 @@ const layoutPages = computed(() => layout.value?.pages ?? [])
|
|||||||
// Sayfa yüksekliği px cinsinden
|
// Sayfa yüksekliği px cinsinden
|
||||||
const pageHeightPx = computed(() => templateStore.template.page.height * scale.value)
|
const pageHeightPx = computed(() => templateStore.template.page.height * scale.value)
|
||||||
|
|
||||||
// Sayfalar container stili — tüm sayfaları kapsayan dış kutu
|
// Görünür sayfa indeksleri — viewport dışındaki sayfaların DOM elemanları render edilmez
|
||||||
|
// Stabil: sadece gerçek indeksler değiştiğinde yeni Set oluştur
|
||||||
|
const _lastVisibleKey = ref('')
|
||||||
|
const _lastVisibleSet = ref(new Set<number>([0]))
|
||||||
|
|
||||||
|
const visiblePageIndices = computed(() => {
|
||||||
|
// Gesture sırasında gesture değerlerini, yoksa store değerlerini kullan
|
||||||
|
const currentPanY = isZoomGesture.value ? gesturePanY.value : editorStore.panY
|
||||||
|
const currentZoom = isZoomGesture.value ? gestureZoom.value : editorStore.zoom
|
||||||
|
const baseScale = containerWidth.value / templateStore.template.page.width
|
||||||
|
const currentScale = baseScale * currentZoom
|
||||||
|
const pageH = templateStore.template.page.height * currentScale
|
||||||
|
const gap = 24
|
||||||
|
const count = layoutPages.value.length
|
||||||
|
if (count === 0) return _lastVisibleSet.value
|
||||||
|
|
||||||
|
const pagesTop = 60 + currentPanY
|
||||||
|
const viewH = containerHeight.value
|
||||||
|
|
||||||
|
const indices: number[] = []
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const pageTop = pagesTop + i * (pageH + gap)
|
||||||
|
const pageBottom = pageTop + pageH
|
||||||
|
const buffer = pageH
|
||||||
|
if (pageBottom > -buffer && pageTop < viewH + buffer) {
|
||||||
|
indices.push(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = indices.join(',')
|
||||||
|
if (key !== _lastVisibleKey.value) {
|
||||||
|
_lastVisibleKey.value = key
|
||||||
|
_lastVisibleSet.value = new Set(indices)
|
||||||
|
}
|
||||||
|
return _lastVisibleSet.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// CSS transform zoom oranı — gesture sırasında visual feedback
|
||||||
|
const zoomCssRatio = computed(() => {
|
||||||
|
if (!isZoomGesture.value) return 1
|
||||||
|
return gestureZoom.value / committedZoom.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sayfalar container stili — committed scale'e göre
|
||||||
const pagesContainerStyle = computed(() => {
|
const pagesContainerStyle = computed(() => {
|
||||||
const w = templateStore.template.page.width * scale.value
|
const w = templateStore.template.page.width * scale.value
|
||||||
const m = templateStore.template.root.padding
|
const m = templateStore.template.root.padding
|
||||||
@@ -56,6 +125,7 @@ const pagesContainerStyle = computed(() => {
|
|||||||
height: `${totalH}px`,
|
height: `${totalH}px`,
|
||||||
position: 'relative' as const,
|
position: 'relative' as const,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
willChange: 'transform' as const,
|
||||||
'--page-margin-top': `${m.top * scale.value}px`,
|
'--page-margin-top': `${m.top * scale.value}px`,
|
||||||
'--page-margin-right': `${m.right * scale.value}px`,
|
'--page-margin-right': `${m.right * scale.value}px`,
|
||||||
'--page-margin-bottom': `${m.bottom * scale.value}px`,
|
'--page-margin-bottom': `${m.bottom * scale.value}px`,
|
||||||
@@ -63,12 +133,94 @@ const pagesContainerStyle = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Pan transform — sayfa container'ına uygulanacak
|
// Pan sınırları
|
||||||
const panTransform = computed(() => {
|
function clampPan(x: number, y: number, zoomOverride?: number): [number, number] {
|
||||||
if (editorStore.panX === 0 && editorStore.panY === 0) return undefined
|
const z = zoomOverride ?? committedZoom.value
|
||||||
return `translate(${editorStore.panX}px, ${editorStore.panY}px)`
|
const baseScale = containerWidth.value / templateStore.template.page.width
|
||||||
|
const s = baseScale * z
|
||||||
|
const pageW = templateStore.template.page.width * s
|
||||||
|
const pageCount = Math.max(1, layoutPages.value.length)
|
||||||
|
const pageGap = 24
|
||||||
|
const phPx = templateStore.template.page.height * s
|
||||||
|
const totalH = phPx * pageCount + pageGap * (pageCount - 1)
|
||||||
|
|
||||||
|
const viewH = (containerRef.value?.clientHeight ?? 600) - 60 - 40
|
||||||
|
|
||||||
|
const clampX = pageW / 2
|
||||||
|
const maxY = viewH * 0.5
|
||||||
|
const minY = viewH * 0.5 - totalH
|
||||||
|
|
||||||
|
return [
|
||||||
|
Math.max(-clampX, Math.min(clampX, x)),
|
||||||
|
Math.max(minY, Math.min(maxY, y)),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pages container transform — pan + gesture zoom CSS scale
|
||||||
|
const pagesTransform = computed(() => {
|
||||||
|
const ratio = zoomCssRatio.value
|
||||||
|
const panX = isZoomGesture.value ? gesturePanX.value : editorStore.panX
|
||||||
|
const panY = isZoomGesture.value ? gesturePanY.value : editorStore.panY
|
||||||
|
|
||||||
|
if (ratio === 1) {
|
||||||
|
if (panX === 0 && panY === 0) return undefined
|
||||||
|
return `translate(${panX}px, ${panY}px)`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scale from top-left (0 0). Centering düzeltmesi:
|
||||||
|
// Flex container ortalar → naturalLeft = (containerW - w) / 2
|
||||||
|
// Scale sonrası visual width = w * ratio, visual center kayar
|
||||||
|
// Düzeltme: tx += w * (1 - ratio) / 2
|
||||||
|
const w = templateStore.template.page.width * scale.value
|
||||||
|
const centerCorrection = w * (1 - ratio) / 2
|
||||||
|
const tx = panX + centerCorrection
|
||||||
|
const ty = panY
|
||||||
|
|
||||||
|
return `translate(${tx}px, ${ty}px) scale(${ratio})`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const pagesTransformOrigin = computed(() => {
|
||||||
|
if (zoomCssRatio.value === 1) return undefined
|
||||||
|
return '0 0'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Zoom commit: gesture sonunda gerçek scale'i güncelle
|
||||||
|
function commitZoom() {
|
||||||
|
const z = gestureZoom.value
|
||||||
|
const px = gesturePanX.value
|
||||||
|
let py = gesturePanY.value
|
||||||
|
|
||||||
|
const ratio = z / committedZoom.value
|
||||||
|
const pageCount = layoutPages.value.length
|
||||||
|
|
||||||
|
// Gap düzeltmesi: CSS scale sırasında 24px gap'ler de ratio ile ölçekleniyor.
|
||||||
|
// Commit sonrası gap'ler tekrar 24px'e dönüyor → dikey kayma.
|
||||||
|
// Viewport merkezindeki sayfanın üstündeki gap sayısı × 24 × (ratio - 1) kadar düzelt.
|
||||||
|
if (ratio !== 1 && pageCount > 1) {
|
||||||
|
const pageH_dom = templateStore.template.page.height * scale.value // committed scale'de
|
||||||
|
const strideVisual = (pageH_dom + 24) * ratio
|
||||||
|
|
||||||
|
// Viewport merkezinin container visual koordinatındaki Y pozisyonu
|
||||||
|
const viewCenterY = containerHeight.value / 2 - 60 - py
|
||||||
|
if (viewCenterY > 0 && strideVisual > 0) {
|
||||||
|
const gapsAbove = Math.min(pageCount - 1, Math.max(0, Math.floor(viewCenterY / strideVisual)))
|
||||||
|
py += gapsAbove * 24 * (ratio - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
committedZoom.value = z
|
||||||
|
editorStore.setZoom(z)
|
||||||
|
const [cx, cy] = clampPan(px, py, z)
|
||||||
|
editorStore.setPan(cx, cy)
|
||||||
|
isZoomGesture.value = false
|
||||||
|
zoomCommitTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleZoomCommit() {
|
||||||
|
if (zoomCommitTimer) clearTimeout(zoomCommitTimer)
|
||||||
|
zoomCommitTimer = setTimeout(commitZoom, 120)
|
||||||
|
}
|
||||||
|
|
||||||
// Pan: Space+drag veya orta fare tuşu
|
// Pan: Space+drag veya orta fare tuşu
|
||||||
const isPanning = ref(false)
|
const isPanning = ref(false)
|
||||||
const panStart = ref({ x: 0, y: 0 })
|
const panStart = ref({ x: 0, y: 0 })
|
||||||
@@ -86,9 +238,12 @@ let resizeObserver: ResizeObserver | null = null
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (containerRef.value) {
|
if (containerRef.value) {
|
||||||
resizeObserver = new ResizeObserver(entries => {
|
resizeObserver = new ResizeObserver((entries) => {
|
||||||
const entry = entries[0]
|
const entry = entries[0]
|
||||||
if (entry) containerWidth.value = entry.contentRect.width
|
if (entry) {
|
||||||
|
containerWidth.value = entry.contentRect.width
|
||||||
|
containerHeight.value = entry.contentRect.height
|
||||||
|
}
|
||||||
})
|
})
|
||||||
resizeObserver.observe(containerRef.value)
|
resizeObserver.observe(containerRef.value)
|
||||||
}
|
}
|
||||||
@@ -99,10 +254,19 @@ onMounted(() => {
|
|||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
resizeObserver?.disconnect()
|
resizeObserver?.disconnect()
|
||||||
dispose()
|
dispose()
|
||||||
|
if (zoomCommitTimer) clearTimeout(zoomCommitTimer)
|
||||||
window.removeEventListener('keydown', onKeyDown)
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
window.removeEventListener('keyup', onKeyUp)
|
window.removeEventListener('keyup', onKeyUp)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Store'daki zoom değiştiğinde (dışarıdan, ör. zoom butonları) committed'ı da güncelle
|
||||||
|
watch(() => editorStore.zoom, (z) => {
|
||||||
|
if (!isZoomGesture.value) {
|
||||||
|
committedZoom.value = z
|
||||||
|
gestureZoom.value = z
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Zoom & Pan via wheel/trackpad
|
// Zoom & Pan via wheel/trackpad
|
||||||
const pageRef = ref<HTMLElement | null>(null)
|
const pageRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
@@ -132,10 +296,17 @@ function onWheel(e: WheelEvent) {
|
|||||||
} else {
|
} else {
|
||||||
// İki parmak pan (touchpad) veya normal scroll
|
// İki parmak pan (touchpad) veya normal scroll
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
editorStore.setPan(
|
const curPanX = isZoomGesture.value ? gesturePanX.value : editorStore.panX
|
||||||
editorStore.panX - e.deltaX,
|
const curPanY = isZoomGesture.value ? gesturePanY.value : editorStore.panY
|
||||||
editorStore.panY - e.deltaY,
|
const curZoom = isZoomGesture.value ? gestureZoom.value : editorStore.zoom
|
||||||
)
|
const [cx, cy] = clampPan(curPanX - e.deltaX, curPanY - e.deltaY, curZoom)
|
||||||
|
|
||||||
|
if (isZoomGesture.value) {
|
||||||
|
gesturePanX.value = cx
|
||||||
|
gesturePanY.value = cy
|
||||||
|
} else {
|
||||||
|
editorStore.setPan(cx, cy)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,33 +314,53 @@ function applyZoom(delta: number, clientX: number, clientY: number) {
|
|||||||
const pageEl = pageRef.value
|
const pageEl = pageRef.value
|
||||||
if (!pageEl) return
|
if (!pageEl) return
|
||||||
|
|
||||||
const oldZoom = editorStore.zoom
|
// Gesture başlat veya devam et
|
||||||
|
if (!isZoomGesture.value) {
|
||||||
|
isZoomGesture.value = true
|
||||||
|
gestureZoom.value = editorStore.zoom
|
||||||
|
gesturePanX.value = editorStore.panX
|
||||||
|
gesturePanY.value = editorStore.panY
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldZoom = gestureZoom.value
|
||||||
const zoomFactor = Math.pow(0.99, delta)
|
const zoomFactor = Math.pow(0.99, delta)
|
||||||
const newZoom = Math.max(0.25, Math.min(4, oldZoom * zoomFactor))
|
const newZoom = Math.max(0.25, Math.min(4, oldZoom * zoomFactor))
|
||||||
if (newZoom === oldZoom) return
|
if (newZoom === oldZoom) return
|
||||||
|
|
||||||
// Sayfa elemanının şu anki ekran pozisyonunu al (centering + pan dahil)
|
|
||||||
const pageRect = pageEl.getBoundingClientRect()
|
|
||||||
|
|
||||||
// Mouse'un sayfa üzerindeki pozisyonu (mm cinsinden)
|
// Mouse'un sayfa üzerindeki pozisyonu (mm cinsinden)
|
||||||
|
// pageRef'in ekran pozisyonunu al (CSS transform dahil)
|
||||||
|
const pageRect = pageEl.getBoundingClientRect()
|
||||||
const baseScale = containerWidth.value / templateStore.template.page.width
|
const baseScale = containerWidth.value / templateStore.template.page.width
|
||||||
const oldScale = baseScale * oldZoom
|
const oldGestureScale = baseScale * oldZoom
|
||||||
const newScale = baseScale * newZoom
|
const newGestureScale = baseScale * newZoom
|
||||||
const mousePageMmX = (clientX - pageRect.left) / oldScale
|
const mousePageMmX = (clientX - pageRect.left) / oldGestureScale
|
||||||
const mousePageMmY = (clientY - pageRect.top) / oldScale
|
const mousePageMmY = (clientY - pageRect.top) / oldGestureScale
|
||||||
|
|
||||||
const pageW = templateStore.template.page.width
|
const pageW = templateStore.template.page.width
|
||||||
|
|
||||||
// Yeni pan: mouse'un gösterdiği mm noktası aynı ekran pozisyonunda kalmalı
|
// Yeni pan: mouse'un gösterdiği mm noktası aynı ekran pozisyonunda kalmalı
|
||||||
const newPanX = editorStore.panX + (mousePageMmX - pageW / 2) * (oldScale - newScale)
|
const newPanX = gesturePanX.value + (mousePageMmX - pageW / 2) * (oldGestureScale - newGestureScale)
|
||||||
const newPanY = editorStore.panY + mousePageMmY * (oldScale - newScale)
|
const newPanY = gesturePanY.value + mousePageMmY * (oldGestureScale - newGestureScale)
|
||||||
|
|
||||||
editorStore.setZoom(newZoom)
|
gestureZoom.value = newZoom
|
||||||
editorStore.setPan(newPanX, newPanY)
|
const [cx, cy] = clampPan(newPanX, newPanY, newZoom)
|
||||||
|
gesturePanX.value = cx
|
||||||
|
gesturePanY.value = cy
|
||||||
|
|
||||||
|
scheduleZoomCommit()
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
if (e.code === 'Space' && !e.repeat && !(e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement || e.target instanceof HTMLTextAreaElement || (e.target as HTMLElement)?.isContentEditable)) {
|
if (
|
||||||
|
e.code === 'Space' &&
|
||||||
|
!e.repeat &&
|
||||||
|
!(
|
||||||
|
e.target instanceof HTMLInputElement ||
|
||||||
|
e.target instanceof HTMLSelectElement ||
|
||||||
|
e.target instanceof HTMLTextAreaElement ||
|
||||||
|
(e.target as HTMLElement)?.isContentEditable
|
||||||
|
)
|
||||||
|
) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
spaceHeld.value = true
|
spaceHeld.value = true
|
||||||
}
|
}
|
||||||
@@ -192,7 +383,8 @@ function onPointerDown(e: PointerEvent) {
|
|||||||
|
|
||||||
function onPointerMove(e: PointerEvent) {
|
function onPointerMove(e: PointerEvent) {
|
||||||
if (!isPanning.value) return
|
if (!isPanning.value) return
|
||||||
editorStore.setPan(e.clientX - panStart.value.x, e.clientY - panStart.value.y)
|
const [cx2, cy2] = clampPan(e.clientX - panStart.value.x, e.clientY - panStart.value.y)
|
||||||
|
editorStore.setPan(cx2, cy2)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPointerUp(e: PointerEvent) {
|
function onPointerUp(e: PointerEvent) {
|
||||||
@@ -201,6 +393,17 @@ function onPointerUp(e: PointerEvent) {
|
|||||||
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
|
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onMinimapNavigate(x: number, y: number) {
|
||||||
|
const [cx, cy] = clampPan(x, y)
|
||||||
|
editorStore.setPan(cx, cy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimap'e gerçek scale'i geçir (gesture dahil)
|
||||||
|
const minimapScale = computed(() => {
|
||||||
|
const z = isZoomGesture.value ? gestureZoom.value : editorStore.zoom
|
||||||
|
return (containerWidth.value / templateStore.template.page.width) * z
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -209,9 +412,12 @@ function onPointerUp(e: PointerEvent) {
|
|||||||
<RulerBar
|
<RulerBar
|
||||||
:page-width="templateStore.template.page.width"
|
:page-width="templateStore.template.page.width"
|
||||||
:page-height="templateStore.template.page.height"
|
:page-height="templateStore.template.page.height"
|
||||||
:scale="scale"
|
:scale="minimapScale"
|
||||||
:pan-x="editorStore.panX"
|
:pan-x="isZoomGesture ? gesturePanX : editorStore.panX"
|
||||||
:pan-y="editorStore.panY"
|
:pan-y="isZoomGesture ? gesturePanY : editorStore.panY"
|
||||||
|
:container-width="containerWidth"
|
||||||
|
:page-count="layoutPages.length"
|
||||||
|
:page-gap="24"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Scroll alanı -->
|
<!-- Scroll alanı -->
|
||||||
@@ -225,9 +431,22 @@ function onPointerUp(e: PointerEvent) {
|
|||||||
@pointerup="onPointerUp"
|
@pointerup="onPointerUp"
|
||||||
>
|
>
|
||||||
<!-- Sayfalar -->
|
<!-- Sayfalar -->
|
||||||
<div ref="pageRef" class="editor-canvas__pages" :style="[pagesContainerStyle, panTransform ? { transform: panTransform } : {}]">
|
<div
|
||||||
<LayoutRenderer :layout="layout" :scale="scale" />
|
ref="pageRef"
|
||||||
<InteractionOverlay :scale="scale" :layout-map="layoutMap" :page-count="layoutPages.length" :page-height-px="pageHeightPx" />
|
class="editor-canvas__pages"
|
||||||
|
:style="[
|
||||||
|
pagesContainerStyle,
|
||||||
|
pagesTransform ? { transform: pagesTransform } : {},
|
||||||
|
pagesTransformOrigin ? { transformOrigin: pagesTransformOrigin } : {},
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<LayoutRenderer :layout="layout" :scale="scale" :visible-page-indices="visiblePageIndices" />
|
||||||
|
<InteractionOverlay
|
||||||
|
:scale="scale"
|
||||||
|
:layout-map="layoutMap"
|
||||||
|
:page-count="layoutPages.length"
|
||||||
|
:page-height-px="pageHeightPx"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -235,11 +454,24 @@ function onPointerUp(e: PointerEvent) {
|
|||||||
<div v-if="props.handleErrors && error" class="editor-canvas__error">
|
<div v-if="props.handleErrors && error" class="editor-canvas__error">
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="compiling" class="editor-canvas__compiling">
|
<div v-if="compiling" class="editor-canvas__compiling">Derleniyor...</div>
|
||||||
Derleniyor...
|
|
||||||
</div>
|
<!-- Minimap + zoom göstergesi -->
|
||||||
<div class="editor-canvas__zoom">
|
<div class="editor-canvas__minimap-area">
|
||||||
%{{ editorStore.zoomPercent }}
|
<MinimapOverlay
|
||||||
|
:layout="layout"
|
||||||
|
:page-width="templateStore.template.page.width"
|
||||||
|
:page-height="templateStore.template.page.height"
|
||||||
|
:zoom="isZoomGesture ? gestureZoom : editorStore.zoom"
|
||||||
|
:pan-x="isZoomGesture ? gesturePanX : editorStore.panX"
|
||||||
|
:pan-y="isZoomGesture ? gesturePanY : editorStore.panY"
|
||||||
|
:container-width="containerWidth"
|
||||||
|
:container-height="containerHeight"
|
||||||
|
:scale="minimapScale"
|
||||||
|
:page-gap="24"
|
||||||
|
@navigate="onMinimapNavigate"
|
||||||
|
/>
|
||||||
|
<div class="editor-canvas__zoom">%{{ Math.round((isZoomGesture ? gestureZoom : editorStore.zoom) * 100) }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -297,15 +529,22 @@ function onPointerUp(e: PointerEvent) {
|
|||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-canvas__zoom {
|
.editor-canvas__minimap-area {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 12px;
|
bottom: 12px;
|
||||||
right: 16px;
|
right: 16px;
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-canvas__zoom {
|
||||||
background: rgba(0, 0, 0, 0.6);
|
background: rgba(0, 0, 0, 0.6);
|
||||||
color: white;
|
color: white;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
z-index: 100;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -59,7 +59,12 @@ const layoutStyle = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// justify (main-axis)
|
// justify (main-axis)
|
||||||
const justifyMap = { start: 'flex-start', center: 'center', end: 'flex-end', 'space-between': 'space-between' }
|
const justifyMap = {
|
||||||
|
start: 'flex-start',
|
||||||
|
center: 'center',
|
||||||
|
end: 'flex-end',
|
||||||
|
'space-between': 'space-between',
|
||||||
|
}
|
||||||
style.justifyContent = justifyMap[c.justify] || 'flex-start'
|
style.justifyContent = justifyMap[c.justify] || 'flex-start'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,18 @@ import { computed } 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 { isContainer } from '../../core/types'
|
||||||
import type { ContainerElement, TextStyle, RepeatingTableElement, TableStyle, ChartElement, ChartType } from '../../core/types'
|
import ContainerToolbar from './toolbars/ContainerToolbar.vue'
|
||||||
|
import TextToolbar from './toolbars/TextToolbar.vue'
|
||||||
|
import TableToolbar from './toolbars/TableToolbar.vue'
|
||||||
|
import ChartToolbar from './toolbars/ChartToolbar.vue'
|
||||||
|
import type {
|
||||||
|
ContainerElement,
|
||||||
|
RepeatingTableElement,
|
||||||
|
TableStyle,
|
||||||
|
ChartElement,
|
||||||
|
} from '../../core/types'
|
||||||
import type { LayoutMapEntry } from '../../core/layout-types'
|
import type { LayoutMapEntry } from '../../core/layout-types'
|
||||||
|
import '../../styles/toolbar.css'
|
||||||
|
|
||||||
const PAGE_GAP_PX = 24
|
const PAGE_GAP_PX = 24
|
||||||
|
|
||||||
@@ -23,9 +33,9 @@ const selected = computed(() => {
|
|||||||
return templateStore.getElementById(id) ?? null
|
return templateStore.getElementById(id) ?? null
|
||||||
})
|
})
|
||||||
|
|
||||||
const container = computed(() => {
|
const containerEl = computed(() => {
|
||||||
const el = selected.value
|
const el = selected.value
|
||||||
return el && isContainer(el) ? el as ContainerElement : null
|
return el && isContainer(el) ? (el as ContainerElement) : null
|
||||||
})
|
})
|
||||||
|
|
||||||
const isText = computed(() => {
|
const isText = computed(() => {
|
||||||
@@ -36,15 +46,16 @@ const isText = computed(() => {
|
|||||||
const isLine = computed(() => selected.value?.type === 'line')
|
const isLine = computed(() => selected.value?.type === 'line')
|
||||||
|
|
||||||
const isTable = computed(() => selected.value?.type === 'repeating_table')
|
const isTable = computed(() => selected.value?.type === 'repeating_table')
|
||||||
const tableEl = computed(() => isTable.value ? selected.value as RepeatingTableElement : null)
|
const tableStyle = computed(() =>
|
||||||
const tableStyle = computed(() => tableEl.value?.style as TableStyle | undefined)
|
isTable.value ? ((selected.value as RepeatingTableElement).style as TableStyle) : null,
|
||||||
|
)
|
||||||
|
|
||||||
const isChart = computed(() => selected.value?.type === 'chart')
|
const isChart = computed(() => selected.value?.type === 'chart')
|
||||||
const chartEl = computed(() => isChart.value ? selected.value as ChartElement : null)
|
const chartEl = computed(() => (isChart.value ? (selected.value as ChartElement) : null))
|
||||||
|
|
||||||
function pageYOffset(pageIndex: number): number {
|
function pageYOffset(pageIndex: number): number {
|
||||||
if (pageIndex <= 0) return 0
|
if (pageIndex <= 0) return 0
|
||||||
const pageH = props.pageHeightPx ?? (templateStore.template.page.height * props.scale)
|
const pageH = props.pageHeightPx ?? templateStore.template.page.height * props.scale
|
||||||
return pageIndex * (pageH + PAGE_GAP_PX)
|
return pageIndex * (pageH + PAGE_GAP_PX)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,498 +85,68 @@ function updateStyle(key: string, value: unknown) {
|
|||||||
update({ style: { ...selected.value.style, [key]: value } })
|
update({ style: { ...selected.value.style, [key]: value } })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Container
|
|
||||||
function setDirection(dir: 'row' | 'column') { update({ direction: dir }) }
|
|
||||||
function setAlign(align: string) { update({ align }) }
|
|
||||||
function setJustify(justify: string) { update({ justify }) }
|
|
||||||
function setGap(e: Event) { update({ gap: parseFloat((e.target as HTMLInputElement).value) || 0 }) }
|
|
||||||
|
|
||||||
// Text
|
|
||||||
function setFontWeight(w: string) { updateStyle('fontWeight', w) }
|
|
||||||
function setTextAlign(a: string) { updateStyle('align', a) }
|
|
||||||
|
|
||||||
// Table
|
|
||||||
function updateTableStyle(key: string, value: unknown) {
|
|
||||||
if (!selected.value) return
|
|
||||||
update({ style: { ...selected.value.style, [key]: value } })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chart
|
|
||||||
function setChartType(t: ChartType) { update({ chartType: t }) }
|
|
||||||
function updateChartStyle(key: string, value: unknown) {
|
|
||||||
if (!selected.value) return
|
|
||||||
update({ style: { ...selected.value.style, [key]: value } })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Z-order
|
// Z-order
|
||||||
function bringForward() { if (selected.value) templateStore.bringForward(selected.value.id) }
|
function bringForward() {
|
||||||
function sendBackward() { if (selected.value) templateStore.sendBackward(selected.value.id) }
|
if (selected.value) templateStore.bringForward(selected.value.id)
|
||||||
function bringToFront() { if (selected.value) templateStore.bringToFront(selected.value.id) }
|
}
|
||||||
function sendToBack() { if (selected.value) templateStore.sendToBack(selected.value.id) }
|
function sendBackward() {
|
||||||
|
if (selected.value) templateStore.sendBackward(selected.value.id)
|
||||||
|
}
|
||||||
|
function bringToFront() {
|
||||||
|
if (selected.value) templateStore.bringToFront(selected.value.id)
|
||||||
|
}
|
||||||
|
function sendToBack() {
|
||||||
|
if (selected.value) templateStore.sendToBack(selected.value.id)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="selected" class="et" :style="toolbarStyle" @pointerdown.stop>
|
<div v-if="selected" class="et" :style="toolbarStyle" @pointerdown.stop>
|
||||||
<!-- ===== Container ===== -->
|
<!-- Container -->
|
||||||
<template v-if="container">
|
<ContainerToolbar v-if="containerEl" :container="containerEl" @update="update" />
|
||||||
<!-- Yön -->
|
|
||||||
<div class="et__group">
|
|
||||||
<button class="et__btn" :class="{ 'et__btn--active': container.direction === 'column' }" data-tip="Dikey" @click="setDirection('column')">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
||||||
<rect x="2" y="1" width="10" height="3" rx="0.5" fill="currentColor"/><rect x="2" y="5.5" width="10" height="3" rx="0.5" fill="currentColor"/><rect x="2" y="10" width="10" height="3" rx="0.5" fill="currentColor"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button class="et__btn" :class="{ 'et__btn--active': container.direction === 'row' }" data-tip="Yatay" @click="setDirection('row')">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
||||||
<rect x="1" y="2" width="3" height="10" rx="0.5" fill="currentColor"/><rect x="5.5" y="2" width="3" height="10" rx="0.5" fill="currentColor"/><rect x="10" y="2" width="3" height="10" rx="0.5" fill="currentColor"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="et__sep" />
|
<!-- Text / Static Text -->
|
||||||
|
<TextToolbar v-if="isText" :element="selected!" @update-style="updateStyle" />
|
||||||
|
|
||||||
<!-- Align -->
|
<!-- Repeating Table -->
|
||||||
<div class="et__group">
|
<TableToolbar v-if="isTable && tableStyle" :table-style="tableStyle" @update-style="updateStyle" />
|
||||||
<template v-if="container.direction === 'column'">
|
|
||||||
<button class="et__btn" :class="{ 'et__btn--active': container.align === 'start' }" data-tip="Sol" @click="setAlign('start')">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="2" width="1.5" height="10" rx="0.5" fill="currentColor" opacity="0.4"/><rect x="3.5" y="3" width="8" height="2.5" rx="0.5" fill="currentColor"/><rect x="3.5" y="8" width="5" height="2.5" rx="0.5" fill="currentColor"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="et__btn" :class="{ 'et__btn--active': container.align === 'center' }" data-tip="Orta" @click="setAlign('center')">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="6.25" y="1" width="1.5" height="12" rx="0.5" fill="currentColor" opacity="0.4"/><rect x="3" y="3" width="8" height="2.5" rx="0.5" fill="currentColor"/><rect x="4.5" y="8" width="5" height="2.5" rx="0.5" fill="currentColor"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="et__btn" :class="{ 'et__btn--active': container.align === 'end' }" data-tip="Sag" @click="setAlign('end')">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="11.5" y="2" width="1.5" height="10" rx="0.5" fill="currentColor" opacity="0.4"/><rect x="2.5" y="3" width="8" height="2.5" rx="0.5" fill="currentColor"/><rect x="5.5" y="8" width="5" height="2.5" rx="0.5" fill="currentColor"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="et__btn" :class="{ 'et__btn--active': container.align === 'stretch' }" data-tip="Esnet" @click="setAlign('stretch')">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="2" width="1.5" height="10" rx="0.5" fill="currentColor" opacity="0.4"/><rect x="11.5" y="2" width="1.5" height="10" rx="0.5" fill="currentColor" opacity="0.4"/><rect x="3.5" y="3" width="7" height="2.5" rx="0.5" fill="currentColor"/><rect x="3.5" y="8" width="7" height="2.5" rx="0.5" fill="currentColor"/></svg>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<button class="et__btn" :class="{ 'et__btn--active': container.align === 'start' }" data-tip="Ust" @click="setAlign('start')">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="1" width="10" height="1.5" rx="0.5" fill="currentColor" opacity="0.4"/><rect x="3" y="3.5" width="2.5" height="8" rx="0.5" fill="currentColor"/><rect x="8" y="3.5" width="2.5" height="5" rx="0.5" fill="currentColor"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="et__btn" :class="{ 'et__btn--active': container.align === 'center' }" data-tip="Orta" @click="setAlign('center')">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="6.25" width="12" height="1.5" rx="0.5" fill="currentColor" opacity="0.4"/><rect x="3" y="2" width="2.5" height="10" rx="0.5" fill="currentColor"/><rect x="8" y="3.5" width="2.5" height="7" rx="0.5" fill="currentColor"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="et__btn" :class="{ 'et__btn--active': container.align === 'end' }" data-tip="Alt" @click="setAlign('end')">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="11.5" width="10" height="1.5" rx="0.5" fill="currentColor" opacity="0.4"/><rect x="3" y="2.5" width="2.5" height="8" rx="0.5" fill="currentColor"/><rect x="8" y="5.5" width="2.5" height="5" rx="0.5" fill="currentColor"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="et__btn" :class="{ 'et__btn--active': container.align === 'stretch' }" data-tip="Esnet" @click="setAlign('stretch')">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="1" width="10" height="1.5" rx="0.5" fill="currentColor" opacity="0.4"/><rect x="2" y="11.5" width="10" height="1.5" rx="0.5" fill="currentColor" opacity="0.4"/><rect x="3" y="3.5" width="2.5" height="7" rx="0.5" fill="currentColor"/><rect x="8" y="3.5" width="2.5" height="7" rx="0.5" fill="currentColor"/></svg>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="et__sep" />
|
<!-- Chart -->
|
||||||
|
<ChartToolbar v-if="isChart && chartEl" :chart="chartEl" @update="update" @update-style="updateStyle" />
|
||||||
|
|
||||||
<!-- Justify -->
|
<!-- Line -->
|
||||||
<div class="et__group">
|
|
||||||
<template v-if="container.direction === 'column'">
|
|
||||||
<button class="et__btn" :class="{ 'et__btn--active': container.justify === 'start' }" data-tip="Ust" @click="setJustify('start')">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="1" width="10" height="1.5" rx="0.5" fill="currentColor" opacity="0.4"/><rect x="3" y="3.5" width="8" height="2" rx="0.5" fill="currentColor"/><rect x="3" y="6.5" width="8" height="2" rx="0.5" fill="currentColor"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="et__btn" :class="{ 'et__btn--active': container.justify === 'center' }" data-tip="Orta" @click="setJustify('center')">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="6.25" width="12" height="1.5" rx="0.5" fill="currentColor" opacity="0.4"/><rect x="3" y="3" width="8" height="2" rx="0.5" fill="currentColor"/><rect x="3" y="9" width="8" height="2" rx="0.5" fill="currentColor"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="et__btn" :class="{ 'et__btn--active': container.justify === 'end' }" data-tip="Alt" @click="setJustify('end')">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="11.5" width="10" height="1.5" rx="0.5" fill="currentColor" opacity="0.4"/><rect x="3" y="5.5" width="8" height="2" rx="0.5" fill="currentColor"/><rect x="3" y="8.5" width="8" height="2" rx="0.5" fill="currentColor"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="et__btn" :class="{ 'et__btn--active': container.justify === 'space-between' }" data-tip="Esit Aralik" @click="setJustify('space-between')">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="1" width="10" height="1.5" rx="0.5" fill="currentColor" opacity="0.4"/><rect x="2" y="11.5" width="10" height="1.5" rx="0.5" fill="currentColor" opacity="0.4"/><rect x="3" y="3.5" width="8" height="2" rx="0.5" fill="currentColor"/><rect x="3" y="8.5" width="8" height="2" rx="0.5" fill="currentColor"/></svg>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<button class="et__btn" :class="{ 'et__btn--active': container.justify === 'start' }" data-tip="Sol" @click="setJustify('start')">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="2" width="1.5" height="10" rx="0.5" fill="currentColor" opacity="0.4"/><rect x="3.5" y="3" width="3" height="8" rx="0.5" fill="currentColor"/><rect x="7.5" y="3" width="3" height="8" rx="0.5" fill="currentColor"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="et__btn" :class="{ 'et__btn--active': container.justify === 'center' }" data-tip="Orta" @click="setJustify('center')">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="6.25" y="1" width="1.5" height="12" rx="0.5" fill="currentColor" opacity="0.4"/><rect x="2" y="3" width="3" height="8" rx="0.5" fill="currentColor"/><rect x="9" y="3" width="3" height="8" rx="0.5" fill="currentColor"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="et__btn" :class="{ 'et__btn--active': container.justify === 'end' }" data-tip="Sag" @click="setJustify('end')">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="11.5" y="2" width="1.5" height="10" rx="0.5" fill="currentColor" opacity="0.4"/><rect x="3.5" y="3" width="3" height="8" rx="0.5" fill="currentColor"/><rect x="7.5" y="3" width="3" height="8" rx="0.5" fill="currentColor"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="et__btn" :class="{ 'et__btn--active': container.justify === 'space-between' }" data-tip="Esit Aralik" @click="setJustify('space-between')">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="2" width="1.5" height="10" rx="0.5" fill="currentColor" opacity="0.4"/><rect x="11.5" y="2" width="1.5" height="10" rx="0.5" fill="currentColor" opacity="0.4"/><rect x="3.5" y="3" width="3" height="8" rx="0.5" fill="currentColor"/><rect x="7.5" y="3" width="3" height="8" rx="0.5" fill="currentColor"/></svg>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="et__sep" />
|
|
||||||
|
|
||||||
<!-- Gap -->
|
|
||||||
<div class="et__group et__group--gap" data-tip="Bosluk (mm)">
|
|
||||||
<svg class="et__gap-icon" width="12" height="12" viewBox="0 0 12 12" fill="none">
|
|
||||||
<rect x="1" y="1" width="3.5" height="10" rx="0.5" stroke="currentColor" stroke-width="1" fill="none"/><rect x="7.5" y="1" width="3.5" height="10" rx="0.5" stroke="currentColor" stroke-width="1" fill="none"/><line x1="6" y1="3" x2="6" y2="9" stroke="currentColor" stroke-width="1" stroke-dasharray="1.5 1"/>
|
|
||||||
</svg>
|
|
||||||
<input type="number" class="et__num" step="1" min="0" :value="container.gap" @input="setGap" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- ===== Text / Static Text ===== -->
|
|
||||||
<template v-if="isText">
|
|
||||||
<!-- Bold -->
|
|
||||||
<div class="et__group">
|
|
||||||
<button class="et__btn" :class="{ 'et__btn--active': (selected!.style as TextStyle).fontWeight === 'bold' }" data-tip="Kalin" @click="setFontWeight((selected!.style as TextStyle).fontWeight === 'bold' ? 'normal' : 'bold')">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
||||||
<path d="M4 2.5h3.5a2.5 2.5 0 0 1 0 5H4V2.5z" stroke="currentColor" stroke-width="1.5" fill="none"/>
|
|
||||||
<path d="M4 7.5h4a2.5 2.5 0 0 1 0 5H4V7.5z" stroke="currentColor" stroke-width="1.5" fill="none"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="et__sep" />
|
|
||||||
|
|
||||||
<!-- Align -->
|
|
||||||
<div class="et__group">
|
|
||||||
<button class="et__btn" :class="{ 'et__btn--active': ((selected!.style as TextStyle).align ?? 'left') === 'left' }" data-tip="Sola Hizala" @click="setTextAlign('left')">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
||||||
<line x1="2" y1="3" x2="12" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
||||||
<line x1="2" y1="7" x2="9" y2="7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
||||||
<line x1="2" y1="11" x2="11" y2="11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button class="et__btn" :class="{ 'et__btn--active': (selected!.style as TextStyle).align === 'center' }" data-tip="Ortala" @click="setTextAlign('center')">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
||||||
<line x1="2" y1="3" x2="12" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
||||||
<line x1="3.5" y1="7" x2="10.5" y2="7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
||||||
<line x1="2.5" y1="11" x2="11.5" y2="11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button class="et__btn" :class="{ 'et__btn--active': (selected!.style as TextStyle).align === 'right' }" data-tip="Saga Hizala" @click="setTextAlign('right')">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
||||||
<line x1="2" y1="3" x2="12" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
||||||
<line x1="5" y1="7" x2="12" y2="7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
||||||
<line x1="3" y1="11" x2="12" y2="11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="et__sep" />
|
|
||||||
|
|
||||||
<!-- Font size -->
|
|
||||||
<div class="et__group et__group--gap">
|
|
||||||
<svg class="et__gap-icon" width="12" height="12" viewBox="0 0 12 12" fill="none">
|
|
||||||
<path d="M2 10L6 2l4 8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
|
||||||
<line x1="3.5" y1="7" x2="8.5" y2="7" stroke="currentColor" stroke-width="1" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
<input type="number" class="et__num" step="1" min="1" :value="(selected!.style as TextStyle).fontSize ?? 11" @input="(e) => updateStyle('fontSize', parseFloat((e.target as HTMLInputElement).value) || 11)" data-tip="Yazi Boyutu (pt)" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="et__sep" />
|
|
||||||
|
|
||||||
<!-- Color -->
|
|
||||||
<div class="et__group">
|
|
||||||
<label class="et__color-wrap" data-tip="Renk">
|
|
||||||
<input type="color" class="et__color" :value="(selected!.style as TextStyle).color ?? '#000000'" @input="(e) => updateStyle('color', (e.target as HTMLInputElement).value)" />
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
||||||
<rect x="2" y="11" width="10" height="2" rx="0.5" :fill="(selected!.style as TextStyle).color ?? '#000000'"/>
|
|
||||||
<path d="M5 9L7 3l2 6" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
|
||||||
<line x1="5.5" y1="7.5" x2="8.5" y2="7.5" stroke="currentColor" stroke-width="1" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- ===== Repeating Table ===== -->
|
|
||||||
<template v-if="isTable && tableStyle">
|
|
||||||
<!-- Font size -->
|
|
||||||
<div class="et__group et__group--gap" data-tip="Yazi Boyutu">
|
|
||||||
<svg class="et__gap-icon" width="12" height="12" viewBox="0 0 12 12" fill="none">
|
|
||||||
<path d="M2 10L6 2l4 8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
|
||||||
<line x1="3.5" y1="7" x2="8.5" y2="7" stroke="currentColor" stroke-width="1" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
<input type="number" class="et__num" step="1" min="6" :value="tableStyle.fontSize ?? 10" @input="(e) => updateTableStyle('fontSize', parseFloat((e.target as HTMLInputElement).value) || 10)" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="et__sep" />
|
|
||||||
|
|
||||||
<!-- Header bg color -->
|
|
||||||
<div class="et__group">
|
|
||||||
<label class="et__color-wrap" data-tip="Header Rengi">
|
|
||||||
<input type="color" class="et__color" :value="tableStyle.headerBg ?? '#f0f0f0'" @input="(e) => updateTableStyle('headerBg', (e.target as HTMLInputElement).value)" />
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
||||||
<rect x="2" y="2" width="10" height="4" rx="1" :fill="tableStyle.headerBg ?? '#f0f0f0'" stroke="#94a3b8" stroke-width="0.5"/>
|
|
||||||
<rect x="2" y="7" width="10" height="2" rx="0.5" fill="none" stroke="#94a3b8" stroke-width="0.5"/>
|
|
||||||
<rect x="2" y="10" width="10" height="2" rx="0.5" fill="none" stroke="#94a3b8" stroke-width="0.5"/>
|
|
||||||
</svg>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Zebra color -->
|
|
||||||
<div class="et__group">
|
|
||||||
<label class="et__color-wrap" data-tip="Zebra Rengi">
|
|
||||||
<input type="color" class="et__color" :value="tableStyle.zebraOdd ?? '#fafafa'" @input="(e) => updateTableStyle('zebraOdd', (e.target as HTMLInputElement).value)" />
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
||||||
<rect x="2" y="2" width="10" height="2.5" rx="0.5" fill="none" stroke="#94a3b8" stroke-width="0.5"/>
|
|
||||||
<rect x="2" y="5.5" width="10" height="2.5" rx="0.5" :fill="tableStyle.zebraOdd ?? '#fafafa'" stroke="#94a3b8" stroke-width="0.5"/>
|
|
||||||
<rect x="2" y="9" width="10" height="2.5" rx="0.5" fill="none" stroke="#94a3b8" stroke-width="0.5"/>
|
|
||||||
</svg>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="et__sep" />
|
|
||||||
|
|
||||||
<!-- Border color -->
|
|
||||||
<div class="et__group">
|
|
||||||
<label class="et__color-wrap" data-tip="Kenarlik Rengi">
|
|
||||||
<input type="color" class="et__color" :value="tableStyle.borderColor ?? '#cccccc'" @input="(e) => updateTableStyle('borderColor', (e.target as HTMLInputElement).value)" />
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
||||||
<rect x="2" y="2" width="10" height="10" rx="1" fill="none" :stroke="tableStyle.borderColor ?? '#cccccc'" stroke-width="1.5"/>
|
|
||||||
<line x1="2" y1="6" x2="12" y2="6" :stroke="tableStyle.borderColor ?? '#cccccc'" stroke-width="0.8"/>
|
|
||||||
<line x1="7" y1="2" x2="7" y2="12" :stroke="tableStyle.borderColor ?? '#cccccc'" stroke-width="0.8"/>
|
|
||||||
</svg>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Border width -->
|
|
||||||
<div class="et__group et__group--gap" data-tip="Kenarlik (mm)">
|
|
||||||
<svg class="et__gap-icon" width="12" height="12" viewBox="0 0 12 12" fill="none">
|
|
||||||
<rect x="1" y="1" width="10" height="10" rx="1" fill="none" stroke="currentColor" stroke-width="1.5"/>
|
|
||||||
</svg>
|
|
||||||
<input type="number" class="et__num" step="0.1" min="0" :value="tableStyle.borderWidth ?? 0.5" @input="(e) => updateTableStyle('borderWidth', parseFloat((e.target as HTMLInputElement).value) || 0)" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- ===== Chart ===== -->
|
|
||||||
<template v-if="isChart && chartEl">
|
|
||||||
<!-- Chart type -->
|
|
||||||
<div class="et__group">
|
|
||||||
<button class="et__btn" :class="{ 'et__btn--active': chartEl.chartType === 'bar' }" data-tip="Cubuk" @click="setChartType('bar')">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
||||||
<rect x="2" y="6" width="3" height="6" rx="0.5" fill="currentColor"/>
|
|
||||||
<rect x="5.5" y="3" width="3" height="9" rx="0.5" fill="currentColor"/>
|
|
||||||
<rect x="9" y="5" width="3" height="7" rx="0.5" fill="currentColor"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button class="et__btn" :class="{ 'et__btn--active': chartEl.chartType === 'line' }" data-tip="Cizgi" @click="setChartType('line')">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
||||||
<polyline points="2,10 5,5 8,7 12,3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
|
||||||
<circle cx="2" cy="10" r="1.2" fill="currentColor"/><circle cx="5" cy="5" r="1.2" fill="currentColor"/><circle cx="8" cy="7" r="1.2" fill="currentColor"/><circle cx="12" cy="3" r="1.2" fill="currentColor"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button class="et__btn" :class="{ 'et__btn--active': chartEl.chartType === 'pie' }" data-tip="Pasta" @click="setChartType('pie')">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
||||||
<path d="M7 2a5 5 0 1 1-3.54 1.46" stroke="currentColor" stroke-width="1.3" fill="none"/>
|
|
||||||
<path d="M7 7V2A5 5 0 0 0 3.46 3.46Z" fill="currentColor"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="et__sep" />
|
|
||||||
|
|
||||||
<!-- Show labels -->
|
|
||||||
<div class="et__group">
|
|
||||||
<button class="et__btn" :class="{ 'et__btn--active': chartEl.labels?.show !== false }" data-tip="Etiketler" @click="update({ labels: { ...chartEl.labels, show: chartEl.labels?.show === false ? true : false } })">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
||||||
<rect x="2" y="8" width="3" height="4" rx="0.5" fill="currentColor" opacity="0.4"/>
|
|
||||||
<rect x="5.5" y="5" width="3" height="7" rx="0.5" fill="currentColor" opacity="0.4"/>
|
|
||||||
<rect x="9" y="6" width="3" height="6" rx="0.5" fill="currentColor" opacity="0.4"/>
|
|
||||||
<text x="3.5" y="7" font-size="4" fill="currentColor" text-anchor="middle" font-weight="bold">3</text>
|
|
||||||
<text x="7" y="4" font-size="4" fill="currentColor" text-anchor="middle" font-weight="bold">7</text>
|
|
||||||
<text x="10.5" y="5" font-size="4" fill="currentColor" text-anchor="middle" font-weight="bold">5</text>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="et__sep" />
|
|
||||||
|
|
||||||
<!-- Show grid -->
|
|
||||||
<div class="et__group">
|
|
||||||
<button class="et__btn" :class="{ 'et__btn--active': chartEl.axis?.showGrid !== false }" data-tip="Izgara" @click="update({ axis: { ...chartEl.axis, showGrid: chartEl.axis?.showGrid === false ? true : false } })">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
||||||
<line x1="2" y1="3" x2="12" y2="3" stroke="currentColor" stroke-width="0.8" stroke-dasharray="2 1.5"/>
|
|
||||||
<line x1="2" y1="7" x2="12" y2="7" stroke="currentColor" stroke-width="0.8" stroke-dasharray="2 1.5"/>
|
|
||||||
<line x1="2" y1="11" x2="12" y2="11" stroke="currentColor" stroke-width="0.8" stroke-dasharray="2 1.5"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="et__sep" />
|
|
||||||
|
|
||||||
<!-- Background color -->
|
|
||||||
<div class="et__group">
|
|
||||||
<label class="et__color-wrap" data-tip="Arka Plan">
|
|
||||||
<input type="color" class="et__color" :value="chartEl.style.backgroundColor ?? '#ffffff'" @input="(e) => updateChartStyle('backgroundColor', (e.target as HTMLInputElement).value)" />
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
|
||||||
<rect x="2" y="2" width="10" height="10" rx="1.5" :fill="chartEl.style.backgroundColor ?? '#ffffff'" stroke="#94a3b8" stroke-width="0.8"/>
|
|
||||||
</svg>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- ===== Line ===== -->
|
|
||||||
<template v-if="isLine">
|
<template v-if="isLine">
|
||||||
<!-- Stroke width -->
|
|
||||||
<div class="et__group et__group--gap">
|
<div class="et__group et__group--gap">
|
||||||
<svg class="et__gap-icon" width="12" height="12" viewBox="0 0 12 12" fill="none">
|
<svg class="et__gap-icon" width="12" height="12" viewBox="0 0 12 12" fill="none"><line x1="1" y1="6" x2="11" y2="6" stroke="currentColor" stroke-width="2" stroke-linecap="round" /></svg>
|
||||||
<line x1="1" y1="6" x2="11" y2="6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
<input type="number" class="et__num" step="0.1" min="0.1" :value="(selected!.style as any).strokeWidth ?? 0.5" @input="(e) => updateStyle('strokeWidth', parseFloat((e.target as HTMLInputElement).value) || 0.5)" data-tip="Kalinlik (mm)" />
|
<input type="number" class="et__num" step="0.1" min="0.1" :value="(selected!.style as any).strokeWidth ?? 0.5" @input="(e) => updateStyle('strokeWidth', parseFloat((e.target as HTMLInputElement).value) || 0.5)" data-tip="Kalinlik (mm)" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="et__sep" />
|
<div class="et__sep" />
|
||||||
|
|
||||||
<!-- Color -->
|
|
||||||
<div class="et__group">
|
<div class="et__group">
|
||||||
<label class="et__color-wrap" data-tip="Renk">
|
<label class="et__color-wrap" data-tip="Renk">
|
||||||
<input type="color" class="et__color" :value="(selected!.style as any).strokeColor ?? '#000000'" @input="(e) => updateStyle('strokeColor', (e.target as HTMLInputElement).value)" />
|
<input type="color" class="et__color" :value="(selected!.style as any).strokeColor ?? '#000000'" @input="(e) => updateStyle('strokeColor', (e.target as HTMLInputElement).value)" />
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><line x1="2" y1="7" x2="12" y2="7" :stroke="(selected!.style as any).strokeColor ?? '#000000'" stroke-width="2.5" stroke-linecap="round" /></svg>
|
||||||
<line x1="2" y1="7" x2="12" y2="7" :stroke="(selected!.style as any).strokeColor ?? '#000000'" stroke-width="2.5" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- ===== Z-Order (tüm elemanlar) ===== -->
|
<!-- Z-Order (all elements) -->
|
||||||
<template v-if="selected">
|
<template v-if="selected">
|
||||||
<div class="et__sep" />
|
<div class="et__sep" />
|
||||||
<div class="et__group">
|
<div class="et__group">
|
||||||
<button class="et__btn" data-tip="Arkaya Gonder" @click="sendToBack">
|
<button class="et__btn" data-tip="Arkaya Gonder" @click="sendToBack">
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="5" y="5" width="7" height="7" rx="1" fill="currentColor" opacity="0.3" /><rect x="2" y="2" width="7" height="7" rx="1" fill="currentColor" /></svg>
|
||||||
<rect x="5" y="5" width="7" height="7" rx="1" fill="currentColor" opacity="0.3"/>
|
|
||||||
<rect x="2" y="2" width="7" height="7" rx="1" fill="currentColor"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
<button class="et__btn" data-tip="Bir Geri" @click="sendBackward">
|
<button class="et__btn" data-tip="Bir Geri" @click="sendBackward">
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="5" y="5" width="7" height="7" rx="1" fill="currentColor" opacity="0.3" /><rect x="2" y="2" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.2" fill="none" /></svg>
|
||||||
<rect x="5" y="5" width="7" height="7" rx="1" fill="currentColor" opacity="0.3"/>
|
|
||||||
<rect x="2" y="2" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.2" fill="none"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
<button class="et__btn" data-tip="Bir Ileri" @click="bringForward">
|
<button class="et__btn" data-tip="Bir Ileri" @click="bringForward">
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="2" width="7" height="7" rx="1" fill="currentColor" opacity="0.3" /><rect x="5" y="5" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.2" fill="none" /></svg>
|
||||||
<rect x="2" y="2" width="7" height="7" rx="1" fill="currentColor" opacity="0.3"/>
|
|
||||||
<rect x="5" y="5" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.2" fill="none"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
<button class="et__btn" data-tip="One Getir" @click="bringToFront">
|
<button class="et__btn" data-tip="One Getir" @click="bringToFront">
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="2" width="7" height="7" rx="1" fill="currentColor" opacity="0.3" /><rect x="5" y="5" width="7" height="7" rx="1" fill="currentColor" /></svg>
|
||||||
<rect x="2" y="2" width="7" height="7" rx="1" fill="currentColor" opacity="0.3"/>
|
|
||||||
<rect x="5" y="5" width="7" height="7" rx="1" fill="currentColor"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.et {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
background: #1e293b;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 3px 4px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.06);
|
|
||||||
pointer-events: auto;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.et__group {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.et__sep {
|
|
||||||
width: 1px;
|
|
||||||
height: 16px;
|
|
||||||
background: #334155;
|
|
||||||
margin: 0 2px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Button */
|
|
||||||
.et__btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: transparent;
|
|
||||||
color: #94a3b8;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
transition: background 0.1s, color 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.et__btn:hover {
|
|
||||||
background: #334155;
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.et__btn--active {
|
|
||||||
background: #3b82f6;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.et__btn--active:hover {
|
|
||||||
background: #2563eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Number input */
|
|
||||||
.et__group--gap {
|
|
||||||
gap: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.et__gap-icon {
|
|
||||||
color: #64748b;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.et__num {
|
|
||||||
width: 32px;
|
|
||||||
height: 22px;
|
|
||||||
border: 1px solid #334155;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: #0f172a;
|
|
||||||
color: #e2e8f0;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 11px;
|
|
||||||
font-family: inherit;
|
|
||||||
padding: 0;
|
|
||||||
outline: none;
|
|
||||||
-moz-appearance: textfield;
|
|
||||||
}
|
|
||||||
|
|
||||||
.et__num::-webkit-inner-spin-button,
|
|
||||||
.et__num::-webkit-outer-spin-button {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.et__num:focus {
|
|
||||||
border-color: #3b82f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Color */
|
|
||||||
.et__color-wrap {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
position: relative;
|
|
||||||
color: #94a3b8;
|
|
||||||
transition: background 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.et__color-wrap:hover {
|
|
||||||
background: #334155;
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.et__color {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
opacity: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const templateStore = useTemplateStore()
|
const templateStore = useTemplateStore()
|
||||||
const editorStore = useEditorStore()
|
const editorStore = useEditorStore()
|
||||||
const { activeGuides, collectEdges, calculateSnap, calculateResizeSnap, clearGuides } = useSnapGuides()
|
const { activeGuides, collectEdges, calculateSnap, calculateResizeSnap, clearGuides } =
|
||||||
|
useSnapGuides()
|
||||||
|
|
||||||
// Tüm elemanları flat olarak topla (root hariç)
|
// Tüm elemanları flat olarak topla (root hariç)
|
||||||
const flatElements = computed(() => {
|
const flatElements = computed(() => {
|
||||||
@@ -69,7 +70,7 @@ const allContainers = computed(() => {
|
|||||||
/** Sayfa index'ine göre y offset hesapla (sayfalar arası gap dahil) */
|
/** Sayfa index'ine göre y offset hesapla (sayfalar arası gap dahil) */
|
||||||
function pageYOffset(pageIndex: number): number {
|
function pageYOffset(pageIndex: number): number {
|
||||||
if (pageIndex <= 0) return 0
|
if (pageIndex <= 0) return 0
|
||||||
const pageH = props.pageHeightPx ?? (templateStore.template.page.height * props.scale)
|
const pageH = props.pageHeightPx ?? templateStore.template.page.height * props.scale
|
||||||
return pageIndex * (pageH + PAGE_GAP_PX)
|
return pageIndex * (pageH + PAGE_GAP_PX)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +119,11 @@ const dropVisualIndex = ref<number | null>(null)
|
|||||||
const dropLogicalIndex = ref<number | null>(null)
|
const dropLogicalIndex = ref<number | null>(null)
|
||||||
|
|
||||||
/** Mouse pozisyonuna göre en derin container'ı bul */
|
/** Mouse pozisyonuna göre en derin container'ı bul */
|
||||||
function findDeepestContainer(mouseX: number, mouseY: number, excludeId?: string): ContainerElement {
|
function findDeepestContainer(
|
||||||
|
mouseX: number,
|
||||||
|
mouseY: number,
|
||||||
|
excludeId?: string,
|
||||||
|
): ContainerElement {
|
||||||
const s = props.scale
|
const s = props.scale
|
||||||
let best: ContainerElement = templateStore.template.root
|
let best: ContainerElement = templateStore.template.root
|
||||||
|
|
||||||
@@ -135,7 +140,7 @@ function findDeepestContainer(mouseX: number, mouseY: number, excludeId?: string
|
|||||||
if (mouseX >= cx && mouseX <= cx + cw && mouseY >= cy && mouseY <= cy + ch) {
|
if (mouseX >= cx && mouseX <= cx + cw && mouseY >= cy && mouseY <= cy + ch) {
|
||||||
// Daha küçük (daha derin) container'ı tercih et
|
// Daha küçük (daha derin) container'ı tercih et
|
||||||
const bestL = props.layoutMap[best.id]
|
const bestL = props.layoutMap[best.id]
|
||||||
if (!bestL || (cw * ch < bestL.width_mm * s * bestL.height_mm * s)) {
|
if (!bestL || cw * ch < bestL.width_mm * s * bestL.height_mm * s) {
|
||||||
best = c
|
best = c
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,9 +149,16 @@ function findDeepestContainer(mouseX: number, mouseY: number, excludeId?: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Container içinde drop index hesapla */
|
/** Container içinde drop index hesapla */
|
||||||
function computeDropIndex(container: ContainerElement, mouseX: number, mouseY: number, excludeId?: string) {
|
function computeDropIndex(
|
||||||
|
container: ContainerElement,
|
||||||
|
mouseX: number,
|
||||||
|
mouseY: number,
|
||||||
|
excludeId?: string,
|
||||||
|
) {
|
||||||
const s = props.scale
|
const s = props.scale
|
||||||
const flowChildren = container.children.filter(c => c.type !== 'page_break' && c.position.type !== 'absolute' && c.id !== excludeId)
|
const flowChildren = container.children.filter(
|
||||||
|
(c) => c.type !== 'page_break' && c.position.type !== 'absolute' && c.id !== excludeId,
|
||||||
|
)
|
||||||
const isRow = container.direction === 'row'
|
const isRow = container.direction === 'row'
|
||||||
|
|
||||||
let visualIdx = flowChildren.length
|
let visualIdx = flowChildren.length
|
||||||
@@ -156,18 +168,26 @@ function computeDropIndex(container: ContainerElement, mouseX: number, mouseY: n
|
|||||||
if (!l) continue
|
if (!l) continue
|
||||||
if (isRow) {
|
if (isRow) {
|
||||||
const centerX = l.x_mm * s + (l.width_mm * s) / 2
|
const centerX = l.x_mm * s + (l.width_mm * s) / 2
|
||||||
if (mouseX < centerX) { visualIdx = i; break }
|
if (mouseX < centerX) {
|
||||||
|
visualIdx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const centerY = l.y_mm * s + pageYOffset(l.pageIndex) + (l.height_mm * s) / 2
|
const centerY = l.y_mm * s + pageYOffset(l.pageIndex) + (l.height_mm * s) / 2
|
||||||
if (mouseY < centerY) { visualIdx = i; break }
|
if (mouseY < centerY) {
|
||||||
|
visualIdx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mantıksal index: excludeId aynı container'daysa offset hesapla
|
// Mantıksal index: excludeId aynı container'daysa offset hesapla
|
||||||
let logicalIdx = visualIdx
|
let logicalIdx = visualIdx
|
||||||
if (excludeId) {
|
if (excludeId) {
|
||||||
const allFlow = container.children.filter(c => c.type !== 'page_break' && c.position.type !== 'absolute')
|
const allFlow = container.children.filter(
|
||||||
const currentIdx = allFlow.findIndex(c => c.id === excludeId)
|
(c) => c.type !== 'page_break' && c.position.type !== 'absolute',
|
||||||
|
)
|
||||||
|
const currentIdx = allFlow.findIndex((c) => c.id === excludeId)
|
||||||
if (currentIdx >= 0) {
|
if (currentIdx >= 0) {
|
||||||
// visualIdx, excludeId çıkarılmış listede. Gerçek listedeki pozisyona çevir.
|
// visualIdx, excludeId çıkarılmış listede. Gerçek listedeki pozisyona çevir.
|
||||||
// flowChildren zaten excludeId hariç, dolayısıyla visualIdx doğrudan gerçek insert indexi
|
// flowChildren zaten excludeId hariç, dolayısıyla visualIdx doğrudan gerçek insert indexi
|
||||||
@@ -177,7 +197,10 @@ function computeDropIndex(container: ContainerElement, mouseX: number, mouseY: n
|
|||||||
let count = 0
|
let count = 0
|
||||||
for (let i = 0; i < allFlow.length; i++) {
|
for (let i = 0; i < allFlow.length; i++) {
|
||||||
if (allFlow[i].id === excludeId) continue
|
if (allFlow[i].id === excludeId) continue
|
||||||
if (count === visualIdx) { realIdx = i; break }
|
if (count === visualIdx) {
|
||||||
|
realIdx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
count++
|
count++
|
||||||
realIdx = i + 1
|
realIdx = i + 1
|
||||||
}
|
}
|
||||||
@@ -219,7 +242,9 @@ const dropIndicatorStyle = computed(() => {
|
|||||||
|
|
||||||
// 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.type !== 'page_break' && c.position.type !== 'absolute' && c.id !== dragId)
|
const flowChildren = container.children.filter(
|
||||||
|
(c) => c.type !== 'page_break' && c.position.type !== 'absolute' && c.id !== dragId,
|
||||||
|
)
|
||||||
|
|
||||||
const cl = props.layoutMap[container.id]
|
const cl = props.layoutMap[container.id]
|
||||||
if (!cl) return { display: 'none' }
|
if (!cl) return { display: 'none' }
|
||||||
@@ -380,13 +405,18 @@ function onDragEnd() {
|
|||||||
window.removeEventListener('pointermove', onDragMove)
|
window.removeEventListener('pointermove', onDragMove)
|
||||||
window.removeEventListener('pointerup', onDragEnd)
|
window.removeEventListener('pointerup', onDragEnd)
|
||||||
|
|
||||||
if (isDragging.value && dragElementId.value && dropTargetContainerId.value !== null && dropLogicalIndex.value !== null) {
|
if (
|
||||||
|
isDragging.value &&
|
||||||
|
dragElementId.value &&
|
||||||
|
dropTargetContainerId.value !== null &&
|
||||||
|
dropLogicalIndex.value !== null
|
||||||
|
) {
|
||||||
const currentParent = templateStore.getParent(dragElementId.value)
|
const currentParent = templateStore.getParent(dragElementId.value)
|
||||||
const targetContainerId = dropTargetContainerId.value
|
const targetContainerId = dropTargetContainerId.value
|
||||||
|
|
||||||
if (currentParent && currentParent.id === targetContainerId) {
|
if (currentParent && currentParent.id === targetContainerId) {
|
||||||
// Aynı container içinde reorder
|
// Aynı container içinde reorder
|
||||||
const currentIdx = currentParent.children.findIndex(c => c.id === dragElementId.value)
|
const currentIdx = currentParent.children.findIndex((c) => c.id === dragElementId.value)
|
||||||
if (currentIdx !== -1 && currentIdx !== dropLogicalIndex.value) {
|
if (currentIdx !== -1 && currentIdx !== dropLogicalIndex.value) {
|
||||||
templateStore.reorderChild(currentParent.id, currentIdx, dropLogicalIndex.value)
|
templateStore.reorderChild(currentParent.id, currentIdx, dropLogicalIndex.value)
|
||||||
}
|
}
|
||||||
@@ -400,7 +430,9 @@ function onDragEnd() {
|
|||||||
dragElementId.value = null
|
dragElementId.value = null
|
||||||
editorStore.setDragging(false)
|
editorStore.setDragging(false)
|
||||||
clearDropTarget()
|
clearDropTarget()
|
||||||
setTimeout(() => { didDrag.value = false }, 50)
|
setTimeout(() => {
|
||||||
|
didDrag.value = false
|
||||||
|
}, 50)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Absolute eleman drag ---
|
// --- Absolute eleman drag ---
|
||||||
@@ -420,7 +452,12 @@ function onAbsoluteDragStart(e: PointerEvent, el: TemplateElement) {
|
|||||||
elY: el.position.y,
|
elY: el.position.y,
|
||||||
}
|
}
|
||||||
|
|
||||||
collectEdges(props.layoutMap, el.id, templateStore.template.page.width, templateStore.template.page.height)
|
collectEdges(
|
||||||
|
props.layoutMap,
|
||||||
|
el.id,
|
||||||
|
templateStore.template.page.width,
|
||||||
|
templateStore.template.page.height,
|
||||||
|
)
|
||||||
|
|
||||||
window.addEventListener('pointermove', onAbsoluteDragMove)
|
window.addEventListener('pointermove', onAbsoluteDragMove)
|
||||||
window.addEventListener('pointerup', onAbsoluteDragEnd)
|
window.addEventListener('pointerup', onAbsoluteDragEnd)
|
||||||
@@ -466,7 +503,9 @@ function onAbsoluteDragEnd() {
|
|||||||
absoluteDragId.value = null
|
absoluteDragId.value = null
|
||||||
editorStore.setDragging(false)
|
editorStore.setDragging(false)
|
||||||
clearGuides()
|
clearGuides()
|
||||||
setTimeout(() => { didDrag.value = false }, 50)
|
setTimeout(() => {
|
||||||
|
didDrag.value = false
|
||||||
|
}, 50)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Resize ---
|
// --- Resize ---
|
||||||
@@ -493,18 +532,34 @@ function onResizeStart(e: PointerEvent, elId: string, handle: string) {
|
|||||||
const s = props.scale
|
const s = props.scale
|
||||||
|
|
||||||
// Barkod ve görsel elemanları için aspect ratio'yu kaydet
|
// Barkod ve görsel elemanları için aspect ratio'yu kaydet
|
||||||
const el = flatElements.value.find(e => e.id === elId)
|
const el = flatElements.value.find((e) => e.id === elId)
|
||||||
resizeAspectRatio.value = ((el?.type === 'barcode' || el?.type === 'image') && l.height_mm > 0) ? l.width_mm / l.height_mm : 0
|
resizeAspectRatio.value =
|
||||||
|
(el?.type === 'barcode' || el?.type === 'image') && l.height_mm > 0
|
||||||
|
? l.width_mm / l.height_mm
|
||||||
|
: 0
|
||||||
|
|
||||||
resizeStart.value = {
|
resizeStart.value = {
|
||||||
mouseX: e.clientX, mouseY: e.clientY,
|
mouseX: e.clientX,
|
||||||
x: l.x_mm * s, y: l.y_mm * s,
|
mouseY: e.clientY,
|
||||||
width: l.width_mm * s, height: l.height_mm * s,
|
x: l.x_mm * s,
|
||||||
|
y: l.y_mm * s,
|
||||||
|
width: l.width_mm * s,
|
||||||
|
height: l.height_mm * s,
|
||||||
|
}
|
||||||
|
resizeGhost.value = {
|
||||||
|
x: l.x_mm * s,
|
||||||
|
y: l.y_mm * s,
|
||||||
|
width: l.width_mm * s,
|
||||||
|
height: l.height_mm * s,
|
||||||
}
|
}
|
||||||
resizeGhost.value = { x: l.x_mm * s, y: l.y_mm * s, width: l.width_mm * s, height: l.height_mm * s }
|
|
||||||
resizeFinalMm.value = { width: l.width_mm, height: l.height_mm }
|
resizeFinalMm.value = { width: l.width_mm, height: l.height_mm }
|
||||||
|
|
||||||
collectEdges(props.layoutMap, elId, templateStore.template.page.width, templateStore.template.page.height)
|
collectEdges(
|
||||||
|
props.layoutMap,
|
||||||
|
elId,
|
||||||
|
templateStore.template.page.width,
|
||||||
|
templateStore.template.page.height,
|
||||||
|
)
|
||||||
|
|
||||||
window.addEventListener('pointermove', onResizeMove)
|
window.addEventListener('pointermove', onResizeMove)
|
||||||
window.addEventListener('pointerup', onResizeEnd)
|
window.addEventListener('pointerup', onResizeEnd)
|
||||||
@@ -519,13 +574,21 @@ function onResizeMove(e: PointerEvent) {
|
|||||||
const pxToMm = 1 / props.scale
|
const pxToMm = 1 / props.scale
|
||||||
const ar = resizeAspectRatio.value
|
const ar = resizeAspectRatio.value
|
||||||
|
|
||||||
let gx = resizeStart.value.x, gy = resizeStart.value.y
|
let gx = resizeStart.value.x,
|
||||||
let gw = resizeStart.value.width, gh = resizeStart.value.height
|
gy = resizeStart.value.y
|
||||||
|
let gw = resizeStart.value.width,
|
||||||
|
gh = resizeStart.value.height
|
||||||
|
|
||||||
if (handle.includes('e')) gw = Math.max(20, resizeStart.value.width + dx)
|
if (handle.includes('e')) gw = Math.max(20, resizeStart.value.width + dx)
|
||||||
if (handle.includes('w')) { gw = Math.max(20, resizeStart.value.width - dx); gx = resizeStart.value.x + dx }
|
if (handle.includes('w')) {
|
||||||
|
gw = Math.max(20, resizeStart.value.width - dx)
|
||||||
|
gx = resizeStart.value.x + dx
|
||||||
|
}
|
||||||
if (handle.includes('s')) gh = Math.max(10, resizeStart.value.height + dy)
|
if (handle.includes('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)
|
// Aspect ratio koruma (barkod)
|
||||||
if (ar > 0) {
|
if (ar > 0) {
|
||||||
@@ -538,7 +601,8 @@ function onResizeMove(e: PointerEvent) {
|
|||||||
const startHMm = resizeStart.value.height * pxToMm
|
const startHMm = resizeStart.value.height * pxToMm
|
||||||
const startXMm = resizeStart.value.x * pxToMm
|
const startXMm = resizeStart.value.x * pxToMm
|
||||||
const startYMm = resizeStart.value.y * pxToMm
|
const startYMm = resizeStart.value.y * pxToMm
|
||||||
let wMm = startWMm, hMm = startHMm
|
let wMm = startWMm,
|
||||||
|
hMm = startHMm
|
||||||
if (handle.includes('e')) {
|
if (handle.includes('e')) {
|
||||||
const rightEdge = calculateResizeSnap('right', startXMm + startWMm + dx * pxToMm)
|
const rightEdge = calculateResizeSnap('right', startXMm + startWMm + dx * pxToMm)
|
||||||
wMm = Math.max(5, rightEdge - startXMm)
|
wMm = Math.max(5, rightEdge - startXMm)
|
||||||
@@ -571,8 +635,10 @@ function onResizeEnd() {
|
|||||||
const handle = resizeHandle.value
|
const handle = resizeHandle.value
|
||||||
const ar = resizeAspectRatio.value
|
const ar = resizeAspectRatio.value
|
||||||
const sizeUpdate: { width?: SizeValue; height?: SizeValue } = {}
|
const sizeUpdate: { width?: SizeValue; height?: SizeValue } = {}
|
||||||
if (handle.includes('e') || handle.includes('w')) sizeUpdate.width = sz.fixed(resizeFinalMm.value.width)
|
if (handle.includes('e') || handle.includes('w'))
|
||||||
if (handle.includes('s') || handle.includes('n')) sizeUpdate.height = sz.fixed(resizeFinalMm.value.height)
|
sizeUpdate.width = sz.fixed(resizeFinalMm.value.width)
|
||||||
|
if (handle.includes('s') || handle.includes('n'))
|
||||||
|
sizeUpdate.height = sz.fixed(resizeFinalMm.value.height)
|
||||||
// Aspect ratio aktifken her zaman hem width hem height güncelle
|
// Aspect ratio aktifken her zaman hem width hem height güncelle
|
||||||
if (ar > 0) {
|
if (ar > 0) {
|
||||||
sizeUpdate.width = sz.fixed(resizeFinalMm.value.width)
|
sizeUpdate.width = sz.fixed(resizeFinalMm.value.width)
|
||||||
@@ -621,8 +687,8 @@ function onToolboxDrop(_e: DragEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Aktif sürükleme var mı (eleman veya toolbox)
|
// Aktif sürükleme var mı (eleman veya toolbox)
|
||||||
const isAnyDragActive = computed(() =>
|
const isAnyDragActive = computed(
|
||||||
(isDragging.value && dragElementId.value !== null) || !!editorStore.draggedNewElement
|
() => (isDragging.value && dragElementId.value !== null) || !!editorStore.draggedNewElement,
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -644,26 +710,57 @@ const isAnyDragActive = computed(() =>
|
|||||||
'element-handle--selected': editorStore.isSelected(el.id),
|
'element-handle--selected': editorStore.isSelected(el.id),
|
||||||
'element-handle--container': isContainer(el),
|
'element-handle--container': isContainer(el),
|
||||||
'element-handle--dragging': isDragging && dragElementId === el.id,
|
'element-handle--dragging': isDragging && dragElementId === el.id,
|
||||||
'element-handle--drop-target': isContainer(el) && dropTargetContainerId === el.id && isAnyDragActive,
|
'element-handle--drop-target':
|
||||||
|
isContainer(el) && dropTargetContainerId === el.id && isAnyDragActive,
|
||||||
}"
|
}"
|
||||||
:style="getElementStyle(el)"
|
:style="getElementStyle(el)"
|
||||||
@pointerdown="(e: PointerEvent) => { onElementClick(e, el.id); onDragStart(e, el) }"
|
@pointerdown="
|
||||||
|
(e: PointerEvent) => {
|
||||||
|
onElementClick(e, el.id)
|
||||||
|
onDragStart(e, el)
|
||||||
|
}
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<!-- Selection border -->
|
<!-- Selection border -->
|
||||||
<div v-if="editorStore.isSelected(el.id)" class="selection-border" />
|
<div v-if="editorStore.isSelected(el.id)" class="selection-border" />
|
||||||
|
|
||||||
<!-- Resize handles (sadece tek seçimde) -->
|
<!-- Resize handles (sadece tek seçimde) -->
|
||||||
<template v-if="editorStore.isSelected(el.id) && editorStore.selectedElementIds.size === 1 && !isResizing && el.type !== 'page_break'">
|
<template
|
||||||
|
v-if="
|
||||||
|
editorStore.isSelected(el.id) &&
|
||||||
|
editorStore.selectedElementIds.size === 1 &&
|
||||||
|
!isResizing &&
|
||||||
|
el.type !== 'page_break'
|
||||||
|
"
|
||||||
|
>
|
||||||
<template v-if="el.type === 'barcode' || el.type === 'image'">
|
<template v-if="el.type === 'barcode' || el.type === 'image'">
|
||||||
<!-- Barkod/Görsel: sadece yatay resize (aspect ratio korunur) -->
|
<!-- Barkod/Görsel: sadece yatay resize (aspect ratio korunur) -->
|
||||||
<div class="resize-handle resize-handle--e" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'e')" />
|
<div
|
||||||
<div class="resize-handle resize-handle--w" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'w')" />
|
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>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="resize-handle resize-handle--se" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'se')" />
|
<div
|
||||||
<div class="resize-handle resize-handle--sw" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'sw')" />
|
class="resize-handle resize-handle--se"
|
||||||
<div class="resize-handle resize-handle--ne" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'ne')" />
|
@pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'se')"
|
||||||
<div class="resize-handle resize-handle--nw" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'nw')" />
|
/>
|
||||||
|
<div
|
||||||
|
class="resize-handle resize-handle--sw"
|
||||||
|
@pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'sw')"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="resize-handle resize-handle--ne"
|
||||||
|
@pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'ne')"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="resize-handle resize-handle--nw"
|
||||||
|
@pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'nw')"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -777,12 +874,36 @@ const isAnyDragActive = computed(() =>
|
|||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resize-handle--se { right: -3px; bottom: -3px; cursor: se-resize; }
|
.resize-handle--se {
|
||||||
.resize-handle--sw { left: -3px; bottom: -3px; cursor: sw-resize; }
|
right: -3px;
|
||||||
.resize-handle--ne { right: -3px; top: -3px; cursor: ne-resize; }
|
bottom: -3px;
|
||||||
.resize-handle--nw { left: -3px; top: -3px; cursor: nw-resize; }
|
cursor: se-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; }
|
.resize-handle--sw {
|
||||||
|
left: -3px;
|
||||||
|
bottom: -3px;
|
||||||
|
cursor: sw-resize;
|
||||||
|
}
|
||||||
|
.resize-handle--ne {
|
||||||
|
right: -3px;
|
||||||
|
top: -3px;
|
||||||
|
cursor: ne-resize;
|
||||||
|
}
|
||||||
|
.resize-handle--nw {
|
||||||
|
left: -3px;
|
||||||
|
top: -3px;
|
||||||
|
cursor: nw-resize;
|
||||||
|
}
|
||||||
|
.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,14 +1,24 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { inject, watch, nextTick } from 'vue'
|
import { inject, watch, nextTick, type CSSProperties } from 'vue'
|
||||||
import type { ElementLayout, PageLayout, LayoutResult } from '../../core/layout-types'
|
import type { ElementLayout, PageLayout, LayoutResult } from '../../core/layout-types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
layout: LayoutResult | null
|
layout: LayoutResult | null
|
||||||
scale: number
|
scale: number
|
||||||
|
visiblePageIndices?: Set<number>
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// WASM barcode üretme fonksiyonu (EditorCanvas'tan provide edilir)
|
// WASM barcode üretme fonksiyonu (EditorCanvas'tan provide edilir)
|
||||||
const generateBarcode = inject<(format: string, value: string, width: number, height: number, includeText: boolean) => Promise<{ width: number; height: number; rgba: ArrayBuffer } | null>>('generateBarcode')
|
const generateBarcode =
|
||||||
|
inject<
|
||||||
|
(
|
||||||
|
format: string,
|
||||||
|
value: string,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
includeText: boolean,
|
||||||
|
) => Promise<{ width: number; height: number; rgba: ArrayBuffer } | null>
|
||||||
|
>('generateBarcode')
|
||||||
|
|
||||||
function pageContainerStyle(page: PageLayout): Record<string, string> {
|
function pageContainerStyle(page: PageLayout): Record<string, string> {
|
||||||
const s = props.scale
|
const s = props.scale
|
||||||
@@ -92,7 +102,12 @@ function lineStyle(el: ElementLayout): Record<string, string> {
|
|||||||
|
|
||||||
// --- Barcode rendering (WASM ile) ---
|
// --- Barcode rendering (WASM ile) ---
|
||||||
|
|
||||||
async function renderBarcodeToCanvas(canvas: HTMLCanvasElement, format: string, value: string, includeText: boolean = false) {
|
async function renderBarcodeToCanvas(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
format: string,
|
||||||
|
value: string,
|
||||||
|
includeText: boolean = false,
|
||||||
|
) {
|
||||||
if (!value || !generateBarcode) return
|
if (!value || !generateBarcode) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -106,7 +121,13 @@ async function renderBarcodeToCanvas(canvas: HTMLCanvasElement, format: string,
|
|||||||
const hPt = elHmm * MM_TO_PT
|
const hPt = elHmm * MM_TO_PT
|
||||||
const size = Math.max(1, Math.round(wPt * 4))
|
const size = Math.max(1, Math.round(wPt * 4))
|
||||||
const barcodeHeight = Math.max(1, Math.round(hPt * 4))
|
const barcodeHeight = Math.max(1, Math.round(hPt * 4))
|
||||||
const result = await generateBarcode(format, value, size, barcodeHeight, isQr ? false : includeText)
|
const result = await generateBarcode(
|
||||||
|
format,
|
||||||
|
value,
|
||||||
|
size,
|
||||||
|
barcodeHeight,
|
||||||
|
isQr ? false : includeText,
|
||||||
|
)
|
||||||
if (!result) return
|
if (!result) return
|
||||||
|
|
||||||
// Canvas boyutlarını WASM çıktısına ayarla (crisp rendering)
|
// Canvas boyutlarını WASM çıktısına ayarla (crisp rendering)
|
||||||
@@ -116,11 +137,7 @@ async function renderBarcodeToCanvas(canvas: HTMLCanvasElement, format: string,
|
|||||||
const ctx = canvas.getContext('2d')
|
const ctx = canvas.getContext('2d')
|
||||||
if (!ctx) return
|
if (!ctx) return
|
||||||
|
|
||||||
const imageData = new ImageData(
|
const imageData = new ImageData(new Uint8ClampedArray(result.rgba), result.width, result.height)
|
||||||
new Uint8ClampedArray(result.rgba),
|
|
||||||
result.width,
|
|
||||||
result.height,
|
|
||||||
)
|
|
||||||
ctx.putImageData(imageData, 0, 0)
|
ctx.putImageData(imageData, 0, 0)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`[dreport] WASM barcode render hatası (${format}):`, e)
|
console.warn(`[dreport] WASM barcode render hatası (${format}):`, e)
|
||||||
@@ -159,7 +176,7 @@ watch(
|
|||||||
await nextTick()
|
await nextTick()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
const canvases = document.querySelectorAll<HTMLCanvasElement>('canvas[data-barcode]')
|
const canvases = document.querySelectorAll<HTMLCanvasElement>('canvas[data-barcode]')
|
||||||
canvases.forEach(canvas => {
|
canvases.forEach((canvas) => {
|
||||||
const format = canvas.dataset.format
|
const format = canvas.dataset.format
|
||||||
const value = canvas.dataset.value
|
const value = canvas.dataset.value
|
||||||
const includeText = canvas.dataset.includeText === 'true'
|
const includeText = canvas.dataset.includeText === 'true'
|
||||||
@@ -168,7 +185,7 @@ watch(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{ deep: true }
|
{ deep: true },
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -180,14 +197,14 @@ watch(
|
|||||||
class="layout-page"
|
class="layout-page"
|
||||||
:style="pageContainerStyle(page)"
|
:style="pageContainerStyle(page)"
|
||||||
>
|
>
|
||||||
<template v-for="el in page.elements" :key="el.id">
|
<template v-if="!visiblePageIndices || visiblePageIndices.has(pageIdx)" v-for="el in page.elements" :key="el.id">
|
||||||
<!-- Page break: dashed horizontal line -->
|
<!-- Page break: dashed horizontal line -->
|
||||||
<div
|
<div
|
||||||
v-if="el.element_type === 'page_break'"
|
v-if="el.element_type === 'page_break'"
|
||||||
class="layout-el layout-el--page-break"
|
class="layout-el layout-el--page-break"
|
||||||
:style="elStyle(el)"
|
:style="elStyle(el)"
|
||||||
>
|
>
|
||||||
<div style="border-top: 1px dashed #9ca3af; width: 100%; height: 0;" />
|
<div style="border-top: 1px dashed #9ca3af; width: 100%; height: 0" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Container -->
|
<!-- Container -->
|
||||||
@@ -200,13 +217,27 @@ watch(
|
|||||||
}"
|
}"
|
||||||
:style="{ ...elStyle(el), ...containerStyle(el) }"
|
:style="{ ...elStyle(el), ...containerStyle(el) }"
|
||||||
>
|
>
|
||||||
<span v-if="el.id === 'header' || el.id.startsWith('header_p')" class="layout-el__section-label">Üst Bilgi</span>
|
<span
|
||||||
<span v-else-if="el.id === 'footer' || el.id.startsWith('footer_p')" class="layout-el__section-label">Alt Bilgi</span>
|
v-if="el.id === 'header' || el.id.startsWith('header_p')"
|
||||||
|
class="layout-el__section-label"
|
||||||
|
>Üst Bilgi</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-else-if="el.id === 'footer' || el.id.startsWith('footer_p')"
|
||||||
|
class="layout-el__section-label"
|
||||||
|
>Alt Bilgi</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Static text / Text / Page number -->
|
<!-- Static text / Text / Page number -->
|
||||||
<div
|
<div
|
||||||
v-else-if="el.element_type === 'static_text' || el.element_type === 'text' || el.element_type === 'page_number' || el.element_type === 'current_date' || el.element_type === 'calculated_text'"
|
v-else-if="
|
||||||
|
el.element_type === 'static_text' ||
|
||||||
|
el.element_type === 'text' ||
|
||||||
|
el.element_type === 'page_number' ||
|
||||||
|
el.element_type === 'current_date' ||
|
||||||
|
el.element_type === 'calculated_text'
|
||||||
|
"
|
||||||
class="layout-el layout-el--text"
|
class="layout-el layout-el--text"
|
||||||
:style="{ ...elStyle(el), ...textStyle(el) }"
|
:style="{ ...elStyle(el), ...textStyle(el) }"
|
||||||
>
|
>
|
||||||
@@ -234,7 +265,7 @@ watch(
|
|||||||
:style="{
|
:style="{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
objectFit: el.style.objectFit || 'fill',
|
objectFit: (el.style.objectFit || 'fill') as CSSProperties['objectFit'],
|
||||||
}"
|
}"
|
||||||
/>
|
/>
|
||||||
<div v-else class="layout-el__placeholder">Görsel</div>
|
<div v-else class="layout-el__placeholder">Görsel</div>
|
||||||
@@ -252,7 +283,10 @@ watch(
|
|||||||
data-barcode
|
data-barcode
|
||||||
:data-format="el.content.format"
|
:data-format="el.content.format"
|
||||||
:data-value="el.content.value"
|
:data-value="el.content.value"
|
||||||
:data-include-text="el.style.barcodeIncludeText ?? (el.content.format === 'ean13' || el.content.format === 'ean8')"
|
:data-include-text="
|
||||||
|
el.style.barcodeIncludeText ??
|
||||||
|
(el.content.format === 'ean13' || el.content.format === 'ean8')
|
||||||
|
"
|
||||||
:data-el-w="el.width_mm"
|
:data-el-w="el.width_mm"
|
||||||
:data-el-h="el.height_mm"
|
:data-el-h="el.height_mm"
|
||||||
:style="{ width: '100%', height: '100%', display: 'block' }"
|
:style="{ width: '100%', height: '100%', display: 'block' }"
|
||||||
@@ -268,16 +302,24 @@ watch(
|
|||||||
:style="elStyle(el)"
|
:style="elStyle(el)"
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 20 20" :style="{ width: '100%', height: '100%' }">
|
<svg viewBox="0 0 20 20" :style="{ width: '100%', height: '100%' }">
|
||||||
<rect x="1" y="1" width="18" height="18" fill="none"
|
<rect
|
||||||
|
x="1"
|
||||||
|
y="1"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
fill="none"
|
||||||
:stroke="el.style.borderColor ?? '#333'"
|
:stroke="el.style.borderColor ?? '#333'"
|
||||||
:stroke-width="el.style.borderWidth ? el.style.borderWidth * 3 : 1.5" />
|
:stroke-width="el.style.borderWidth ? el.style.borderWidth * 3 : 1.5"
|
||||||
<path v-if="el.content?.type === 'checkbox' && el.content.checked"
|
/>
|
||||||
|
<path
|
||||||
|
v-if="el.content?.type === 'checkbox' && el.content.checked"
|
||||||
d="M4 10 L8 15 L16 5"
|
d="M4 10 L8 15 L16 5"
|
||||||
fill="none"
|
fill="none"
|
||||||
:stroke="el.style.color ?? '#000'"
|
:stroke="el.style.color ?? '#000'"
|
||||||
stroke-width="2.5"
|
stroke-width="2.5"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round" />
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -297,7 +339,8 @@ watch(
|
|||||||
fontFamily: span.fontFamily || undefined,
|
fontFamily: span.fontFamily || undefined,
|
||||||
color: span.color || undefined,
|
color: span.color || undefined,
|
||||||
}"
|
}"
|
||||||
>{{ span.text }}</span>
|
>{{ span.text }}</span
|
||||||
|
>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -317,13 +360,24 @@ watch(
|
|||||||
<div
|
<div
|
||||||
v-if="el.content?.type === 'chart' && el.content.svg"
|
v-if="el.content?.type === 'chart' && el.content.svg"
|
||||||
v-html="el.content.svg"
|
v-html="el.content.svg"
|
||||||
style="width: 100%; height: 100%;"
|
style="width: 100%; height: 100%"
|
||||||
/>
|
/>
|
||||||
<div v-else class="layout-el__placeholder" :style="{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%', color: '#94a3b8', fontSize: '12px' }">
|
<div
|
||||||
|
v-else
|
||||||
|
class="layout-el__placeholder"
|
||||||
|
:style="{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
color: '#94a3b8',
|
||||||
|
fontSize: '12px',
|
||||||
|
}"
|
||||||
|
>
|
||||||
Grafik
|
Grafik
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
442
frontend/src/components/editor/MinimapOverlay.vue
Normal file
442
frontend/src/components/editor/MinimapOverlay.vue
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, onMounted, nextTick } from 'vue'
|
||||||
|
import type { LayoutResult } from '../../core/layout-types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
layout: LayoutResult | null
|
||||||
|
pageWidth: number // mm
|
||||||
|
pageHeight: number // mm
|
||||||
|
zoom: number
|
||||||
|
panX: number
|
||||||
|
panY: number
|
||||||
|
containerWidth: number // px — editor canvas container genişliği
|
||||||
|
containerHeight: number // px — editor canvas container yüksekliği
|
||||||
|
scale: number // mm → px (zoom dahil)
|
||||||
|
pageGap: number // px — sayfalar arası boşluk
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
navigate: [x: number, y: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const MAX_MINIMAP_WIDTH = 140
|
||||||
|
const MAX_EXPANDED_HEIGHT = 300
|
||||||
|
const PADDING = 6
|
||||||
|
|
||||||
|
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||||
|
const scrollRef = ref<HTMLElement | null>(null)
|
||||||
|
const isHovered = ref(false)
|
||||||
|
const isPointerDragging = ref(false)
|
||||||
|
|
||||||
|
// Offscreen canvas — sayfa içeriği cache'i (layout değiştiğinde yeniden çizilir)
|
||||||
|
let contentCanvas: OffscreenCanvas | null = null
|
||||||
|
let contentDirty = true
|
||||||
|
|
||||||
|
const pageCount = computed(() => Math.max(1, props.layout?.pages.length ?? 1))
|
||||||
|
|
||||||
|
// Minimap'te sayfalar arası sabit piksel boşluk
|
||||||
|
const MINIMAP_PAGE_GAP_PX = 4
|
||||||
|
|
||||||
|
// Editördeki toplam yükseklik (mm, viewport hesabı için)
|
||||||
|
const totalHeightMm = computed(() => {
|
||||||
|
const gapMm = props.pageGap / props.scale
|
||||||
|
return props.pageHeight * pageCount.value + gapMm * (pageCount.value - 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
const minimapScale = computed(() => (MAX_MINIMAP_WIDTH - PADDING * 2) / props.pageWidth)
|
||||||
|
|
||||||
|
const pageHeightPx = computed(() => props.pageHeight * minimapScale.value)
|
||||||
|
|
||||||
|
const canvasWidth = computed(() => props.pageWidth * minimapScale.value + PADDING * 2)
|
||||||
|
const canvasHeight = computed(() => {
|
||||||
|
const n = pageCount.value
|
||||||
|
return pageHeightPx.value * n + MINIMAP_PAGE_GAP_PX * (n - 1) + PADDING * 2
|
||||||
|
})
|
||||||
|
|
||||||
|
const singlePageMinimapH = computed(() => pageHeightPx.value + PADDING * 2)
|
||||||
|
|
||||||
|
// Editördeki gap'in mm karşılığı (activePageIndex hesabı için)
|
||||||
|
const editorGapMm = computed(() => props.pageGap / props.scale)
|
||||||
|
|
||||||
|
const activePageIndex = computed(() => {
|
||||||
|
const viewH = props.containerHeight - 60 - 40
|
||||||
|
const viewCenterMm = (-props.panY + viewH / 2) / props.scale
|
||||||
|
const stride = props.pageHeight + editorGapMm.value
|
||||||
|
const idx = Math.floor(viewCenterMm / stride)
|
||||||
|
return Math.max(0, Math.min(pageCount.value - 1, idx))
|
||||||
|
})
|
||||||
|
|
||||||
|
const visibleHeight = computed(() => {
|
||||||
|
if (isHovered.value || isPointerDragging.value) {
|
||||||
|
return Math.min(canvasHeight.value, MAX_EXPANDED_HEIGHT)
|
||||||
|
}
|
||||||
|
return Math.min(singlePageMinimapH.value, canvasHeight.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Sayfanın canvas üzerindeki Y pozisyonu (px) */
|
||||||
|
function pageTopOnCanvas(pageIdx: number): number {
|
||||||
|
return PADDING + pageIdx * (pageHeightPx.value + MINIMAP_PAGE_GAP_PX)
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetScrollTop = computed(() => {
|
||||||
|
if (isHovered.value || isPointerDragging.value) {
|
||||||
|
const vp = viewportRect.value
|
||||||
|
const vpCenter = vp.y + vp.h / 2
|
||||||
|
const half = visibleHeight.value / 2
|
||||||
|
const maxScroll = canvasHeight.value - visibleHeight.value
|
||||||
|
return Math.max(0, Math.min(maxScroll, vpCenter - half))
|
||||||
|
}
|
||||||
|
const top = pageTopOnCanvas(activePageIndex.value) - PADDING
|
||||||
|
const maxScroll = canvasHeight.value - visibleHeight.value
|
||||||
|
return Math.max(0, Math.min(maxScroll, top))
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Editör mm koordinatını minimap canvas px'e çevir (Y ekseni, sayfa gap'leri hesaba katarak) */
|
||||||
|
function mmYToCanvasPx(mmY: number): number {
|
||||||
|
const gapMm = editorGapMm.value
|
||||||
|
const stride = props.pageHeight + gapMm
|
||||||
|
const pageIdx = Math.min(pageCount.value - 1, Math.max(0, Math.floor(mmY / stride)))
|
||||||
|
const withinPageMm = mmY - pageIdx * stride
|
||||||
|
return pageTopOnCanvas(pageIdx) + withinPageMm * minimapScale.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewportRect = computed(() => {
|
||||||
|
const s = minimapScale.value
|
||||||
|
const pageWidthPx = props.pageWidth * props.scale
|
||||||
|
const pageLeftPx = (props.containerWidth - pageWidthPx) / 2 + props.panX
|
||||||
|
const pageTopPx = props.panY
|
||||||
|
|
||||||
|
const viewW = props.containerWidth
|
||||||
|
const viewH = props.containerHeight - 60 - 40
|
||||||
|
|
||||||
|
const visLeftMm = -pageLeftPx / props.scale
|
||||||
|
const visTopMm = -pageTopPx / props.scale
|
||||||
|
const visWidthMm = viewW / props.scale
|
||||||
|
const visHeightMm = viewH / props.scale
|
||||||
|
|
||||||
|
// Clamp to page boundaries
|
||||||
|
const clampedLeft = Math.max(0, visLeftMm)
|
||||||
|
const clampedTop = Math.max(0, visTopMm)
|
||||||
|
const clampedRight = Math.min(props.pageWidth, visLeftMm + visWidthMm)
|
||||||
|
const clampedBottom = Math.min(totalHeightMm.value, visTopMm + visHeightMm)
|
||||||
|
|
||||||
|
const y1 = mmYToCanvasPx(clampedTop)
|
||||||
|
const y2 = mmYToCanvasPx(clampedBottom)
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: PADDING + clampedLeft * s,
|
||||||
|
y: y1,
|
||||||
|
w: Math.max(0, (clampedRight - clampedLeft) * s),
|
||||||
|
h: Math.max(0, y2 - y1),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function elementColor(type: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'text':
|
||||||
|
case 'static_text':
|
||||||
|
case 'rich_text':
|
||||||
|
return '#93c5fd'
|
||||||
|
case 'container':
|
||||||
|
return '#c4b5fd'
|
||||||
|
case 'repeating_table':
|
||||||
|
return '#86efac'
|
||||||
|
case 'image':
|
||||||
|
return '#fdba74'
|
||||||
|
case 'line':
|
||||||
|
return '#9ca3af'
|
||||||
|
case 'barcode':
|
||||||
|
return '#fca5a5'
|
||||||
|
case 'chart':
|
||||||
|
return '#67e8f9'
|
||||||
|
default:
|
||||||
|
return '#d1d5db'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- İki aşamalı çizim: content (pahalı, cache'li) + viewport overlay (ucuz) ---
|
||||||
|
|
||||||
|
/** Sayfa içeriğini offscreen canvas'a çizer — sadece layout değiştiğinde çağrılır */
|
||||||
|
function drawContent() {
|
||||||
|
const dpr = window.devicePixelRatio || 1
|
||||||
|
const w = canvasWidth.value
|
||||||
|
const h = canvasHeight.value
|
||||||
|
|
||||||
|
if (!contentCanvas || contentCanvas.width !== Math.ceil(w * dpr) || contentCanvas.height !== Math.ceil(h * dpr)) {
|
||||||
|
contentCanvas = new OffscreenCanvas(Math.ceil(w * dpr), Math.ceil(h * dpr))
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = contentCanvas.getContext('2d')!
|
||||||
|
ctx.resetTransform()
|
||||||
|
ctx.scale(dpr, dpr)
|
||||||
|
ctx.clearRect(0, 0, w, h)
|
||||||
|
|
||||||
|
const s = minimapScale.value
|
||||||
|
const pages = props.layout?.pages ?? []
|
||||||
|
|
||||||
|
for (let i = 0; i < Math.max(1, pages.length); i++) {
|
||||||
|
const px = PADDING
|
||||||
|
const py = pageTopOnCanvas(i)
|
||||||
|
const pw = props.pageWidth * s
|
||||||
|
const ph = props.pageHeight * s
|
||||||
|
|
||||||
|
ctx.fillStyle = '#ffffff'
|
||||||
|
ctx.fillRect(px, py, pw, ph)
|
||||||
|
ctx.strokeStyle = '#d1d5db'
|
||||||
|
ctx.lineWidth = 0.5
|
||||||
|
ctx.strokeRect(px, py, pw, ph)
|
||||||
|
|
||||||
|
const page = pages[i]
|
||||||
|
if (page) {
|
||||||
|
for (const el of page.elements) {
|
||||||
|
if (el.element_type === 'container') continue
|
||||||
|
const ex = px + el.x_mm * s
|
||||||
|
const ey = py + el.y_mm * s
|
||||||
|
const ew = Math.max(1, el.width_mm * s)
|
||||||
|
const eh = Math.max(1, el.height_mm * s)
|
||||||
|
|
||||||
|
ctx.fillStyle = elementColor(el.element_type)
|
||||||
|
ctx.globalAlpha = 0.7
|
||||||
|
ctx.fillRect(ex, ey, ew, eh)
|
||||||
|
ctx.globalAlpha = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contentDirty = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ana canvas'a composite: cached content + viewport dikdörtgeni */
|
||||||
|
function compose() {
|
||||||
|
const canvas = canvasRef.value
|
||||||
|
if (!canvas) return
|
||||||
|
|
||||||
|
if (contentDirty || !contentCanvas) {
|
||||||
|
drawContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
const dpr = window.devicePixelRatio || 1
|
||||||
|
const w = canvasWidth.value
|
||||||
|
const h = canvasHeight.value
|
||||||
|
|
||||||
|
canvas.width = Math.ceil(w * dpr)
|
||||||
|
canvas.height = Math.ceil(h * dpr)
|
||||||
|
canvas.style.width = `${w}px`
|
||||||
|
canvas.style.height = `${h}px`
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')!
|
||||||
|
ctx.resetTransform()
|
||||||
|
|
||||||
|
// Offscreen content'i kopyala (1:1 pixel, zaten dpr ölçekli)
|
||||||
|
ctx.drawImage(contentCanvas!, 0, 0)
|
||||||
|
|
||||||
|
// Viewport dikdörtgenini çiz (dpr ölçekli)
|
||||||
|
ctx.scale(dpr, dpr)
|
||||||
|
const v = viewportRect.value
|
||||||
|
ctx.strokeStyle = '#2563eb'
|
||||||
|
ctx.lineWidth = 1.5
|
||||||
|
ctx.strokeRect(v.x, v.y, v.w, v.h)
|
||||||
|
ctx.fillStyle = 'rgba(37, 99, 235, 0.08)'
|
||||||
|
ctx.fillRect(v.x, v.y, v.w, v.h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rAF throttle — aynı frame'de birden fazla compose çağrısını engelle
|
||||||
|
let composeRAF: number | null = null
|
||||||
|
function scheduleCompose() {
|
||||||
|
if (composeRAF !== null) return
|
||||||
|
composeRAF = requestAnimationFrame(() => {
|
||||||
|
composeRAF = null
|
||||||
|
compose()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Scroll yönetimi ---
|
||||||
|
|
||||||
|
function smoothScrollTo(target: number) {
|
||||||
|
scrollRef.value?.scrollTo({ top: target, behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpScrollTo(target: number) {
|
||||||
|
if (scrollRef.value) scrollRef.value.scrollTop = target
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Pointer etkileşimi ---
|
||||||
|
|
||||||
|
/** Canvas px → editör mm (sayfa gap dönüşümü dahil) */
|
||||||
|
function canvasToMm(clientX: number, clientY: number): { mmX: number; mmY: number } {
|
||||||
|
const canvas = canvasRef.value!
|
||||||
|
const rect = canvas.getBoundingClientRect()
|
||||||
|
const mx = clientX - rect.left - PADDING
|
||||||
|
const my = clientY - rect.top - PADDING
|
||||||
|
const s = minimapScale.value
|
||||||
|
|
||||||
|
// Y: canvas px'ten hangi sayfadayız bul, editör mm'e çevir
|
||||||
|
const pageStridePx = pageHeightPx.value + MINIMAP_PAGE_GAP_PX
|
||||||
|
const pageIdx = Math.min(pageCount.value - 1, Math.max(0, Math.floor(my / pageStridePx)))
|
||||||
|
const withinPagePx = my - pageIdx * pageStridePx
|
||||||
|
const withinPageMm = withinPagePx / s
|
||||||
|
const editorStride = props.pageHeight + editorGapMm.value
|
||||||
|
const mmY = pageIdx * editorStride + withinPageMm
|
||||||
|
|
||||||
|
return { mmX: mx / s, mmY }
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateTo(clientX: number, clientY: number) {
|
||||||
|
const { mmX, mmY } = canvasToMm(clientX, clientY)
|
||||||
|
const viewW = props.containerWidth
|
||||||
|
const viewH = props.containerHeight - 60 - 40
|
||||||
|
const pageWidthPx = props.pageWidth * props.scale
|
||||||
|
|
||||||
|
const newPanX = -(mmX * props.scale) + viewW / 2 - (viewW - pageWidthPx) / 2
|
||||||
|
const newPanY = -(mmY * props.scale) + viewH / 2
|
||||||
|
|
||||||
|
emit('navigate', newPanX, newPanY)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
isPointerDragging.value = true
|
||||||
|
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||||
|
navigateTo(e.clientX, e.clientY)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(e: PointerEvent) {
|
||||||
|
if (!isPointerDragging.value) return
|
||||||
|
navigateTo(e.clientX, e.clientY)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp(e: PointerEvent) {
|
||||||
|
if (isPointerDragging.value) {
|
||||||
|
isPointerDragging.value = false
|
||||||
|
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseEnter() {
|
||||||
|
isHovered.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseLeave() {
|
||||||
|
if (!isPointerDragging.value) {
|
||||||
|
isHovered.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(isPointerDragging, (dragging) => {
|
||||||
|
if (!dragging) {
|
||||||
|
nextTick(() => {
|
||||||
|
const el = scrollRef.value
|
||||||
|
if (el && !el.matches(':hover')) {
|
||||||
|
isHovered.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Layout değiştiğinde content'i dirty işaretle + tam redraw
|
||||||
|
watch(() => props.layout, () => {
|
||||||
|
contentDirty = true
|
||||||
|
scheduleCompose()
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// Scale değiştiğinde (zoom) content'i de yeniden çizmek gerekir (gapMm değişir)
|
||||||
|
watch(() => props.scale, () => {
|
||||||
|
contentDirty = true
|
||||||
|
scheduleCompose()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Pan değiştiğinde sadece viewport overlay'i yeniden çiz (ucuz)
|
||||||
|
// Minimap drag sırasında scroll yapma — kullanıcı zaten sürükleyerek kontrol ediyor
|
||||||
|
watch([() => props.panX, () => props.panY], () => {
|
||||||
|
scheduleCompose()
|
||||||
|
if (!isPointerDragging.value) {
|
||||||
|
smoothScrollTo(targetScrollTop.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Zoom değiştiğinde scroll da güncelle
|
||||||
|
watch(() => props.zoom, () => {
|
||||||
|
smoothScrollTo(targetScrollTop.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Container boyutu değiştiğinde
|
||||||
|
watch([() => props.containerWidth, () => props.containerHeight], () => {
|
||||||
|
scheduleCompose()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Hover/collapse durumu değiştiğinde
|
||||||
|
watch([isHovered, isPointerDragging], () => {
|
||||||
|
nextTick(() => {
|
||||||
|
if (isHovered.value || isPointerDragging.value) {
|
||||||
|
smoothScrollTo(targetScrollTop.value)
|
||||||
|
} else {
|
||||||
|
jumpScrollTo(targetScrollTop.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
drawContent()
|
||||||
|
compose()
|
||||||
|
jumpScrollTo(targetScrollTop.value)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="minimap"
|
||||||
|
:class="{
|
||||||
|
'minimap--expanded': isHovered || isPointerDragging,
|
||||||
|
'minimap--dragging': isPointerDragging,
|
||||||
|
}"
|
||||||
|
:style="{ width: `${canvasWidth}px`, height: `${visibleHeight}px` }"
|
||||||
|
@mouseenter="onMouseEnter"
|
||||||
|
@mouseleave="onMouseLeave"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref="scrollRef"
|
||||||
|
class="minimap__scroll"
|
||||||
|
:style="{ height: `${visibleHeight}px` }"
|
||||||
|
>
|
||||||
|
<canvas
|
||||||
|
ref="canvasRef"
|
||||||
|
@pointerdown="onPointerDown"
|
||||||
|
@pointermove="onPointerMove"
|
||||||
|
@pointerup="onPointerUp"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.minimap {
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
|
||||||
|
cursor: crosshair;
|
||||||
|
user-select: none;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: height 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.minimap--expanded {
|
||||||
|
border-color: #93c5fd;
|
||||||
|
box-shadow: 0 2px 12px rgba(37, 99, 235, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.minimap--dragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.minimap__scroll {
|
||||||
|
overflow: hidden;
|
||||||
|
scroll-behavior: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.minimap__scroll canvas {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -12,6 +12,12 @@ const props = defineProps<{
|
|||||||
panX: number
|
panX: number
|
||||||
/** Pan offset Y (px) */
|
/** Pan offset Y (px) */
|
||||||
panY: number
|
panY: number
|
||||||
|
/** editor-canvas content width (px) — ResizeObserver'dan */
|
||||||
|
containerWidth: number
|
||||||
|
/** Sayfa sayısı */
|
||||||
|
pageCount: number
|
||||||
|
/** Sayfalar arası boşluk (px) */
|
||||||
|
pageGap?: number
|
||||||
/** Cetvel kalınlığı px */
|
/** Cetvel kalınlığı px */
|
||||||
rulerSize?: number
|
rulerSize?: number
|
||||||
}>()
|
}>()
|
||||||
@@ -21,10 +27,7 @@ const RULER_SIZE = computed(() => props.rulerSize ?? 20)
|
|||||||
const hCanvas = ref<HTMLCanvasElement | null>(null)
|
const hCanvas = ref<HTMLCanvasElement | null>(null)
|
||||||
const vCanvas = ref<HTMLCanvasElement | null>(null)
|
const vCanvas = ref<HTMLCanvasElement | null>(null)
|
||||||
|
|
||||||
function drawRuler(
|
function drawRuler(canvas: HTMLCanvasElement | null, direction: 'horizontal' | 'vertical') {
|
||||||
canvas: HTMLCanvasElement | null,
|
|
||||||
direction: 'horizontal' | 'vertical',
|
|
||||||
) {
|
|
||||||
if (!canvas) return
|
if (!canvas) return
|
||||||
const ctx = canvas.getContext('2d')
|
const ctx = canvas.getContext('2d')
|
||||||
if (!ctx) return
|
if (!ctx) return
|
||||||
@@ -72,18 +75,8 @@ function drawTicks(
|
|||||||
size: number,
|
size: number,
|
||||||
) {
|
) {
|
||||||
const s = props.scale
|
const s = props.scale
|
||||||
const pageMm = direction === 'horizontal' ? props.pageWidth : props.pageHeight
|
const rulerSz = RULER_SIZE.value
|
||||||
const pan = direction === 'horizontal' ? props.panX : props.panY
|
const gap = props.pageGap ?? 24
|
||||||
|
|
||||||
// Sayfa başlangıcı: ortaya hizalı + pan
|
|
||||||
// EditorCanvas sayfayı ortalar, ruler da buna uymalı
|
|
||||||
// Yatay: canvas ortası - sayfa genişliği/2
|
|
||||||
// Sayfanın canvas üzerindeki orijin px'i
|
|
||||||
const canvasCenter = direction === 'horizontal'
|
|
||||||
? (length / 2) // flex centering approximation
|
|
||||||
: 40 // EditorCanvas padding-top: 40px
|
|
||||||
|
|
||||||
const pageStartPx = canvasCenter - (pageMm * s) / 2 + pan
|
|
||||||
|
|
||||||
// Tick aralığı belirleme (zoom'a göre)
|
// Tick aralığı belirleme (zoom'a göre)
|
||||||
const mmPerPx = 1 / s
|
const mmPerPx = 1 / s
|
||||||
@@ -100,11 +93,41 @@ function drawTicks(
|
|||||||
ctx.font = '9px system-ui, sans-serif'
|
ctx.font = '9px system-ui, sans-serif'
|
||||||
ctx.textBaseline = 'top'
|
ctx.textBaseline = 'top'
|
||||||
|
|
||||||
// Sayfanın mm aralığını çiz
|
if (direction === 'horizontal') {
|
||||||
const startMm = 0
|
// Yatay cetvel: tek sayfa genişliği, flex-center ile hizalı
|
||||||
const endMm = pageMm
|
// editor-canvas padding: left=60, right=40; ruler canvas left=rulerSize
|
||||||
|
// pageLeft_in_wrapper = 60 + (containerWidth - pageWidthPx) / 2
|
||||||
|
// pageLeft_in_ruler = pageLeft_in_wrapper - rulerSz + panX
|
||||||
|
const pageWidthPx = props.pageWidth * s
|
||||||
|
const pageStartPx = (60 - rulerSz) + (props.containerWidth - pageWidthPx) / 2 + props.panX
|
||||||
|
|
||||||
for (let mm = startMm; mm <= endMm; mm += tickMm) {
|
drawPageTicks(ctx, direction, length, size, pageStartPx, props.pageWidth, tickMm)
|
||||||
|
} else {
|
||||||
|
// Dikey cetvel: her sayfa için ayrı tick çiz
|
||||||
|
// editor-canvas padding-top=60; ruler canvas top=rulerSize
|
||||||
|
// pageTop for page i = (60 - rulerSz) + panY + i * (pageHeightPx + gap)
|
||||||
|
const pageHeightPx = props.pageHeight * s
|
||||||
|
const pageCount = Math.max(1, props.pageCount)
|
||||||
|
|
||||||
|
for (let i = 0; i < pageCount; i++) {
|
||||||
|
const pageStartPx = (60 - rulerSz) + props.panY + i * (pageHeightPx + gap)
|
||||||
|
drawPageTicks(ctx, direction, length, size, pageStartPx, props.pageHeight, tickMm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPageTicks(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
direction: 'horizontal' | 'vertical',
|
||||||
|
length: number,
|
||||||
|
size: number,
|
||||||
|
pageStartPx: number,
|
||||||
|
pageMm: number,
|
||||||
|
tickMm: number,
|
||||||
|
) {
|
||||||
|
const s = props.scale
|
||||||
|
|
||||||
|
for (let mm = 0; mm <= pageMm; mm += tickMm) {
|
||||||
const px = pageStartPx + mm * s
|
const px = pageStartPx + mm * s
|
||||||
|
|
||||||
if (px < -10 || px > length + 10) continue
|
if (px < -10 || px > length + 10) continue
|
||||||
@@ -143,7 +166,7 @@ function drawTicks(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sayfa kenar çizgileri (margin göstergesi)
|
// Sayfa kenar çizgileri
|
||||||
ctx.strokeStyle = 'rgba(59, 130, 246, 0.3)'
|
ctx.strokeStyle = 'rgba(59, 130, 246, 0.3)'
|
||||||
ctx.lineWidth = 1
|
ctx.lineWidth = 1
|
||||||
const startPx = pageStartPx
|
const startPx = pageStartPx
|
||||||
@@ -161,6 +184,11 @@ function drawTicks(
|
|||||||
ctx.lineTo(size, endPx)
|
ctx.lineTo(size, endPx)
|
||||||
}
|
}
|
||||||
ctx.stroke()
|
ctx.stroke()
|
||||||
|
|
||||||
|
// Renkleri geri al (sonraki sayfa için)
|
||||||
|
ctx.fillStyle = '#94a3b8'
|
||||||
|
ctx.strokeStyle = '#94a3b8'
|
||||||
|
ctx.lineWidth = 0.5
|
||||||
}
|
}
|
||||||
|
|
||||||
function redraw() {
|
function redraw() {
|
||||||
@@ -168,7 +196,7 @@ function redraw() {
|
|||||||
drawRuler(vCanvas.value, 'vertical')
|
drawRuler(vCanvas.value, 'vertical')
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => [props.scale, props.panX, props.panY, props.pageWidth, props.pageHeight], redraw)
|
watch(() => [props.scale, props.panX, props.panY, props.pageWidth, props.pageHeight, props.containerWidth, props.pageCount], redraw)
|
||||||
|
|
||||||
let resizeObserver: ResizeObserver | null = null
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
@@ -188,16 +216,8 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="ruler-corner" :style="{ width: `${RULER_SIZE}px`, height: `${RULER_SIZE}px` }" />
|
<div class="ruler-corner" :style="{ width: `${RULER_SIZE}px`, height: `${RULER_SIZE}px` }" />
|
||||||
<canvas
|
<canvas ref="hCanvas" class="ruler-h" :style="{ height: `${RULER_SIZE}px` }" />
|
||||||
ref="hCanvas"
|
<canvas ref="vCanvas" class="ruler-v" :style="{ width: `${RULER_SIZE}px` }" />
|
||||||
class="ruler-h"
|
|
||||||
:style="{ height: `${RULER_SIZE}px` }"
|
|
||||||
/>
|
|
||||||
<canvas
|
|
||||||
ref="vCanvas"
|
|
||||||
class="ruler-v"
|
|
||||||
:style="{ width: `${RULER_SIZE}px` }"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -215,7 +235,7 @@ onBeforeUnmount(() => {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 20px;
|
left: 20px;
|
||||||
right: 0;
|
width: calc(100% - 20px);
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
@@ -224,7 +244,7 @@ onBeforeUnmount(() => {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 20px;
|
top: 20px;
|
||||||
left: 0;
|
left: 0;
|
||||||
bottom: 0;
|
height: calc(100% - 20px);
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|||||||
53
frontend/src/components/editor/toolbars/ChartToolbar.vue
Normal file
53
frontend/src/components/editor/toolbars/ChartToolbar.vue
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ChartElement, ChartType } from '../../../core/types'
|
||||||
|
|
||||||
|
defineProps<{ chart: ChartElement }>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
update: [updates: Record<string, unknown>]
|
||||||
|
updateStyle: [key: string, value: unknown]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Chart type -->
|
||||||
|
<div class="et__group">
|
||||||
|
<button class="et__btn" :class="{ 'et__btn--active': chart.chartType === 'bar' }" data-tip="Cubuk" @click="emit('update', { chartType: 'bar' as ChartType })">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="6" width="3" height="6" rx="0.5" fill="currentColor" /><rect x="5.5" y="3" width="3" height="9" rx="0.5" fill="currentColor" /><rect x="9" y="5" width="3" height="7" rx="0.5" fill="currentColor" /></svg>
|
||||||
|
</button>
|
||||||
|
<button class="et__btn" :class="{ 'et__btn--active': chart.chartType === 'line' }" data-tip="Cizgi" @click="emit('update', { chartType: 'line' as ChartType })">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><polyline points="2,10 5,5 8,7 12,3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none" /><circle cx="2" cy="10" r="1.2" fill="currentColor" /><circle cx="5" cy="5" r="1.2" fill="currentColor" /><circle cx="8" cy="7" r="1.2" fill="currentColor" /><circle cx="12" cy="3" r="1.2" fill="currentColor" /></svg>
|
||||||
|
</button>
|
||||||
|
<button class="et__btn" :class="{ 'et__btn--active': chart.chartType === 'pie' }" data-tip="Pasta" @click="emit('update', { chartType: 'pie' as ChartType })">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M7 2a5 5 0 1 1-3.54 1.46" stroke="currentColor" stroke-width="1.3" fill="none" /><path d="M7 7V2A5 5 0 0 0 3.46 3.46Z" fill="currentColor" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="et__sep" />
|
||||||
|
|
||||||
|
<!-- Show labels -->
|
||||||
|
<div class="et__group">
|
||||||
|
<button class="et__btn" :class="{ 'et__btn--active': chart.labels?.show !== false }" data-tip="Etiketler" @click="emit('update', { labels: { ...chart.labels, show: chart.labels?.show === false ? true : false } })">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="8" width="3" height="4" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="5.5" y="5" width="3" height="7" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="9" y="6" width="3" height="6" rx="0.5" fill="currentColor" opacity="0.4" /><text x="3.5" y="7" font-size="4" fill="currentColor" text-anchor="middle" font-weight="bold">3</text><text x="7" y="4" font-size="4" fill="currentColor" text-anchor="middle" font-weight="bold">7</text><text x="10.5" y="5" font-size="4" fill="currentColor" text-anchor="middle" font-weight="bold">5</text></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="et__sep" />
|
||||||
|
|
||||||
|
<!-- Show grid -->
|
||||||
|
<div class="et__group">
|
||||||
|
<button class="et__btn" :class="{ 'et__btn--active': chart.axis?.showGrid !== false }" data-tip="Izgara" @click="emit('update', { axis: { ...chart.axis, showGrid: chart.axis?.showGrid === false ? true : false } })">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><line x1="2" y1="3" x2="12" y2="3" stroke="currentColor" stroke-width="0.8" stroke-dasharray="2 1.5" /><line x1="2" y1="7" x2="12" y2="7" stroke="currentColor" stroke-width="0.8" stroke-dasharray="2 1.5" /><line x1="2" y1="11" x2="12" y2="11" stroke="currentColor" stroke-width="0.8" stroke-dasharray="2 1.5" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="et__sep" />
|
||||||
|
|
||||||
|
<!-- Background color -->
|
||||||
|
<div class="et__group">
|
||||||
|
<label class="et__color-wrap" data-tip="Arka Plan">
|
||||||
|
<input type="color" class="et__color" :value="chart.style.backgroundColor ?? '#ffffff'" @input="(e) => emit('updateStyle', 'backgroundColor', (e.target as HTMLInputElement).value)" />
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="2" width="10" height="10" rx="1.5" :fill="chart.style.backgroundColor ?? '#ffffff'" stroke="#94a3b8" stroke-width="0.8" /></svg>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
97
frontend/src/components/editor/toolbars/ContainerToolbar.vue
Normal file
97
frontend/src/components/editor/toolbars/ContainerToolbar.vue
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ContainerElement } from '../../../core/types'
|
||||||
|
|
||||||
|
const props = defineProps<{ container: ContainerElement }>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
update: [updates: Record<string, unknown>]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Direction -->
|
||||||
|
<div class="et__group">
|
||||||
|
<button class="et__btn" :class="{ 'et__btn--active': container.direction === 'column' }" data-tip="Dikey" @click="emit('update', { direction: 'column' })">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="1" width="10" height="3" rx="0.5" fill="currentColor" /><rect x="2" y="5.5" width="10" height="3" rx="0.5" fill="currentColor" /><rect x="2" y="10" width="10" height="3" rx="0.5" fill="currentColor" /></svg>
|
||||||
|
</button>
|
||||||
|
<button class="et__btn" :class="{ 'et__btn--active': container.direction === 'row' }" data-tip="Yatay" @click="emit('update', { direction: 'row' })">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="2" width="3" height="10" rx="0.5" fill="currentColor" /><rect x="5.5" y="2" width="3" height="10" rx="0.5" fill="currentColor" /><rect x="10" y="2" width="3" height="10" rx="0.5" fill="currentColor" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="et__sep" />
|
||||||
|
|
||||||
|
<!-- Align -->
|
||||||
|
<div class="et__group">
|
||||||
|
<template v-if="container.direction === 'column'">
|
||||||
|
<button class="et__btn" :class="{ 'et__btn--active': container.align === 'start' }" data-tip="Sol" @click="emit('update', { align: 'start' })">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="2" width="1.5" height="10" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3.5" y="3" width="8" height="2.5" rx="0.5" fill="currentColor" /><rect x="3.5" y="8" width="5" height="2.5" rx="0.5" fill="currentColor" /></svg>
|
||||||
|
</button>
|
||||||
|
<button class="et__btn" :class="{ 'et__btn--active': container.align === 'center' }" data-tip="Orta" @click="emit('update', { align: 'center' })">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="6.25" y="1" width="1.5" height="12" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3" y="3" width="8" height="2.5" rx="0.5" fill="currentColor" /><rect x="4.5" y="8" width="5" height="2.5" rx="0.5" fill="currentColor" /></svg>
|
||||||
|
</button>
|
||||||
|
<button class="et__btn" :class="{ 'et__btn--active': container.align === 'end' }" data-tip="Sag" @click="emit('update', { align: 'end' })">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="11.5" y="2" width="1.5" height="10" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="2.5" y="3" width="8" height="2.5" rx="0.5" fill="currentColor" /><rect x="5.5" y="8" width="5" height="2.5" rx="0.5" fill="currentColor" /></svg>
|
||||||
|
</button>
|
||||||
|
<button class="et__btn" :class="{ 'et__btn--active': container.align === 'stretch' }" data-tip="Esnet" @click="emit('update', { align: 'stretch' })">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="2" width="1.5" height="10" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="11.5" y="2" width="1.5" height="10" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3.5" y="3" width="7" height="2.5" rx="0.5" fill="currentColor" /><rect x="3.5" y="8" width="7" height="2.5" rx="0.5" fill="currentColor" /></svg>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<button class="et__btn" :class="{ 'et__btn--active': container.align === 'start' }" data-tip="Ust" @click="emit('update', { align: 'start' })">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="1" width="10" height="1.5" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3" y="3.5" width="2.5" height="8" rx="0.5" fill="currentColor" /><rect x="8" y="3.5" width="2.5" height="5" rx="0.5" fill="currentColor" /></svg>
|
||||||
|
</button>
|
||||||
|
<button class="et__btn" :class="{ 'et__btn--active': container.align === 'center' }" data-tip="Orta" @click="emit('update', { align: 'center' })">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="6.25" width="12" height="1.5" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3" y="2" width="2.5" height="10" rx="0.5" fill="currentColor" /><rect x="8" y="3.5" width="2.5" height="7" rx="0.5" fill="currentColor" /></svg>
|
||||||
|
</button>
|
||||||
|
<button class="et__btn" :class="{ 'et__btn--active': container.align === 'end' }" data-tip="Alt" @click="emit('update', { align: 'end' })">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="11.5" width="10" height="1.5" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3" y="2.5" width="2.5" height="8" rx="0.5" fill="currentColor" /><rect x="8" y="5.5" width="2.5" height="5" rx="0.5" fill="currentColor" /></svg>
|
||||||
|
</button>
|
||||||
|
<button class="et__btn" :class="{ 'et__btn--active': container.align === 'stretch' }" data-tip="Esnet" @click="emit('update', { align: 'stretch' })">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="1" width="10" height="1.5" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="2" y="11.5" width="10" height="1.5" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3" y="3.5" width="2.5" height="7" rx="0.5" fill="currentColor" /><rect x="8" y="3.5" width="2.5" height="7" rx="0.5" fill="currentColor" /></svg>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="et__sep" />
|
||||||
|
|
||||||
|
<!-- Justify -->
|
||||||
|
<div class="et__group">
|
||||||
|
<template v-if="container.direction === 'column'">
|
||||||
|
<button class="et__btn" :class="{ 'et__btn--active': container.justify === 'start' }" data-tip="Ust" @click="emit('update', { justify: 'start' })">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="1" width="10" height="1.5" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3" y="3.5" width="8" height="2" rx="0.5" fill="currentColor" /><rect x="3" y="6.5" width="8" height="2" rx="0.5" fill="currentColor" /></svg>
|
||||||
|
</button>
|
||||||
|
<button class="et__btn" :class="{ 'et__btn--active': container.justify === 'center' }" data-tip="Orta" @click="emit('update', { justify: 'center' })">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="6.25" width="12" height="1.5" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3" y="3" width="8" height="2" rx="0.5" fill="currentColor" /><rect x="3" y="9" width="8" height="2" rx="0.5" fill="currentColor" /></svg>
|
||||||
|
</button>
|
||||||
|
<button class="et__btn" :class="{ 'et__btn--active': container.justify === 'end' }" data-tip="Alt" @click="emit('update', { justify: 'end' })">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="11.5" width="10" height="1.5" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3" y="5.5" width="8" height="2" rx="0.5" fill="currentColor" /><rect x="3" y="8.5" width="8" height="2" rx="0.5" fill="currentColor" /></svg>
|
||||||
|
</button>
|
||||||
|
<button class="et__btn" :class="{ 'et__btn--active': container.justify === 'space-between' }" data-tip="Esit Aralik" @click="emit('update', { justify: 'space-between' })">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="1" width="10" height="1.5" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="2" y="11.5" width="10" height="1.5" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3" y="3.5" width="8" height="2" rx="0.5" fill="currentColor" /><rect x="3" y="8.5" width="8" height="2" rx="0.5" fill="currentColor" /></svg>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<button class="et__btn" :class="{ 'et__btn--active': container.justify === 'start' }" data-tip="Sol" @click="emit('update', { justify: 'start' })">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="2" width="1.5" height="10" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3.5" y="3" width="3" height="8" rx="0.5" fill="currentColor" /><rect x="7.5" y="3" width="3" height="8" rx="0.5" fill="currentColor" /></svg>
|
||||||
|
</button>
|
||||||
|
<button class="et__btn" :class="{ 'et__btn--active': container.justify === 'center' }" data-tip="Orta" @click="emit('update', { justify: 'center' })">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="6.25" y="1" width="1.5" height="12" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="2" y="3" width="3" height="8" rx="0.5" fill="currentColor" /><rect x="9" y="3" width="3" height="8" rx="0.5" fill="currentColor" /></svg>
|
||||||
|
</button>
|
||||||
|
<button class="et__btn" :class="{ 'et__btn--active': container.justify === 'end' }" data-tip="Sag" @click="emit('update', { justify: 'end' })">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="11.5" y="2" width="1.5" height="10" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3.5" y="3" width="3" height="8" rx="0.5" fill="currentColor" /><rect x="7.5" y="3" width="3" height="8" rx="0.5" fill="currentColor" /></svg>
|
||||||
|
</button>
|
||||||
|
<button class="et__btn" :class="{ 'et__btn--active': container.justify === 'space-between' }" data-tip="Esit Aralik" @click="emit('update', { justify: 'space-between' })">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="2" width="1.5" height="10" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="11.5" y="2" width="1.5" height="10" rx="0.5" fill="currentColor" opacity="0.4" /><rect x="3.5" y="3" width="3" height="8" rx="0.5" fill="currentColor" /><rect x="7.5" y="3" width="3" height="8" rx="0.5" fill="currentColor" /></svg>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="et__sep" />
|
||||||
|
|
||||||
|
<!-- Gap -->
|
||||||
|
<div class="et__group et__group--gap" data-tip="Bosluk (mm)">
|
||||||
|
<svg class="et__gap-icon" width="12" height="12" viewBox="0 0 12 12" fill="none"><rect x="1" y="1" width="3.5" height="10" rx="0.5" stroke="currentColor" stroke-width="1" fill="none" /><rect x="7.5" y="1" width="3.5" height="10" rx="0.5" stroke="currentColor" stroke-width="1" fill="none" /><line x1="6" y1="3" x2="6" y2="9" stroke="currentColor" stroke-width="1" stroke-dasharray="1.5 1" /></svg>
|
||||||
|
<input type="number" class="et__num" step="1" min="0" :value="container.gap" @input="(e) => emit('update', { gap: parseFloat((e.target as HTMLInputElement).value) || 0 })" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
51
frontend/src/components/editor/toolbars/TableToolbar.vue
Normal file
51
frontend/src/components/editor/toolbars/TableToolbar.vue
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { TableStyle } from '../../../core/types'
|
||||||
|
|
||||||
|
defineProps<{ tableStyle: TableStyle }>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
updateStyle: [key: string, value: unknown]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Font size -->
|
||||||
|
<div class="et__group et__group--gap" data-tip="Yazi Boyutu">
|
||||||
|
<svg class="et__gap-icon" width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M2 10L6 2l4 8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" fill="none" /><line x1="3.5" y1="7" x2="8.5" y2="7" stroke="currentColor" stroke-width="1" stroke-linecap="round" /></svg>
|
||||||
|
<input type="number" class="et__num" step="1" min="6" :value="tableStyle.fontSize ?? 10" @input="(e) => emit('updateStyle', 'fontSize', parseFloat((e.target as HTMLInputElement).value) || 10)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="et__sep" />
|
||||||
|
|
||||||
|
<!-- Header bg color -->
|
||||||
|
<div class="et__group">
|
||||||
|
<label class="et__color-wrap" data-tip="Header Rengi">
|
||||||
|
<input type="color" class="et__color" :value="tableStyle.headerBg ?? '#f0f0f0'" @input="(e) => emit('updateStyle', 'headerBg', (e.target as HTMLInputElement).value)" />
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="2" width="10" height="4" rx="1" :fill="tableStyle.headerBg ?? '#f0f0f0'" stroke="#94a3b8" stroke-width="0.5" /><rect x="2" y="7" width="10" height="2" rx="0.5" fill="none" stroke="#94a3b8" stroke-width="0.5" /><rect x="2" y="10" width="10" height="2" rx="0.5" fill="none" stroke="#94a3b8" stroke-width="0.5" /></svg>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Zebra color -->
|
||||||
|
<div class="et__group">
|
||||||
|
<label class="et__color-wrap" data-tip="Zebra Rengi">
|
||||||
|
<input type="color" class="et__color" :value="tableStyle.zebraOdd ?? '#fafafa'" @input="(e) => emit('updateStyle', 'zebraOdd', (e.target as HTMLInputElement).value)" />
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="2" width="10" height="2.5" rx="0.5" fill="none" stroke="#94a3b8" stroke-width="0.5" /><rect x="2" y="5.5" width="10" height="2.5" rx="0.5" :fill="tableStyle.zebraOdd ?? '#fafafa'" stroke="#94a3b8" stroke-width="0.5" /><rect x="2" y="9" width="10" height="2.5" rx="0.5" fill="none" stroke="#94a3b8" stroke-width="0.5" /></svg>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="et__sep" />
|
||||||
|
|
||||||
|
<!-- Border color -->
|
||||||
|
<div class="et__group">
|
||||||
|
<label class="et__color-wrap" data-tip="Kenarlik Rengi">
|
||||||
|
<input type="color" class="et__color" :value="tableStyle.borderColor ?? '#cccccc'" @input="(e) => emit('updateStyle', 'borderColor', (e.target as HTMLInputElement).value)" />
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="2" width="10" height="10" rx="1" fill="none" :stroke="tableStyle.borderColor ?? '#cccccc'" stroke-width="1.5" /><line x1="2" y1="6" x2="12" y2="6" :stroke="tableStyle.borderColor ?? '#cccccc'" stroke-width="0.8" /><line x1="7" y1="2" x2="7" y2="12" :stroke="tableStyle.borderColor ?? '#cccccc'" stroke-width="0.8" /></svg>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Border width -->
|
||||||
|
<div class="et__group et__group--gap" data-tip="Kenarlik (mm)">
|
||||||
|
<svg class="et__gap-icon" width="12" height="12" viewBox="0 0 12 12" fill="none"><rect x="1" y="1" width="10" height="10" rx="1" fill="none" stroke="currentColor" stroke-width="1.5" /></svg>
|
||||||
|
<input type="number" class="et__num" step="0.1" min="0" :value="tableStyle.borderWidth ?? 0.5" @input="(e) => emit('updateStyle', 'borderWidth', parseFloat((e.target as HTMLInputElement).value) || 0)" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
52
frontend/src/components/editor/toolbars/TextToolbar.vue
Normal file
52
frontend/src/components/editor/toolbars/TextToolbar.vue
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { TextStyle, TemplateElement } from '../../../core/types'
|
||||||
|
|
||||||
|
const props = defineProps<{ element: TemplateElement }>()
|
||||||
|
const style = () => props.element.style as TextStyle
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
updateStyle: [key: string, value: unknown]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Bold -->
|
||||||
|
<div class="et__group">
|
||||||
|
<button class="et__btn" :class="{ 'et__btn--active': style().fontWeight === 'bold' }" data-tip="Kalin" @click="emit('updateStyle', 'fontWeight', style().fontWeight === 'bold' ? 'normal' : 'bold')">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M4 2.5h3.5a2.5 2.5 0 0 1 0 5H4V2.5z" stroke="currentColor" stroke-width="1.5" fill="none" /><path d="M4 7.5h4a2.5 2.5 0 0 1 0 5H4V7.5z" stroke="currentColor" stroke-width="1.5" fill="none" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="et__sep" />
|
||||||
|
|
||||||
|
<!-- Align -->
|
||||||
|
<div class="et__group">
|
||||||
|
<button class="et__btn" :class="{ 'et__btn--active': (style().align ?? 'left') === 'left' }" data-tip="Sola Hizala" @click="emit('updateStyle', 'align', 'left')">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><line x1="2" y1="3" x2="12" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /><line x1="2" y1="7" x2="9" y2="7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /><line x1="2" y1="11" x2="11" y2="11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /></svg>
|
||||||
|
</button>
|
||||||
|
<button class="et__btn" :class="{ 'et__btn--active': style().align === 'center' }" data-tip="Ortala" @click="emit('updateStyle', 'align', 'center')">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><line x1="2" y1="3" x2="12" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /><line x1="3.5" y1="7" x2="10.5" y2="7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /><line x1="2.5" y1="11" x2="11.5" y2="11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /></svg>
|
||||||
|
</button>
|
||||||
|
<button class="et__btn" :class="{ 'et__btn--active': style().align === 'right' }" data-tip="Saga Hizala" @click="emit('updateStyle', 'align', 'right')">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><line x1="2" y1="3" x2="12" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /><line x1="5" y1="7" x2="12" y2="7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /><line x1="3" y1="11" x2="12" y2="11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="et__sep" />
|
||||||
|
|
||||||
|
<!-- Font size -->
|
||||||
|
<div class="et__group et__group--gap">
|
||||||
|
<svg class="et__gap-icon" width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M2 10L6 2l4 8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" fill="none" /><line x1="3.5" y1="7" x2="8.5" y2="7" stroke="currentColor" stroke-width="1" stroke-linecap="round" /></svg>
|
||||||
|
<input type="number" class="et__num" step="1" min="1" :value="style().fontSize ?? 11" @input="(e) => emit('updateStyle', 'fontSize', parseFloat((e.target as HTMLInputElement).value) || 11)" data-tip="Yazi Boyutu (pt)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="et__sep" />
|
||||||
|
|
||||||
|
<!-- Color -->
|
||||||
|
<div class="et__group">
|
||||||
|
<label class="et__color-wrap" data-tip="Renk">
|
||||||
|
<input type="color" class="et__color" :value="style().color ?? '#000000'" @input="(e) => emit('updateStyle', 'color', (e.target as HTMLInputElement).value)" />
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="2" y="11" width="10" height="2" rx="0.5" :fill="style().color ?? '#000000'" /><path d="M5 9L7 3l2 6" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" fill="none" /><line x1="5.5" y1="7.5" x2="8.5" y2="7.5" stroke="currentColor" stroke-width="1" stroke-linecap="round" /></svg>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -32,6 +32,7 @@ import RichTextProperties from '../properties/RichTextProperties.vue'
|
|||||||
import ContainerProperties from '../properties/ContainerProperties.vue'
|
import ContainerProperties from '../properties/ContainerProperties.vue'
|
||||||
import RepeatingTableProperties from '../properties/RepeatingTableProperties.vue'
|
import RepeatingTableProperties from '../properties/RepeatingTableProperties.vue'
|
||||||
import ChartProperties from '../properties/ChartProperties.vue'
|
import ChartProperties from '../properties/ChartProperties.vue'
|
||||||
|
import PropCondition from '../properties/shared/PropCondition.vue'
|
||||||
import '../../styles/properties.css'
|
import '../../styles/properties.css'
|
||||||
|
|
||||||
const templateStore = useTemplateStore()
|
const templateStore = useTemplateStore()
|
||||||
@@ -55,21 +56,36 @@ const elementTypeLabel = computed(() => {
|
|||||||
if (el.id === 'header') return 'Üst Bilgi'
|
if (el.id === 'header') return 'Üst Bilgi'
|
||||||
if (el.id === 'footer') return 'Alt Bilgi'
|
if (el.id === 'footer') return 'Alt Bilgi'
|
||||||
return 'Container'
|
return 'Container'
|
||||||
case 'static_text': return 'Metin'
|
case 'static_text':
|
||||||
case 'text': return 'Metin'
|
return 'Metin'
|
||||||
case 'line': return 'Cizgi'
|
case 'text':
|
||||||
case 'repeating_table': return 'Tablo'
|
return 'Metin'
|
||||||
case 'image': return 'Gorsel'
|
case 'line':
|
||||||
case 'page_number': return 'Sayfa No'
|
return 'Cizgi'
|
||||||
case 'barcode': return 'Barkod'
|
case 'repeating_table':
|
||||||
case 'checkbox': return 'Onay Kutusu'
|
return 'Tablo'
|
||||||
case 'shape': return 'Sekil'
|
case 'image':
|
||||||
case 'current_date': return 'Tarih'
|
return 'Gorsel'
|
||||||
case 'calculated_text': return 'Hesaplanan Metin'
|
case 'page_number':
|
||||||
case 'rich_text': return 'Zengin Metin'
|
return 'Sayfa No'
|
||||||
case 'page_break': return 'Sayfa Sonu'
|
case 'barcode':
|
||||||
case 'chart': return 'Grafik'
|
return 'Barkod'
|
||||||
default: return 'Eleman'
|
case 'checkbox':
|
||||||
|
return 'Onay Kutusu'
|
||||||
|
case 'shape':
|
||||||
|
return 'Sekil'
|
||||||
|
case 'current_date':
|
||||||
|
return 'Tarih'
|
||||||
|
case 'calculated_text':
|
||||||
|
return 'Hesaplanan Metin'
|
||||||
|
case 'rich_text':
|
||||||
|
return 'Zengin Metin'
|
||||||
|
case 'page_break':
|
||||||
|
return 'Sayfa Sonu'
|
||||||
|
case 'chart':
|
||||||
|
return 'Grafik'
|
||||||
|
default:
|
||||||
|
return 'Eleman'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -105,12 +121,12 @@ function deleteSelected() {
|
|||||||
<div class="properties-panel">
|
<div class="properties-panel">
|
||||||
<div v-if="multipleSelected" class="properties-panel__empty">
|
<div v-if="multipleSelected" class="properties-panel__empty">
|
||||||
{{ editorStore.selectedElementIds.size }} eleman secili
|
{{ editorStore.selectedElementIds.size }} eleman secili
|
||||||
<button class="prop-delete-btn" style="margin-top: 12px" @click="deleteSelected">Secilenleri Sil</button>
|
<button class="prop-delete-btn" style="margin-top: 12px" @click="deleteSelected">
|
||||||
|
Secilenleri Sil
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="!selectedElement" class="properties-panel__empty">
|
<div v-else-if="!selectedElement" class="properties-panel__empty">Bir eleman secin</div>
|
||||||
Bir eleman secin
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
@@ -134,71 +150,97 @@ function deleteSelected() {
|
|||||||
|
|
||||||
<TextProperties
|
<TextProperties
|
||||||
v-if="selectedElement.type === 'static_text' || selectedElement.type === 'text'"
|
v-if="selectedElement.type === 'static_text' || selectedElement.type === 'text'"
|
||||||
:element="selectedElement" />
|
:element="selectedElement"
|
||||||
|
/>
|
||||||
|
|
||||||
<LineProperties
|
<LineProperties
|
||||||
v-if="selectedElement.type === 'line'"
|
v-if="selectedElement.type === 'line'"
|
||||||
:element="(selectedElement as LineElement)" />
|
:element="selectedElement as LineElement"
|
||||||
|
/>
|
||||||
|
|
||||||
<ImageProperties
|
<ImageProperties
|
||||||
v-if="selectedElement.type === 'image'"
|
v-if="selectedElement.type === 'image'"
|
||||||
:element="(selectedElement as ImageElement)" />
|
:element="selectedElement as ImageElement"
|
||||||
|
/>
|
||||||
|
|
||||||
<PageNumberProperties
|
<PageNumberProperties
|
||||||
v-if="selectedElement.type === 'page_number'"
|
v-if="selectedElement.type === 'page_number'"
|
||||||
:element="(selectedElement as PageNumberElement)" />
|
:element="selectedElement as PageNumberElement"
|
||||||
|
/>
|
||||||
|
|
||||||
<BarcodeProperties
|
<BarcodeProperties
|
||||||
v-if="selectedElement.type === 'barcode'"
|
v-if="selectedElement.type === 'barcode'"
|
||||||
:element="(selectedElement as BarcodeElement)" />
|
:element="selectedElement as BarcodeElement"
|
||||||
|
/>
|
||||||
|
|
||||||
<CurrentDateProperties
|
<CurrentDateProperties
|
||||||
v-if="selectedElement.type === 'current_date'"
|
v-if="selectedElement.type === 'current_date'"
|
||||||
:element="(selectedElement as CurrentDateElement)" />
|
:element="selectedElement as CurrentDateElement"
|
||||||
|
/>
|
||||||
|
|
||||||
<CheckboxProperties
|
<CheckboxProperties
|
||||||
v-if="selectedElement.type === 'checkbox'"
|
v-if="selectedElement.type === 'checkbox'"
|
||||||
:element="(selectedElement as CheckboxElement)" />
|
:element="selectedElement as CheckboxElement"
|
||||||
|
/>
|
||||||
|
|
||||||
<CalculatedTextProperties
|
<CalculatedTextProperties
|
||||||
v-if="selectedElement.type === 'calculated_text'"
|
v-if="selectedElement.type === 'calculated_text'"
|
||||||
:element="(selectedElement as CalculatedTextElement)" />
|
:element="selectedElement as CalculatedTextElement"
|
||||||
|
/>
|
||||||
|
|
||||||
<RichTextProperties
|
<RichTextProperties
|
||||||
v-if="selectedElement.type === 'rich_text'"
|
v-if="selectedElement.type === 'rich_text'"
|
||||||
:element="(selectedElement as RichTextElement)" />
|
:element="selectedElement as RichTextElement"
|
||||||
|
/>
|
||||||
|
|
||||||
<ShapeProperties
|
<ShapeProperties
|
||||||
v-if="selectedElement.type === 'shape'"
|
v-if="selectedElement.type === 'shape'"
|
||||||
:element="(selectedElement as ShapeElement)" />
|
:element="selectedElement as ShapeElement"
|
||||||
|
/>
|
||||||
|
|
||||||
<ContainerProperties
|
<ContainerProperties
|
||||||
v-if="isContainer(selectedElement)"
|
v-if="isContainer(selectedElement)"
|
||||||
:element="(selectedElement as ContainerElement)" />
|
:element="selectedElement as ContainerElement"
|
||||||
|
/>
|
||||||
|
|
||||||
<RepeatingTableProperties
|
<RepeatingTableProperties
|
||||||
v-if="selectedElement.type === 'repeating_table'"
|
v-if="selectedElement.type === 'repeating_table'"
|
||||||
:element="(selectedElement as RepeatingTableElement)" />
|
:element="selectedElement as RepeatingTableElement"
|
||||||
|
/>
|
||||||
|
|
||||||
<ChartProperties
|
<ChartProperties
|
||||||
v-if="selectedElement.type === 'chart'"
|
v-if="selectedElement.type === 'chart'"
|
||||||
:element="(selectedElement as ChartElement)" />
|
:element="selectedElement as ChartElement"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Header/Footer toggles for root element -->
|
<!-- Header/Footer toggles for root element -->
|
||||||
<div v-if="selectedElement.id === 'root'" class="prop-section">
|
<div v-if="selectedElement.id === 'root'" class="prop-section">
|
||||||
<div class="prop-section__title">Sayfa Ust/Alt Bilgi</div>
|
<div class="prop-section__title">Sayfa Ust/Alt Bilgi</div>
|
||||||
<div class="prop-row">
|
<div class="prop-row">
|
||||||
<label class="prop-label">Ust Bilgi (Header)</label>
|
<label class="prop-label">Ust Bilgi (Header)</label>
|
||||||
<input type="checkbox" :checked="!!templateStore.template.header"
|
<input
|
||||||
@change="toggleHeader" />
|
type="checkbox"
|
||||||
|
:checked="!!templateStore.template.header"
|
||||||
|
@change="toggleHeader"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="prop-row">
|
<div class="prop-row">
|
||||||
<label class="prop-label">Alt Bilgi (Footer)</label>
|
<label class="prop-label">Alt Bilgi (Footer)</label>
|
||||||
<input type="checkbox" :checked="!!templateStore.template.footer"
|
<input
|
||||||
@change="toggleFooter" />
|
type="checkbox"
|
||||||
|
:checked="!!templateStore.template.footer"
|
||||||
|
@change="toggleFooter"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Condition -->
|
||||||
|
<PropCondition
|
||||||
|
v-if="selectedElement.id !== 'root'"
|
||||||
|
:condition="selectedElement.condition"
|
||||||
|
@update:condition="(v) => templateStore.updateElement(selectedElement!.id, { condition: v } as any)"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 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>
|
||||||
|
|||||||
@@ -7,12 +7,15 @@ import { sz } from '../../core/types'
|
|||||||
import { useEditorStore } from '../../stores/editor'
|
import { useEditorStore } from '../../stores/editor'
|
||||||
import { useSchemaStore } from '../../stores/schema'
|
import { useSchemaStore } from '../../stores/schema'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
node: SchemaNode
|
node: SchemaNode
|
||||||
depth?: number
|
depth?: number
|
||||||
}>(), {
|
}>(),
|
||||||
|
{
|
||||||
depth: 0,
|
depth: 0,
|
||||||
})
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const editorStore = useEditorStore()
|
const editorStore = useEditorStore()
|
||||||
const schemaStore = useSchemaStore()
|
const schemaStore = useSchemaStore()
|
||||||
@@ -68,7 +71,7 @@ function createBoundTextElement(node: SchemaNode): TemplateElement {
|
|||||||
|
|
||||||
function createBoundTableElement(node: SchemaNode): RepeatingTableElement {
|
function createBoundTableElement(node: SchemaNode): RepeatingTableElement {
|
||||||
const itemFields = schemaStore.getArrayItemFields(node.path)
|
const itemFields = schemaStore.getArrayItemFields(node.path)
|
||||||
const columns: TableColumn[] = itemFields.map(field => ({
|
const columns: TableColumn[] = itemFields.map((field) => ({
|
||||||
id: `col_${(++colIdCounter).toString(36)}`,
|
id: `col_${(++colIdCounter).toString(36)}`,
|
||||||
field: field.key,
|
field: field.key,
|
||||||
title: field.title,
|
title: field.title,
|
||||||
@@ -108,9 +111,7 @@ function onDragEnd() {
|
|||||||
editorStore.endDragNewElement()
|
editorStore.endDragNewElement()
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayChildren = isArray
|
const displayChildren = isArray ? (props.node.itemProperties ?? []) : props.node.children
|
||||||
? (props.node.itemProperties ?? [])
|
|
||||||
: props.node.children
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -131,7 +132,11 @@ const displayChildren = isArray
|
|||||||
@dragstart="onDragStart"
|
@dragstart="onDragStart"
|
||||||
@dragend="onDragEnd"
|
@dragend="onDragEnd"
|
||||||
>
|
>
|
||||||
<span v-if="hasChildren" class="schema-node__arrow" :class="{ 'schema-node__arrow--expanded': expanded }">
|
<span
|
||||||
|
v-if="hasChildren"
|
||||||
|
class="schema-node__arrow"
|
||||||
|
:class="{ 'schema-node__arrow--expanded': expanded }"
|
||||||
|
>
|
||||||
▶
|
▶
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="schema-node__arrow-placeholder" />
|
<span v-else class="schema-node__arrow-placeholder" />
|
||||||
|
|||||||
@@ -1,7 +1,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useEditorStore } from '../../stores/editor'
|
import { useEditorStore } from '../../stores/editor'
|
||||||
import { useSchemaStore } from '../../stores/schema'
|
import { useSchemaStore } from '../../stores/schema'
|
||||||
import type { TemplateElement, RepeatingTableElement, TableColumn, ImageElement, PageNumberElement, BarcodeElement, PageBreakElement, CurrentDateElement, ShapeElement, CheckboxElement, CalculatedTextElement, RichTextElement, ChartElement } from '../../core/types'
|
import type {
|
||||||
|
TemplateElement,
|
||||||
|
TextElement,
|
||||||
|
RepeatingTableElement,
|
||||||
|
TableColumn,
|
||||||
|
ImageElement,
|
||||||
|
PageNumberElement,
|
||||||
|
BarcodeElement,
|
||||||
|
PageBreakElement,
|
||||||
|
CurrentDateElement,
|
||||||
|
ShapeElement,
|
||||||
|
CheckboxElement,
|
||||||
|
CalculatedTextElement,
|
||||||
|
RichTextElement,
|
||||||
|
ChartElement,
|
||||||
|
} from '../../core/types'
|
||||||
import { sz } from '../../core/types'
|
import { sz } from '../../core/types'
|
||||||
import { schemaFormatToFormatType, defaultAlignForSchema } from '../../core/schema-parser'
|
import { schemaFormatToFormatType, defaultAlignForSchema } from '../../core/schema-parser'
|
||||||
|
|
||||||
@@ -32,6 +47,18 @@ const tools: ToolItem[] = [
|
|||||||
content: 'Yeni metin',
|
content: 'Yeni metin',
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Veri Metni',
|
||||||
|
icon: 'D',
|
||||||
|
create: (): TextElement => ({
|
||||||
|
id: nextId('dtxt'),
|
||||||
|
type: 'text',
|
||||||
|
position: { type: 'flow' },
|
||||||
|
size: { width: sz.auto(), height: sz.auto() },
|
||||||
|
style: { fontSize: 11, color: '#000000' },
|
||||||
|
binding: { type: 'scalar', path: '' },
|
||||||
|
}),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Zengin Metin',
|
label: 'Zengin Metin',
|
||||||
icon: 'R',
|
icon: 'R',
|
||||||
@@ -88,7 +115,7 @@ const tools: ToolItem[] = [
|
|||||||
if (firstArray) {
|
if (firstArray) {
|
||||||
dataPath = firstArray.path
|
dataPath = firstArray.path
|
||||||
const itemFields = schemaStore.getArrayItemFields(firstArray.path)
|
const itemFields = schemaStore.getArrayItemFields(firstArray.path)
|
||||||
columns = itemFields.map(field => ({
|
columns = itemFields.map((field) => ({
|
||||||
id: nextId('col'),
|
id: nextId('col'),
|
||||||
field: field.key,
|
field: field.key,
|
||||||
title: field.title,
|
title: field.title,
|
||||||
@@ -212,8 +239,8 @@ const tools: ToolItem[] = [
|
|||||||
if (firstArray) {
|
if (firstArray) {
|
||||||
dataPath = firstArray.path
|
dataPath = firstArray.path
|
||||||
const itemFields = schemaStore.getArrayItemFields(firstArray.path)
|
const itemFields = schemaStore.getArrayItemFields(firstArray.path)
|
||||||
const stringField = itemFields.find(f => f.type === 'string')
|
const stringField = itemFields.find((f) => f.type === 'string')
|
||||||
const numberField = itemFields.find(f => f.type === 'number' || f.type === 'integer')
|
const numberField = itemFields.find((f) => f.type === 'number' || f.type === 'integer')
|
||||||
categoryField = stringField?.key ?? itemFields[0]?.key ?? ''
|
categoryField = stringField?.key ?? itemFields[0]?.key ?? ''
|
||||||
valueField = numberField?.key ?? itemFields[1]?.key ?? ''
|
valueField = numberField?.key ?? itemFields[1]?.key ?? ''
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,26 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { useTemplateStore } from '../../stores/template'
|
import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
|
||||||
import { useEditorStore } from '../../stores/editor'
|
|
||||||
import { useSchemaStore } from '../../stores/schema'
|
import { useSchemaStore } from '../../stores/schema'
|
||||||
import type { BarcodeElement, BarcodeFormat, TemplateElement } from '../../core/types'
|
import PropSection from './shared/PropSection.vue'
|
||||||
|
import PropSelect from './shared/PropSelect.vue'
|
||||||
|
import PropColorInput from './shared/PropColorInput.vue'
|
||||||
|
import PropCheckbox from './shared/PropCheckbox.vue'
|
||||||
|
import PropFieldSelect from './shared/PropFieldSelect.vue'
|
||||||
|
import type { BarcodeElement, BarcodeFormat } from '../../core/types'
|
||||||
import '../../styles/properties.css'
|
import '../../styles/properties.css'
|
||||||
|
|
||||||
const props = defineProps<{ element: BarcodeElement }>()
|
const props = defineProps<{ element: BarcodeElement }>()
|
||||||
const templateStore = useTemplateStore()
|
const { update, updateStyle } = usePropertyUpdate(() => props.element)
|
||||||
const editorStore = useEditorStore()
|
|
||||||
const schemaStore = useSchemaStore()
|
const schemaStore = useSchemaStore()
|
||||||
|
|
||||||
function update(updates: Partial<TemplateElement>) {
|
const formatOptions = [
|
||||||
const id = editorStore.selectedElementId
|
{ value: 'qr', label: 'QR Kod' },
|
||||||
if (!id) return
|
{ value: 'ean13', label: 'EAN-13' },
|
||||||
templateStore.updateElement(id, updates)
|
{ value: 'ean8', label: 'EAN-8' },
|
||||||
}
|
{ value: 'code128', label: 'Code 128' },
|
||||||
|
{ value: 'code39', label: 'Code 39' },
|
||||||
function updateStyle(key: string, value: unknown) {
|
]
|
||||||
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>)
|
|
||||||
}
|
|
||||||
|
|
||||||
const barcodeDefaults: Record<BarcodeFormat, string> = {
|
const barcodeDefaults: Record<BarcodeFormat, string> = {
|
||||||
qr: 'https://example.com',
|
qr: 'https://example.com',
|
||||||
@@ -50,7 +51,7 @@ function validateBarcode(format: BarcodeFormat, value: string): boolean {
|
|||||||
case 'code39':
|
case 'code39':
|
||||||
return /^[A-Z0-9\-. $/+%]+$/i.test(value)
|
return /^[A-Z0-9\-. $/+%]+$/i.test(value)
|
||||||
case 'code128':
|
case 'code128':
|
||||||
return value.length > 0 && [...value].every(c => c.charCodeAt(0) < 128)
|
return value.length > 0 && [...value].every((c) => c.charCodeAt(0) < 128)
|
||||||
case 'qr':
|
case 'qr':
|
||||||
return value.length > 0
|
return value.length > 0
|
||||||
default:
|
default:
|
||||||
@@ -61,15 +62,18 @@ function validateBarcode(format: BarcodeFormat, value: string): boolean {
|
|||||||
const barcodeInputValue = ref('')
|
const barcodeInputValue = ref('')
|
||||||
const barcodeInputInvalid = ref(false)
|
const barcodeInputInvalid = ref(false)
|
||||||
|
|
||||||
watch(() => props.element.value ?? '', (val) => {
|
watch(
|
||||||
|
() => props.element.value ?? '',
|
||||||
|
(val) => {
|
||||||
barcodeInputValue.value = val
|
barcodeInputValue.value = val
|
||||||
barcodeInputInvalid.value = false
|
barcodeInputInvalid.value = false
|
||||||
}, { immediate: true })
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
function onBarcodeValueInput(e: Event) {
|
function onBarcodeValueInput(e: Event) {
|
||||||
const val = (e.target as HTMLInputElement).value
|
const val = (e.target as HTMLInputElement).value
|
||||||
barcodeInputValue.value = val
|
barcodeInputValue.value = val
|
||||||
|
|
||||||
if (validateBarcode(props.element.format, val)) {
|
if (validateBarcode(props.element.format, val)) {
|
||||||
barcodeInputInvalid.value = false
|
barcodeInputInvalid.value = false
|
||||||
update({ value: val } as any)
|
update({ value: val } as any)
|
||||||
@@ -78,75 +82,69 @@ function onBarcodeValueInput(e: Event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onBarcodeFormatChange(newFormat: BarcodeFormat) {
|
function onBarcodeFormatChange(newFormat: string) {
|
||||||
|
const fmt = newFormat as BarcodeFormat
|
||||||
const currentValue = props.element.value ?? ''
|
const currentValue = props.element.value ?? ''
|
||||||
if (validateBarcode(newFormat, currentValue)) {
|
if (validateBarcode(fmt, currentValue)) {
|
||||||
update({ format: newFormat } as any)
|
update({ format: fmt } as any)
|
||||||
} else {
|
} else {
|
||||||
const defaultVal = barcodeDefaults[newFormat]
|
const defaultVal = barcodeDefaults[fmt]
|
||||||
barcodeInputValue.value = defaultVal
|
barcodeInputValue.value = defaultVal
|
||||||
barcodeInputInvalid.value = false
|
barcodeInputInvalid.value = false
|
||||||
update({ format: newFormat, value: defaultVal } as any)
|
update({ format: fmt, value: defaultVal } as any)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="prop-section">
|
<PropSection title="Barkod Ayarlari">
|
||||||
<div class="prop-section__title">Barkod Ayarlari</div>
|
<PropSelect
|
||||||
<div class="prop-row" data-tip="Barkod formati">
|
label="Format"
|
||||||
<label class="prop-label">Format</label>
|
:model-value="element.format"
|
||||||
<select class="prop-input prop-select"
|
:options="formatOptions"
|
||||||
:value="element.format"
|
data-tip="Barkod formati"
|
||||||
@change="(e) => onBarcodeFormatChange((e.target as HTMLSelectElement).value as BarcodeFormat)">
|
@update:model-value="onBarcodeFormatChange"
|
||||||
<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" data-tip="Barkod icerigi — formata uygun olmali">
|
<div class="prop-row" data-tip="Barkod icerigi — formata uygun olmali">
|
||||||
<label class="prop-label">Deger</label>
|
<label class="prop-label">Deger</label>
|
||||||
<input class="prop-input" type="text"
|
<input
|
||||||
|
class="prop-input"
|
||||||
|
type="text"
|
||||||
:class="{ 'prop-input--invalid': barcodeInputInvalid }"
|
:class="{ 'prop-input--invalid': barcodeInputInvalid }"
|
||||||
:value="barcodeInputValue"
|
:value="barcodeInputValue"
|
||||||
@input="onBarcodeValueInput" />
|
@input="onBarcodeValueInput"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="prop-row" data-tip="Barkod cizgi/modül rengi">
|
<PropColorInput
|
||||||
<label class="prop-label">Renk</label>
|
label="Renk"
|
||||||
<div class="prop-row-inline">
|
:model-value="element.style.color ?? '#000000'"
|
||||||
<input class="prop-input prop-color" type="color"
|
:clearable="true"
|
||||||
:value="element.style.color ?? '#000000'"
|
data-tip="Barkod cizgi/modul rengi"
|
||||||
@input="(e) => updateStyle('color', (e.target as HTMLInputElement).value)" />
|
@update:model-value="(v) => updateStyle('color', v)"
|
||||||
<button v-if="element.style.color" class="prop-clear" @click="updateStyle('color', undefined)">x</button>
|
/>
|
||||||
</div>
|
<PropCheckbox
|
||||||
</div>
|
v-if="element.format !== 'qr'"
|
||||||
<div v-if="element.format !== 'qr'" class="prop-row" data-tip="Barkod altinda degeri metin olarak goster">
|
label="Metin Goster"
|
||||||
<label class="prop-label">Metin Goster</label>
|
:model-value="
|
||||||
<input type="checkbox"
|
element.style.includeText ?? (element.format === 'ean13' || element.format === 'ean8')
|
||||||
:checked="element.style.includeText ?? (element.format === 'ean13' || element.format === 'ean8')"
|
"
|
||||||
@change="(e) => updateStyle('includeText', (e.target as HTMLInputElement).checked)" />
|
data-tip="Barkod altinda degeri metin olarak goster"
|
||||||
</div>
|
@update:model-value="(v) => updateStyle('includeText', v)"
|
||||||
<div v-if="schemaStore.scalarFields.length > 0" class="prop-row" data-tip="Schema'dan dinamik veri baglama">
|
/>
|
||||||
<label class="prop-label">Veri Baglama</label>
|
<PropFieldSelect
|
||||||
<select class="prop-input prop-select"
|
v-if="schemaStore.scalarFields.length > 0"
|
||||||
:value="element.binding?.path ?? ''"
|
label="Veri Baglama"
|
||||||
@change="(e) => {
|
:model-value="element.binding?.path ?? ''"
|
||||||
const val = (e.target as HTMLSelectElement).value
|
:fields="schemaStore.scalarFields"
|
||||||
if (val) {
|
:allow-empty="true"
|
||||||
update({ binding: { type: 'scalar', path: val } } as any)
|
empty-label="Yok (statik deger)"
|
||||||
} else {
|
data-tip="Schema'dan dinamik veri baglama"
|
||||||
update({ binding: undefined } as any)
|
@update:model-value="
|
||||||
|
(v) => {
|
||||||
|
if (v) update({ binding: { type: 'scalar', path: v } } as any)
|
||||||
|
else update({ binding: undefined } as any)
|
||||||
}
|
}
|
||||||
}">
|
"
|
||||||
<option value="">Yok (statik deger)</option>
|
/>
|
||||||
<option
|
</PropSection>
|
||||||
v-for="field in schemaStore.scalarFields"
|
|
||||||
:key="field.path"
|
|
||||||
:value="field.path"
|
|
||||||
>{{ field.title }} ({{ field.path }})</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,80 +1,55 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useTemplateStore } from '../../stores/template'
|
import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
|
||||||
import { useEditorStore } from '../../stores/editor'
|
import PropSection from './shared/PropSection.vue'
|
||||||
import type { CalculatedTextElement, TextStyle, TemplateElement } from '../../core/types'
|
import PropSelect from './shared/PropSelect.vue'
|
||||||
|
import PropTextStyleGroup from './shared/PropTextStyleGroup.vue'
|
||||||
import DexprEditor from '../common/DexprEditor.vue'
|
import DexprEditor from '../common/DexprEditor.vue'
|
||||||
|
import type { CalculatedTextElement, TextStyle } from '../../core/types'
|
||||||
import '../../styles/properties.css'
|
import '../../styles/properties.css'
|
||||||
|
|
||||||
const props = defineProps<{ element: CalculatedTextElement }>()
|
const props = defineProps<{ element: CalculatedTextElement }>()
|
||||||
const templateStore = useTemplateStore()
|
const { update, updateStyle } = usePropertyUpdate(() => props.element)
|
||||||
const editorStore = useEditorStore()
|
const style = () => props.element.style as TextStyle
|
||||||
|
|
||||||
function update(updates: Partial<TemplateElement>) {
|
const formatOptions = [
|
||||||
const id = editorStore.selectedElementId
|
{ value: '', label: 'Yok' },
|
||||||
if (!id) return
|
{ value: 'currency', label: 'Para Birimi' },
|
||||||
templateStore.updateElement(id, updates)
|
{ value: 'number', label: 'Sayi' },
|
||||||
}
|
{ value: 'percentage', label: 'Yuzde' },
|
||||||
|
]
|
||||||
function updateStyle(key: string, value: unknown) {
|
|
||||||
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onExpressionChange(value: string) {
|
|
||||||
update({ expression: value } as any)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="prop-section">
|
<PropSection title="Hesaplanan Metin">
|
||||||
<div class="prop-section__title">Hesaplanan Metin</div>
|
<div
|
||||||
<div class="prop-row-stack" data-tip="Hesaplama ifadesi (orn: toplamlar.kdv + toplamlar.araToplam)">
|
class="prop-row-stack"
|
||||||
|
data-tip="Hesaplama ifadesi (orn: toplamlar.kdv + toplamlar.araToplam)"
|
||||||
|
>
|
||||||
<label class="prop-label">Ifade</label>
|
<label class="prop-label">Ifade</label>
|
||||||
<DexprEditor
|
<DexprEditor
|
||||||
:model-value="element.expression"
|
:model-value="element.expression"
|
||||||
@update:model-value="onExpressionChange"
|
@update:model-value="(v) => update({ expression: v } as any)"
|
||||||
placeholder="toplamlar.kdv + toplamlar.araToplam" />
|
placeholder="toplamlar.kdv + toplamlar.araToplam"
|
||||||
</div>
|
/>
|
||||||
<div class="prop-row" data-tip="Sonucun gosterim formati">
|
|
||||||
<label class="prop-label">Format</label>
|
|
||||||
<select class="prop-input prop-select"
|
|
||||||
:value="element.format ?? ''"
|
|
||||||
@change="(e) => update({ format: (e.target as HTMLSelectElement).value || undefined } as any)">
|
|
||||||
<option value="">Yok</option>
|
|
||||||
<option value="currency">Para Birimi</option>
|
|
||||||
<option value="number">Sayi</option>
|
|
||||||
<option value="percentage">Yuzde</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="prop-row" data-tip="Yazi tipi boyutu (point)">
|
|
||||||
<label class="prop-label">Boyut (pt)</label>
|
|
||||||
<input class="prop-input" type="number" step="1" min="1"
|
|
||||||
:value="(element.style as TextStyle).fontSize ?? 11"
|
|
||||||
@input="(e) => updateStyle('fontSize', parseFloat((e.target as HTMLInputElement).value) || 11)" />
|
|
||||||
</div>
|
|
||||||
<div class="prop-row" data-tip="Metin rengi">
|
|
||||||
<label class="prop-label">Renk</label>
|
|
||||||
<input class="prop-input prop-color" type="color"
|
|
||||||
:value="(element.style as TextStyle).color ?? '#000000'"
|
|
||||||
@input="(e) => updateStyle('color', (e.target as HTMLInputElement).value)" />
|
|
||||||
</div>
|
|
||||||
<div class="prop-row" data-tip="Yazi tipi kalinligi">
|
|
||||||
<label class="prop-label">Kalinlik</label>
|
|
||||||
<select class="prop-input prop-select"
|
|
||||||
:value="(element.style as TextStyle).fontWeight ?? 'normal'"
|
|
||||||
@change="(e) => updateStyle('fontWeight', (e.target as HTMLSelectElement).value)">
|
|
||||||
<option value="normal">Normal</option>
|
|
||||||
<option value="bold">Kalin</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="prop-row" data-tip="Metnin yatay hizalamasi">
|
|
||||||
<label class="prop-label">Hizalama</label>
|
|
||||||
<select class="prop-input prop-select"
|
|
||||||
:value="(element.style as TextStyle).align ?? 'left'"
|
|
||||||
@change="(e) => updateStyle('align', (e.target as HTMLSelectElement).value)">
|
|
||||||
<option value="left">Sol</option>
|
|
||||||
<option value="center">Orta</option>
|
|
||||||
<option value="right">Sag</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<PropSelect
|
||||||
|
label="Format"
|
||||||
|
:model-value="element.format ?? ''"
|
||||||
|
:options="formatOptions"
|
||||||
|
data-tip="Sonucun gosterim formati"
|
||||||
|
@update:model-value="(v) => update({ format: v || undefined } as any)"
|
||||||
|
/>
|
||||||
|
<PropTextStyleGroup
|
||||||
|
:font-size="style().fontSize ?? 11"
|
||||||
|
:font-weight="style().fontWeight ?? 'normal'"
|
||||||
|
:font-family="style().fontFamily"
|
||||||
|
:color="style().color ?? '#000000'"
|
||||||
|
:align="style().align ?? 'left'"
|
||||||
|
@update:font-size="(v) => updateStyle('fontSize', v)"
|
||||||
|
@update:font-weight="(v) => updateStyle('fontWeight', v)"
|
||||||
|
@update:font-family="(v) => updateStyle('fontFamily', v)"
|
||||||
|
@update:color="(v) => updateStyle('color', v)"
|
||||||
|
@update:align="(v) => updateStyle('align', v)"
|
||||||
|
/>
|
||||||
|
</PropSection>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,81 +1,75 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useTemplateStore } from '../../stores/template'
|
import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
|
||||||
import { useEditorStore } from '../../stores/editor'
|
|
||||||
import { useSchemaStore } from '../../stores/schema'
|
import { useSchemaStore } from '../../stores/schema'
|
||||||
import type { ChartElement, ChartType, GroupMode, TemplateElement } from '../../core/types'
|
import PropSection from './shared/PropSection.vue'
|
||||||
|
import PropSelect from './shared/PropSelect.vue'
|
||||||
|
import PropNumberInput from './shared/PropNumberInput.vue'
|
||||||
|
import PropColorInput from './shared/PropColorInput.vue'
|
||||||
|
import PropCheckbox from './shared/PropCheckbox.vue'
|
||||||
|
import PropFieldSelect from './shared/PropFieldSelect.vue'
|
||||||
|
import type { ChartElement, ChartType, GroupMode } from '../../core/types'
|
||||||
import '../../styles/properties.css'
|
import '../../styles/properties.css'
|
||||||
|
|
||||||
const props = defineProps<{ element: ChartElement }>()
|
const props = defineProps<{ element: ChartElement }>()
|
||||||
const templateStore = useTemplateStore()
|
const { update, updateStyle, updateNested } = usePropertyUpdate(() => props.element)
|
||||||
const editorStore = useEditorStore()
|
|
||||||
const schemaStore = useSchemaStore()
|
const schemaStore = useSchemaStore()
|
||||||
|
|
||||||
function update(updates: Partial<ChartElement>) {
|
const chartTypeOptions = [
|
||||||
const id = editorStore.selectedElementId
|
{ value: 'bar', label: 'Bar' },
|
||||||
if (!id) return
|
{ value: 'line', label: 'Line' },
|
||||||
templateStore.updateElement(id, updates as Partial<TemplateElement>)
|
{ value: 'pie', label: 'Pie' },
|
||||||
}
|
]
|
||||||
|
|
||||||
function updateStyle(key: string, value: unknown) {
|
const groupModeOptions = [
|
||||||
const newStyle = { ...props.element.style, [key]: value }
|
{ value: 'grouped', label: 'Yan Yana' },
|
||||||
if (value === undefined || value === '') delete (newStyle as Record<string, unknown>)[key]
|
{ value: 'stacked', label: 'Yigin' },
|
||||||
update({ style: newStyle })
|
]
|
||||||
}
|
|
||||||
|
|
||||||
// Schema'daki array alanlari
|
const alignOptions = [
|
||||||
const arrayFields = computed(() => schemaStore.arrayFields)
|
{ value: 'left', label: 'Sol' },
|
||||||
|
{ value: 'center', label: 'Orta' },
|
||||||
|
{ value: 'right', label: 'Sag' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const legendPositionOptions = [
|
||||||
|
{ value: 'top', label: 'Ust' },
|
||||||
|
{ value: 'bottom', label: 'Alt' },
|
||||||
|
{ value: 'right', label: 'Sag' },
|
||||||
|
]
|
||||||
|
|
||||||
// Secili array'in item alanlari
|
|
||||||
const itemFields = computed(() => {
|
const itemFields = computed(() => {
|
||||||
const path = props.element.dataSource?.path
|
const path = props.element.dataSource?.path
|
||||||
if (!path) return []
|
if (!path) return []
|
||||||
return schemaStore.getArrayItemFields(path)
|
return schemaStore.getArrayItemFields(path)
|
||||||
})
|
})
|
||||||
|
|
||||||
const stringFields = computed(() => itemFields.value.filter(f => f.type === 'string'))
|
const stringFields = computed(() => itemFields.value.filter((f) => f.type === 'string'))
|
||||||
const numberFields = computed(() => itemFields.value.filter(f => f.type === 'number' || f.type === 'integer'))
|
const numberFields = computed(() =>
|
||||||
|
itemFields.value.filter((f) => f.type === 'number' || f.type === 'integer'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const isPie = computed(() => props.element.chartType === 'pie')
|
||||||
|
const hasGroup = computed(() => !!props.element.groupField)
|
||||||
|
|
||||||
|
const colorList = computed(() => {
|
||||||
|
return (
|
||||||
|
props.element.style.colors ?? ['#4F46E5', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899']
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
function updateDataSource(path: string) {
|
function updateDataSource(path: string) {
|
||||||
const fields = schemaStore.getArrayItemFields(path)
|
const fields = schemaStore.getArrayItemFields(path)
|
||||||
const strField = fields.find(f => f.type === 'string')
|
const strField = fields.find((f) => f.type === 'string')
|
||||||
const numField = fields.find(f => f.type === 'number' || f.type === 'integer')
|
const numField = fields.find((f) => f.type === 'number' || f.type === 'integer')
|
||||||
update({
|
update({
|
||||||
dataSource: { type: 'array', path },
|
dataSource: { type: 'array', path },
|
||||||
categoryField: strField?.key ?? fields[0]?.key ?? '',
|
categoryField: strField?.key ?? fields[0]?.key ?? '',
|
||||||
valueField: numField?.key ?? fields[1]?.key ?? '',
|
valueField: numField?.key ?? fields[1]?.key ?? '',
|
||||||
groupField: undefined,
|
groupField: undefined,
|
||||||
})
|
} as any)
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateTitle(key: string, value: unknown) {
|
|
||||||
const current = props.element.title ?? { text: '' }
|
|
||||||
update({ title: { ...current, [key]: value } })
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateLegend(key: string, value: unknown) {
|
|
||||||
const current = props.element.legend ?? { show: false }
|
|
||||||
update({ legend: { ...current, [key]: value } })
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateLabels(key: string, value: unknown) {
|
|
||||||
const current = props.element.labels ?? { show: false }
|
|
||||||
update({ labels: { ...current, [key]: value } })
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateAxis(key: string, value: unknown) {
|
|
||||||
const current = props.element.axis ?? {}
|
|
||||||
update({ axis: { ...current, [key]: value } })
|
|
||||||
}
|
|
||||||
|
|
||||||
const isPie = computed(() => props.element.chartType === 'pie')
|
|
||||||
const hasGroup = computed(() => !!props.element.groupField)
|
|
||||||
|
|
||||||
// Renk paleti (default 6 renk)
|
|
||||||
const colorList = computed(() => {
|
|
||||||
return props.element.style.colors ?? ['#4F46E5', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899']
|
|
||||||
})
|
|
||||||
|
|
||||||
function updateColor(index: number, value: string) {
|
function updateColor(index: number, value: string) {
|
||||||
const colors = [...colorList.value]
|
const colors = [...colorList.value]
|
||||||
colors[index] = value
|
colors[index] = value
|
||||||
@@ -83,8 +77,7 @@ function updateColor(index: number, value: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addColor() {
|
function addColor() {
|
||||||
const colors = [...colorList.value, '#6B7280']
|
updateStyle('colors', [...colorList.value, '#6B7280'])
|
||||||
updateStyle('colors', colors)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeColor(index: number) {
|
function removeColor(index: number) {
|
||||||
@@ -96,191 +89,246 @@ function removeColor(index: number) {
|
|||||||
<template>
|
<template>
|
||||||
<div class="chart-properties">
|
<div class="chart-properties">
|
||||||
<!-- Grafik Tipi -->
|
<!-- Grafik Tipi -->
|
||||||
<div class="prop-section">
|
<PropSection title="Grafik Tipi">
|
||||||
<div class="prop-section__title">Grafik Tipi</div>
|
<PropSelect
|
||||||
<div class="prop-row">
|
label=""
|
||||||
<select class="prop-input prop-select" :value="element.chartType" @change="update({ chartType: ($event.target as HTMLSelectElement).value as ChartType })">
|
:model-value="element.chartType"
|
||||||
<option value="bar">Bar</option>
|
:options="chartTypeOptions"
|
||||||
<option value="line">Line</option>
|
@update:model-value="(v) => update({ chartType: v as ChartType } as any)"
|
||||||
<option value="pie">Pie</option>
|
/>
|
||||||
</select>
|
</PropSection>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Veri Kaynagi -->
|
<!-- Veri Kaynagi -->
|
||||||
<div class="prop-section">
|
<PropSection title="Veri Kaynagi">
|
||||||
<div class="prop-section__title">Veri Kaynagi</div>
|
<PropFieldSelect
|
||||||
<div class="prop-row">
|
label="Array"
|
||||||
<label class="prop-label">Array</label>
|
:model-value="element.dataSource?.path ?? ''"
|
||||||
<select class="prop-input prop-select" :value="element.dataSource?.path ?? ''" @change="updateDataSource(($event.target as HTMLSelectElement).value)">
|
:fields="schemaStore.arrayFields"
|
||||||
<option value="" disabled>Sec...</option>
|
placeholder="Sec..."
|
||||||
<option v-for="arr in arrayFields" :key="arr.path" :value="arr.path">{{ arr.title || arr.path }}</option>
|
@update:model-value="updateDataSource"
|
||||||
</select>
|
/>
|
||||||
</div>
|
<PropFieldSelect
|
||||||
<div class="prop-row">
|
label="Kategori"
|
||||||
<label class="prop-label">Kategori</label>
|
:model-value="element.categoryField"
|
||||||
<select class="prop-input prop-select" :value="element.categoryField" @change="update({ categoryField: ($event.target as HTMLSelectElement).value })">
|
:fields="itemFields"
|
||||||
<option v-for="f in itemFields" :key="f.key" :value="f.key">{{ f.title || f.key }}</option>
|
@update:model-value="(v) => update({ categoryField: v } as any)"
|
||||||
</select>
|
/>
|
||||||
</div>
|
<PropFieldSelect
|
||||||
<div class="prop-row">
|
label="Deger"
|
||||||
<label class="prop-label">Deger</label>
|
:model-value="element.valueField"
|
||||||
<select class="prop-input prop-select" :value="element.valueField" @change="update({ valueField: ($event.target as HTMLSelectElement).value })">
|
:fields="numberFields"
|
||||||
<option v-for="f in numberFields" :key="f.key" :value="f.key">{{ f.title || f.key }}</option>
|
@update:model-value="(v) => update({ valueField: v } as any)"
|
||||||
</select>
|
/>
|
||||||
</div>
|
<PropFieldSelect
|
||||||
<div class="prop-row">
|
label="Gruplama"
|
||||||
<label class="prop-label">Gruplama</label>
|
:model-value="element.groupField ?? ''"
|
||||||
<select class="prop-input prop-select" :value="element.groupField ?? ''" @change="update({ groupField: ($event.target as HTMLSelectElement).value || undefined })">
|
:fields="stringFields"
|
||||||
<option value="">Yok</option>
|
:allow-empty="true"
|
||||||
<option v-for="f in stringFields" :key="f.key" :value="f.key">{{ f.title || f.key }}</option>
|
empty-label="Yok"
|
||||||
</select>
|
@update:model-value="(v) => update({ groupField: v || undefined } as any)"
|
||||||
</div>
|
/>
|
||||||
<div v-if="hasGroup && !isPie" class="prop-row">
|
<PropSelect
|
||||||
<label class="prop-label">Grup Modu</label>
|
v-if="hasGroup && !isPie"
|
||||||
<select class="prop-input prop-select" :value="element.groupMode ?? 'grouped'" @change="update({ groupMode: ($event.target as HTMLSelectElement).value as GroupMode })">
|
label="Grup Modu"
|
||||||
<option value="grouped">Yan Yana</option>
|
:model-value="element.groupMode ?? 'grouped'"
|
||||||
<option value="stacked">Yigin</option>
|
:options="groupModeOptions"
|
||||||
</select>
|
@update:model-value="(v) => update({ groupMode: v as GroupMode } as any)"
|
||||||
</div>
|
/>
|
||||||
</div>
|
</PropSection>
|
||||||
|
|
||||||
<!-- Baslik -->
|
<!-- Baslik -->
|
||||||
<div class="prop-section">
|
<PropSection title="Baslik">
|
||||||
<div class="prop-section__title">Baslik</div>
|
|
||||||
<div class="prop-row">
|
<div class="prop-row">
|
||||||
<label class="prop-label">Metin</label>
|
<label class="prop-label">Metin</label>
|
||||||
<input class="prop-input" type="text" :value="element.title?.text ?? ''" @change="updateTitle('text', ($event.target as HTMLInputElement).value)" placeholder="Grafik basligi">
|
<input
|
||||||
</div>
|
class="prop-input"
|
||||||
<div class="prop-row" v-if="element.title?.text">
|
type="text"
|
||||||
<label class="prop-label">Boyut</label>
|
:value="element.title?.text ?? ''"
|
||||||
<input class="prop-input prop-input--sm" type="number" :value="element.title?.fontSize ?? 4" step="0.5" @change="updateTitle('fontSize', parseFloat(($event.target as HTMLInputElement).value))">
|
@change="(e) => updateNested('title', 'text', (e.target as HTMLInputElement).value, { text: '' })"
|
||||||
</div>
|
placeholder="Grafik basligi"
|
||||||
<div class="prop-row" v-if="element.title?.text">
|
/>
|
||||||
<label class="prop-label">Renk</label>
|
|
||||||
<input class="prop-color" type="color" :value="element.title?.color ?? '#333333'" @input="updateTitle('color', ($event.target as HTMLInputElement).value)">
|
|
||||||
</div>
|
|
||||||
<div class="prop-row" v-if="element.title?.text">
|
|
||||||
<label class="prop-label">Hiza</label>
|
|
||||||
<select class="prop-input prop-select" :value="element.title?.align ?? 'center'" @change="updateTitle('align', ($event.target as HTMLSelectElement).value)">
|
|
||||||
<option value="left">Sol</option>
|
|
||||||
<option value="center">Orta</option>
|
|
||||||
<option value="right">Sag</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<template v-if="element.title?.text">
|
||||||
|
<PropNumberInput
|
||||||
|
label="Boyut"
|
||||||
|
:model-value="element.title?.fontSize ?? 4"
|
||||||
|
:step="0.5"
|
||||||
|
@update:model-value="(v) => updateNested('title', 'fontSize', v, { text: '' })"
|
||||||
|
/>
|
||||||
|
<PropColorInput
|
||||||
|
label="Renk"
|
||||||
|
:model-value="element.title?.color ?? '#333333'"
|
||||||
|
@update:model-value="(v) => updateNested('title', 'color', v, { text: '' })"
|
||||||
|
/>
|
||||||
|
<PropSelect
|
||||||
|
label="Hiza"
|
||||||
|
:model-value="element.title?.align ?? 'center'"
|
||||||
|
:options="alignOptions"
|
||||||
|
@update:model-value="(v) => updateNested('title', 'align', v, { text: '' })"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</PropSection>
|
||||||
|
|
||||||
<!-- Gosterge (Legend) -->
|
<!-- Gosterge (Legend) -->
|
||||||
<div class="prop-section">
|
<PropSection title="Gosterge">
|
||||||
<div class="prop-section__title">Gosterge</div>
|
<PropCheckbox
|
||||||
<div class="prop-row">
|
label="Goster"
|
||||||
<label class="prop-label">Goster</label>
|
:model-value="element.legend?.show ?? false"
|
||||||
<input type="checkbox" :checked="element.legend?.show ?? false" @change="updateLegend('show', ($event.target as HTMLInputElement).checked)">
|
@update:model-value="(v) => updateNested('legend', 'show', v, { show: false })"
|
||||||
</div>
|
/>
|
||||||
<template v-if="element.legend?.show">
|
<template v-if="element.legend?.show">
|
||||||
<div class="prop-row">
|
<PropSelect
|
||||||
<label class="prop-label">Konum</label>
|
label="Konum"
|
||||||
<select class="prop-input prop-select" :value="element.legend?.position ?? 'bottom'" @change="updateLegend('position', ($event.target as HTMLSelectElement).value)">
|
:model-value="element.legend?.position ?? 'bottom'"
|
||||||
<option value="top">Ust</option>
|
:options="legendPositionOptions"
|
||||||
<option value="bottom">Alt</option>
|
@update:model-value="(v) => updateNested('legend', 'position', v)"
|
||||||
<option value="right">Sag</option>
|
/>
|
||||||
</select>
|
<PropNumberInput
|
||||||
</div>
|
label="Boyut"
|
||||||
<div class="prop-row">
|
:model-value="element.legend?.fontSize ?? 2.8"
|
||||||
<label class="prop-label">Boyut</label>
|
:step="0.2"
|
||||||
<input class="prop-input prop-input--sm" type="number" :value="element.legend?.fontSize ?? 2.8" step="0.2" @change="updateLegend('fontSize', parseFloat(($event.target as HTMLInputElement).value))">
|
@update:model-value="(v) => updateNested('legend', 'fontSize', v)"
|
||||||
</div>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</PropSection>
|
||||||
|
|
||||||
<!-- Etiketler -->
|
<!-- Etiketler -->
|
||||||
<div class="prop-section">
|
<PropSection title="Etiketler">
|
||||||
<div class="prop-section__title">Etiketler</div>
|
<PropCheckbox
|
||||||
<div class="prop-row">
|
label="Goster"
|
||||||
<label class="prop-label">Goster</label>
|
:model-value="element.labels?.show ?? false"
|
||||||
<input type="checkbox" :checked="element.labels?.show ?? false" @change="updateLabels('show', ($event.target as HTMLInputElement).checked)">
|
@update:model-value="(v) => updateNested('labels', 'show', v, { show: false })"
|
||||||
</div>
|
/>
|
||||||
<template v-if="element.labels?.show">
|
<template v-if="element.labels?.show">
|
||||||
<div class="prop-row">
|
<PropNumberInput
|
||||||
<label class="prop-label">Boyut</label>
|
label="Boyut"
|
||||||
<input class="prop-input prop-input--sm" type="number" :value="element.labels?.fontSize ?? 2.2" step="0.2" @change="updateLabels('fontSize', parseFloat(($event.target as HTMLInputElement).value))">
|
:model-value="element.labels?.fontSize ?? 2.2"
|
||||||
</div>
|
:step="0.2"
|
||||||
<div class="prop-row">
|
@update:model-value="(v) => updateNested('labels', 'fontSize', v)"
|
||||||
<label class="prop-label">Renk</label>
|
/>
|
||||||
<input class="prop-color" type="color" :value="element.labels?.color ?? '#333333'" @input="updateLabels('color', ($event.target as HTMLInputElement).value)">
|
<PropColorInput
|
||||||
</div>
|
label="Renk"
|
||||||
|
:model-value="element.labels?.color ?? '#333333'"
|
||||||
|
@update:model-value="(v) => updateNested('labels', 'color', v)"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</PropSection>
|
||||||
|
|
||||||
<!-- Eksenler (pie haric) -->
|
<!-- Eksenler (pie haric) -->
|
||||||
<div class="prop-section" v-if="!isPie">
|
<PropSection v-if="!isPie" title="Eksenler">
|
||||||
<div class="prop-section__title">Eksenler</div>
|
|
||||||
<div class="prop-row">
|
<div class="prop-row">
|
||||||
<label class="prop-label">X Etiketi</label>
|
<label class="prop-label">X Etiketi</label>
|
||||||
<input class="prop-input" type="text" :value="element.axis?.xLabel ?? ''" @change="updateAxis('xLabel', ($event.target as HTMLInputElement).value || undefined)" placeholder="X ekseni">
|
<input
|
||||||
|
class="prop-input"
|
||||||
|
type="text"
|
||||||
|
:value="element.axis?.xLabel ?? ''"
|
||||||
|
@change="(e) => updateNested('axis', 'xLabel', (e.target as HTMLInputElement).value || undefined, {})"
|
||||||
|
placeholder="X ekseni"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="prop-row">
|
<div class="prop-row">
|
||||||
<label class="prop-label">Y Etiketi</label>
|
<label class="prop-label">Y Etiketi</label>
|
||||||
<input class="prop-input" type="text" :value="element.axis?.yLabel ?? ''" @change="updateAxis('yLabel', ($event.target as HTMLInputElement).value || undefined)" placeholder="Y ekseni">
|
<input
|
||||||
</div>
|
class="prop-input"
|
||||||
<div class="prop-row">
|
type="text"
|
||||||
<label class="prop-label">Izgara</label>
|
:value="element.axis?.yLabel ?? ''"
|
||||||
<input type="checkbox" :checked="element.axis?.showGrid ?? true" @change="updateAxis('showGrid', ($event.target as HTMLInputElement).checked)">
|
@change="(e) => updateNested('axis', 'yLabel', (e.target as HTMLInputElement).value || undefined, {})"
|
||||||
</div>
|
placeholder="Y ekseni"
|
||||||
<div class="prop-row" v-if="element.axis?.showGrid !== false">
|
/>
|
||||||
<label class="prop-label">Izgara Renk</label>
|
|
||||||
<input class="prop-color" type="color" :value="element.axis?.gridColor ?? '#E5E7EB'" @input="updateAxis('gridColor', ($event.target as HTMLInputElement).value)">
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<PropCheckbox
|
||||||
|
label="Izgara"
|
||||||
|
:model-value="element.axis?.showGrid ?? true"
|
||||||
|
@update:model-value="(v) => updateNested('axis', 'showGrid', v, {})"
|
||||||
|
/>
|
||||||
|
<PropColorInput
|
||||||
|
v-if="element.axis?.showGrid !== false"
|
||||||
|
label="Izgara Renk"
|
||||||
|
:model-value="element.axis?.gridColor ?? '#E5E7EB'"
|
||||||
|
@update:model-value="(v) => updateNested('axis', 'gridColor', v, {})"
|
||||||
|
/>
|
||||||
|
<template v-if="element.chartType === 'line'">
|
||||||
|
<PropCheckbox
|
||||||
|
label="Dikey Izgara"
|
||||||
|
:model-value="element.axis?.showVerticalGrid ?? true"
|
||||||
|
@update:model-value="(v) => updateNested('axis', 'showVerticalGrid', v, {})"
|
||||||
|
/>
|
||||||
|
<PropColorInput
|
||||||
|
v-if="element.axis?.showVerticalGrid !== false"
|
||||||
|
label="Dikey Izgara Renk"
|
||||||
|
:model-value="element.axis?.verticalGridColor ?? '#E5E7EB'"
|
||||||
|
@update:model-value="(v) => updateNested('axis', 'verticalGridColor', v, {})"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</PropSection>
|
||||||
|
|
||||||
<!-- Stil -->
|
<!-- Stil -->
|
||||||
<div class="prop-section">
|
<PropSection title="Stil">
|
||||||
<div class="prop-section__title">Stil</div>
|
<PropColorInput
|
||||||
<div class="prop-row">
|
label="Arka Plan"
|
||||||
<label class="prop-label">Arka Plan</label>
|
:model-value="element.style.backgroundColor ?? '#FFFFFF'"
|
||||||
<input class="prop-color" type="color" :value="element.style.backgroundColor ?? '#FFFFFF'" @input="updateStyle('backgroundColor', ($event.target as HTMLInputElement).value)">
|
@update:model-value="(v) => updateStyle('backgroundColor', v)"
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<!-- Renk Paleti -->
|
|
||||||
<div class="prop-section__subtitle">Renk Paleti</div>
|
<div class="prop-section__subtitle">Renk Paleti</div>
|
||||||
<div v-for="(color, i) in colorList" :key="i" class="prop-row">
|
<div v-for="(color, i) in colorList" :key="i" class="prop-row">
|
||||||
<input class="prop-color" type="color" :value="color" @input="updateColor(i, ($event.target as HTMLInputElement).value)">
|
<input
|
||||||
<button class="prop-btn-sm prop-btn-sm--danger" @click="removeColor(i)" title="Kaldir">×</button>
|
class="prop-color"
|
||||||
|
type="color"
|
||||||
|
:value="color"
|
||||||
|
@input="(e) => updateColor(i, (e.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
<button class="prop-btn-sm prop-btn-sm--danger" @click="removeColor(i)" title="Kaldir">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="prop-btn-sm" @click="addColor">+ Renk Ekle</button>
|
<button class="prop-btn-sm" @click="addColor">+ Renk Ekle</button>
|
||||||
</div>
|
</PropSection>
|
||||||
|
|
||||||
<!-- Tipe Ozel -->
|
<!-- Tipe Ozel -->
|
||||||
<div class="prop-section" v-if="element.chartType === 'bar'">
|
<PropSection v-if="element.chartType === 'bar'" title="Bar Ayarlari">
|
||||||
<div class="prop-section__title">Bar Ayarlari</div>
|
<PropNumberInput
|
||||||
<div class="prop-row">
|
label="Bar Boslugu"
|
||||||
<label class="prop-label">Bar Boslugu</label>
|
:model-value="element.style.barGap ?? 0.2"
|
||||||
<input class="prop-input prop-input--sm" type="number" :value="element.style.barGap ?? 0.2" step="0.05" min="0" max="0.8" @change="updateStyle('barGap', parseFloat(($event.target as HTMLInputElement).value))">
|
:step="0.05"
|
||||||
</div>
|
:min="0"
|
||||||
</div>
|
:max="0.8"
|
||||||
|
@update:model-value="(v) => updateStyle('barGap', v)"
|
||||||
|
/>
|
||||||
|
</PropSection>
|
||||||
|
|
||||||
<div class="prop-section" v-if="element.chartType === 'line'">
|
<PropSection v-if="element.chartType === 'line'" title="Line Ayarlari">
|
||||||
<div class="prop-section__title">Line Ayarlari</div>
|
<PropNumberInput
|
||||||
<div class="prop-row">
|
label="Cizgi Kalinligi"
|
||||||
<label class="prop-label">Cizgi Kalinligi</label>
|
:model-value="element.style.lineWidth ?? 0.5"
|
||||||
<input class="prop-input prop-input--sm" type="number" :value="element.style.lineWidth ?? 0.5" step="0.1" min="0.1" @change="updateStyle('lineWidth', parseFloat(($event.target as HTMLInputElement).value))">
|
:step="0.1"
|
||||||
</div>
|
:min="0.1"
|
||||||
<div class="prop-row">
|
@update:model-value="(v) => updateStyle('lineWidth', v)"
|
||||||
<label class="prop-label">Noktalar</label>
|
/>
|
||||||
<input type="checkbox" :checked="element.style.showPoints ?? true" @change="updateStyle('showPoints', ($event.target as HTMLInputElement).checked)">
|
<PropSelect
|
||||||
</div>
|
label="Egri Tipi"
|
||||||
</div>
|
:model-value="element.style.curveType ?? 'linear'"
|
||||||
|
:options="[{ value: 'linear', label: 'Duz' }, { value: 'smooth', label: 'Yumusak' }]"
|
||||||
|
@update:model-value="(v) => updateStyle('curveType', v)"
|
||||||
|
/>
|
||||||
|
<PropCheckbox
|
||||||
|
label="Noktalar"
|
||||||
|
:model-value="element.style.showPoints ?? true"
|
||||||
|
@update:model-value="(v) => updateStyle('showPoints', v)"
|
||||||
|
/>
|
||||||
|
</PropSection>
|
||||||
|
|
||||||
<div class="prop-section" v-if="element.chartType === 'pie'">
|
<PropSection v-if="element.chartType === 'pie'" title="Pie Ayarlari">
|
||||||
<div class="prop-section__title">Pie Ayarlari</div>
|
<PropNumberInput
|
||||||
<div class="prop-row">
|
label="Ic Yaricap"
|
||||||
<label class="prop-label">Ic Yaricap</label>
|
:model-value="element.style.innerRadius ?? 0"
|
||||||
<input class="prop-input prop-input--sm" type="number" :value="element.style.innerRadius ?? 0" step="0.05" min="0" max="0.9" @change="updateStyle('innerRadius', parseFloat(($event.target as HTMLInputElement).value))">
|
:step="0.05"
|
||||||
</div>
|
:min="0"
|
||||||
<div class="prop-row" style="font-size: 11px; color: #94a3b8;">
|
:max="0.9"
|
||||||
0 = Pie, >0 = Donut
|
@update:model-value="(v) => updateStyle('innerRadius', v)"
|
||||||
</div>
|
/>
|
||||||
</div>
|
<div class="prop-row" style="font-size: 11px; color: #94a3b8">0 = Pie, >0 = Donut</div>
|
||||||
|
</PropSection>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,50 +1,75 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useTemplateStore } from '../../stores/template'
|
import { computed } from 'vue'
|
||||||
import { useEditorStore } from '../../stores/editor'
|
import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
|
||||||
import type { CheckboxElement, TemplateElement } from '../../core/types'
|
import { useSchemaStore } from '../../stores/schema'
|
||||||
|
import PropSection from './shared/PropSection.vue'
|
||||||
|
import PropNumberInput from './shared/PropNumberInput.vue'
|
||||||
|
import PropColorInput from './shared/PropColorInput.vue'
|
||||||
|
import PropCheckbox from './shared/PropCheckbox.vue'
|
||||||
|
import PropFieldSelect from './shared/PropFieldSelect.vue'
|
||||||
|
import type { CheckboxElement } from '../../core/types'
|
||||||
import '../../styles/properties.css'
|
import '../../styles/properties.css'
|
||||||
|
|
||||||
const props = defineProps<{ element: CheckboxElement }>()
|
const props = defineProps<{ element: CheckboxElement }>()
|
||||||
const templateStore = useTemplateStore()
|
const { update, updateStyle } = usePropertyUpdate(() => props.element)
|
||||||
const editorStore = useEditorStore()
|
const schemaStore = useSchemaStore()
|
||||||
|
|
||||||
function update(updates: Partial<TemplateElement>) {
|
const booleanFields = computed(() =>
|
||||||
const id = editorStore.selectedElementId
|
schemaStore.scalarFields.filter((f) => f.type === 'boolean' || f.type === 'string'),
|
||||||
if (!id) return
|
)
|
||||||
templateStore.updateElement(id, updates)
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateStyle(key: string, value: unknown) {
|
|
||||||
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="prop-section">
|
<PropSection title="Onay Kutusu">
|
||||||
<div class="prop-section__title">Onay Kutusu</div>
|
<PropFieldSelect
|
||||||
<div v-if="!element.binding" class="prop-row" data-tip="Onay kutusunun varsayilan durumu">
|
label="Veri Alani"
|
||||||
<label class="prop-label">Isaretli</label>
|
:model-value="element.binding?.path ?? ''"
|
||||||
<input type="checkbox"
|
:fields="booleanFields"
|
||||||
:checked="element.checked ?? false"
|
:allow-empty="true"
|
||||||
@change="(e) => update({ checked: (e.target as HTMLInputElement).checked } as any)" />
|
empty-label="Yok (statik)"
|
||||||
</div>
|
data-tip="Onay durumunun gelecegi veri alani"
|
||||||
<div class="prop-row" data-tip="Onay kutusu boyutu (mm)">
|
@update:model-value="
|
||||||
<label class="prop-label">Boyut (mm)</label>
|
(v) =>
|
||||||
<input class="prop-input" type="number" step="0.5" min="1"
|
update({
|
||||||
:value="element.style.size ?? 4"
|
binding: v ? { type: 'scalar', path: v } : undefined,
|
||||||
@input="(e) => updateStyle('size', parseFloat((e.target as HTMLInputElement).value) || 4)" />
|
checked: v ? undefined : element.checked ?? false,
|
||||||
</div>
|
} as any)
|
||||||
<div class="prop-row" data-tip="Isaret (tik) rengi">
|
"
|
||||||
<label class="prop-label">Isaret Rengi</label>
|
/>
|
||||||
<input class="prop-input prop-color" type="color"
|
<PropCheckbox
|
||||||
:value="element.style.checkColor ?? '#000000'"
|
v-if="!element.binding"
|
||||||
@input="(e) => updateStyle('checkColor', (e.target as HTMLInputElement).value)" />
|
label="Isaretli"
|
||||||
</div>
|
:model-value="element.checked ?? false"
|
||||||
<div class="prop-row" data-tip="Kutu kenarlik rengi">
|
data-tip="Onay kutusunun varsayilan durumu"
|
||||||
<label class="prop-label">Kenar Rengi</label>
|
@update:model-value="(v) => update({ checked: v } as any)"
|
||||||
<input class="prop-input prop-color" type="color"
|
/>
|
||||||
:value="element.style.borderColor ?? '#333333'"
|
<PropNumberInput
|
||||||
@input="(e) => updateStyle('borderColor', (e.target as HTMLInputElement).value)" />
|
label="Boyut (mm)"
|
||||||
</div>
|
:model-value="element.style.size ?? 4"
|
||||||
</div>
|
:step="0.5"
|
||||||
|
:min="1"
|
||||||
|
data-tip="Onay kutusu boyutu (mm)"
|
||||||
|
@update:model-value="(v) => updateStyle('size', v)"
|
||||||
|
/>
|
||||||
|
<PropColorInput
|
||||||
|
label="Isaret Rengi"
|
||||||
|
:model-value="element.style.checkColor ?? '#000000'"
|
||||||
|
data-tip="Isaret (tik) rengi"
|
||||||
|
@update:model-value="(v) => updateStyle('checkColor', v)"
|
||||||
|
/>
|
||||||
|
<PropColorInput
|
||||||
|
label="Kenar Rengi"
|
||||||
|
:model-value="element.style.borderColor ?? '#333333'"
|
||||||
|
data-tip="Kutu kenarlik rengi"
|
||||||
|
@update:model-value="(v) => updateStyle('borderColor', v)"
|
||||||
|
/>
|
||||||
|
<PropNumberInput
|
||||||
|
label="Kenar Kalinligi"
|
||||||
|
:model-value="element.style.borderWidth ?? 0.3"
|
||||||
|
:step="0.1"
|
||||||
|
:min="0"
|
||||||
|
data-tip="Kutu kenarlik kalinligi (mm)"
|
||||||
|
@update:model-value="(v) => updateStyle('borderWidth', v)"
|
||||||
|
/>
|
||||||
|
</PropSection>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,48 +1,60 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useTemplateStore } from '../../stores/template'
|
import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
|
||||||
import { useEditorStore } from '../../stores/editor'
|
import PropSection from './shared/PropSection.vue'
|
||||||
|
import PropSelect from './shared/PropSelect.vue'
|
||||||
|
import PropNumberInput from './shared/PropNumberInput.vue'
|
||||||
|
import PropColorInput from './shared/PropColorInput.vue'
|
||||||
import PaddingBox from './PaddingBox.vue'
|
import PaddingBox from './PaddingBox.vue'
|
||||||
import type { ContainerElement, TemplateElement } from '../../core/types'
|
import type { ContainerElement } from '../../core/types'
|
||||||
import '../../styles/properties.css'
|
import '../../styles/properties.css'
|
||||||
|
|
||||||
const props = defineProps<{ element: ContainerElement }>()
|
const props = defineProps<{ element: ContainerElement }>()
|
||||||
const templateStore = useTemplateStore()
|
const { update, updateStyle } = usePropertyUpdate(() => props.element)
|
||||||
const editorStore = useEditorStore()
|
|
||||||
|
|
||||||
function update(updates: Partial<TemplateElement>) {
|
const directionOptions = [
|
||||||
const id = editorStore.selectedElementId
|
{ value: 'column', label: 'Dikey' },
|
||||||
if (!id) return
|
{ value: 'row', label: 'Yatay' },
|
||||||
templateStore.updateElement(id, updates)
|
]
|
||||||
}
|
|
||||||
|
|
||||||
function updateStyle(key: string, value: unknown) {
|
const breakOptions = [
|
||||||
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>)
|
{ value: 'auto', label: 'Izin Ver' },
|
||||||
}
|
{ value: 'avoid', label: 'Bolme' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const borderStyleOptions = [
|
||||||
|
{ value: 'solid', label: 'Duz' },
|
||||||
|
{ value: 'dashed', label: 'Kesikli' },
|
||||||
|
{ value: 'dotted', label: 'Noktali' },
|
||||||
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="prop-section">
|
<PropSection title="Container Ayarlari">
|
||||||
<div class="prop-section__title">Container Ayarlari</div>
|
<PropSelect
|
||||||
<div class="prop-row" data-tip="Cocuk elemanlarin dizilim yonu">
|
label="Yon"
|
||||||
<label class="prop-label">Yon</label>
|
:model-value="element.direction"
|
||||||
<select class="prop-input prop-select"
|
:options="directionOptions"
|
||||||
:value="element.direction"
|
data-tip="Cocuk elemanlarin dizilim yonu"
|
||||||
@change="(e) => update({ direction: (e.target as HTMLSelectElement).value } as any)">
|
@update:model-value="(v) => update({ direction: v } as any)"
|
||||||
<option value="column">Dikey</option>
|
/>
|
||||||
<option value="row">Yatay</option>
|
<PropNumberInput
|
||||||
</select>
|
label="Bosluk (mm)"
|
||||||
</div>
|
:model-value="element.gap"
|
||||||
<div class="prop-row" data-tip="Cocuk elemanlar arasi bosluk (mm)">
|
:step="1"
|
||||||
<label class="prop-label">Bosluk (mm)</label>
|
:min="0"
|
||||||
<input class="prop-input" type="number" step="1" min="0"
|
data-tip="Cocuk elemanlar arasi bosluk (mm)"
|
||||||
:value="element.gap"
|
@update:model-value="(v) => update({ gap: v } as any)"
|
||||||
@input="(e) => update({ gap: parseFloat((e.target as HTMLInputElement).value) || 0 } as any)" />
|
/>
|
||||||
</div>
|
|
||||||
<div class="prop-row" data-tip="Cocuklarin cross-axis hizalamasi">
|
<div class="prop-row" data-tip="Cocuklarin cross-axis hizalamasi">
|
||||||
<label class="prop-label">{{ element.direction === 'column' ? 'Yatay Hizalama' : 'Dikey Hizalama' }}</label>
|
<label class="prop-label">{{
|
||||||
<select class="prop-input prop-select"
|
element.direction === 'column' ? 'Yatay Hizalama' : 'Dikey Hizalama'
|
||||||
|
}}</label>
|
||||||
|
<select
|
||||||
|
class="prop-input prop-select"
|
||||||
:value="element.align"
|
:value="element.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">{{ element.direction === 'column' ? 'Sol' : 'Ust' }}</option>
|
<option value="start">{{ element.direction === 'column' ? 'Sol' : 'Ust' }}</option>
|
||||||
<option value="center">Orta</option>
|
<option value="center">Orta</option>
|
||||||
<option value="end">{{ element.direction === 'column' ? 'Sag' : 'Alt' }}</option>
|
<option value="end">{{ element.direction === 'column' ? 'Sag' : 'Alt' }}</option>
|
||||||
@@ -50,10 +62,14 @@ function updateStyle(key: string, value: unknown) {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="prop-row" data-tip="Cocuklarin main-axis dagilimi">
|
<div class="prop-row" data-tip="Cocuklarin main-axis dagilimi">
|
||||||
<label class="prop-label">{{ element.direction === 'column' ? 'Dikey Dagilim' : 'Yatay Dagilim' }}</label>
|
<label class="prop-label">{{
|
||||||
<select class="prop-input prop-select"
|
element.direction === 'column' ? 'Dikey Dagilim' : 'Yatay Dagilim'
|
||||||
|
}}</label>
|
||||||
|
<select
|
||||||
|
class="prop-input prop-select"
|
||||||
:value="element.justify"
|
:value="element.justify"
|
||||||
@change="(e) => update({ justify: (e.target as HTMLSelectElement).value } as any)">
|
@change="(e) => update({ justify: (e.target as HTMLSelectElement).value } as any)"
|
||||||
|
>
|
||||||
<option value="start">{{ element.direction === 'column' ? 'Ust' : 'Sol' }}</option>
|
<option value="start">{{ element.direction === 'column' ? 'Ust' : 'Sol' }}</option>
|
||||||
<option value="center">Orta</option>
|
<option value="center">Orta</option>
|
||||||
<option value="end">{{ element.direction === 'column' ? 'Alt' : 'Sag' }}</option>
|
<option value="end">{{ element.direction === 'column' ? 'Alt' : 'Sag' }}</option>
|
||||||
@@ -70,56 +86,53 @@ function updateStyle(key: string, value: unknown) {
|
|||||||
@update="(side, value) => update({ padding: { ...element.padding, [side]: value } } as any)"
|
@update="(side, value) => update({ padding: { ...element.padding, [side]: value } } as any)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="prop-row" data-tip="Sayfa sonunda bolunmeyi kontrol eder">
|
<PropSelect
|
||||||
<label class="prop-label">Sayfa Bolme</label>
|
label="Sayfa Bolme"
|
||||||
<select class="prop-input prop-select"
|
:model-value="element.breakInside ?? 'auto'"
|
||||||
:value="element.breakInside ?? 'auto'"
|
:options="breakOptions"
|
||||||
@change="(e) => update({ breakInside: (e.target as HTMLSelectElement).value } as any)">
|
data-tip="Sayfa sonunda bolunmeyi kontrol eder"
|
||||||
<option value="auto">Izin Ver</option>
|
@update:model-value="(v) => update({ breakInside: v } as any)"
|
||||||
<option value="avoid">Bolme</option>
|
/>
|
||||||
</select>
|
</PropSection>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="prop-section__subtitle">Stil</div>
|
<PropSection title="Stil">
|
||||||
<div class="prop-row" data-tip="Container arka plan rengi">
|
<PropColorInput
|
||||||
<label class="prop-label">Arka plan</label>
|
label="Arka plan"
|
||||||
<div class="prop-row-inline">
|
:model-value="element.style.backgroundColor"
|
||||||
<input class="prop-input prop-color" type="color"
|
default-color="#ffffff"
|
||||||
:value="element.style.backgroundColor ?? '#ffffff'"
|
:clearable="true"
|
||||||
@input="(e) => updateStyle('backgroundColor', (e.target as HTMLInputElement).value)" />
|
data-tip="Container arka plan rengi"
|
||||||
<button v-if="element.style.backgroundColor" class="prop-clear" @click="updateStyle('backgroundColor', undefined)">x</button>
|
@update:model-value="(v) => updateStyle('backgroundColor', v)"
|
||||||
</div>
|
/>
|
||||||
</div>
|
<PropNumberInput
|
||||||
<div class="prop-row" data-tip="Kenarlik kalinligi (mm)">
|
label="Kenarlik (mm)"
|
||||||
<label class="prop-label">Kenarlik (mm)</label>
|
:model-value="element.style.borderWidth ?? 0"
|
||||||
<input class="prop-input" type="number" step="0.1" min="0"
|
:step="0.1"
|
||||||
:value="element.style.borderWidth ?? 0"
|
:min="0"
|
||||||
@input="(e) => updateStyle('borderWidth', parseFloat((e.target as HTMLInputElement).value) || 0)" />
|
data-tip="Kenarlik kalinligi (mm)"
|
||||||
</div>
|
@update:model-value="(v) => updateStyle('borderWidth', v)"
|
||||||
<div class="prop-row" data-tip="Kenarlik cizgisi rengi">
|
/>
|
||||||
<label class="prop-label">Kenarlik rengi</label>
|
<PropColorInput
|
||||||
<div class="prop-row-inline">
|
label="Kenarlik rengi"
|
||||||
<input class="prop-input prop-color" type="color"
|
:model-value="element.style.borderColor"
|
||||||
:value="element.style.borderColor ?? '#000000'"
|
:clearable="true"
|
||||||
@input="(e) => updateStyle('borderColor', (e.target as HTMLInputElement).value)" />
|
data-tip="Kenarlik cizgisi rengi"
|
||||||
<button v-if="element.style.borderColor" class="prop-clear" @click="updateStyle('borderColor', undefined)">x</button>
|
@update:model-value="(v) => updateStyle('borderColor', v)"
|
||||||
</div>
|
/>
|
||||||
</div>
|
<PropSelect
|
||||||
<div class="prop-row" data-tip="Kenarlik cizgi stili">
|
label="Kenarlik stili"
|
||||||
<label class="prop-label">Kenarlik stili</label>
|
:model-value="element.style.borderStyle ?? 'solid'"
|
||||||
<select class="prop-input prop-select"
|
:options="borderStyleOptions"
|
||||||
:value="element.style.borderStyle ?? 'solid'"
|
data-tip="Kenarlik cizgi stili"
|
||||||
@change="(e) => updateStyle('borderStyle', (e.target as HTMLSelectElement).value)">
|
@update:model-value="(v) => updateStyle('borderStyle', v)"
|
||||||
<option value="solid">Duz</option>
|
/>
|
||||||
<option value="dashed">Kesikli</option>
|
<PropNumberInput
|
||||||
<option value="dotted">Noktali</option>
|
label="Radius (mm)"
|
||||||
</select>
|
:model-value="element.style.borderRadius ?? 0"
|
||||||
</div>
|
:step="0.5"
|
||||||
<div class="prop-row" data-tip="Kose yuvarlakligi (mm)">
|
:min="0"
|
||||||
<label class="prop-label">Radius (mm)</label>
|
data-tip="Kose yuvarlakligi (mm)"
|
||||||
<input class="prop-input" type="number" step="0.5" min="0"
|
@update:model-value="(v) => updateStyle('borderRadius', v)"
|
||||||
:value="element.style.borderRadius ?? 0"
|
/>
|
||||||
@input="(e) => updateStyle('borderRadius', parseFloat((e.target as HTMLInputElement).value) || 0)" />
|
</PropSection>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,59 +1,43 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useTemplateStore } from '../../stores/template'
|
import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
|
||||||
import { useEditorStore } from '../../stores/editor'
|
import PropSection from './shared/PropSection.vue'
|
||||||
import type { CurrentDateElement, TextStyle, TemplateElement } from '../../core/types'
|
import PropSelect from './shared/PropSelect.vue'
|
||||||
|
import PropTextStyleGroup from './shared/PropTextStyleGroup.vue'
|
||||||
|
import type { CurrentDateElement, TextStyle } from '../../core/types'
|
||||||
import '../../styles/properties.css'
|
import '../../styles/properties.css'
|
||||||
|
|
||||||
const props = defineProps<{ element: CurrentDateElement }>()
|
const props = defineProps<{ element: CurrentDateElement }>()
|
||||||
const templateStore = useTemplateStore()
|
const { update, updateStyle } = usePropertyUpdate(() => props.element)
|
||||||
const editorStore = useEditorStore()
|
const style = () => props.element.style as TextStyle
|
||||||
|
|
||||||
function update(updates: Partial<TemplateElement>) {
|
const formatOptions = [
|
||||||
const id = editorStore.selectedElementId
|
{ value: 'DD.MM.YYYY', label: '30.03.2026' },
|
||||||
if (!id) return
|
{ value: 'DD/MM/YYYY', label: '30/03/2026' },
|
||||||
templateStore.updateElement(id, updates)
|
{ value: 'YYYY-MM-DD', label: '2026-03-30' },
|
||||||
}
|
{ value: 'DD.MM.YYYY HH:mm', label: '30.03.2026 14:30' },
|
||||||
|
]
|
||||||
function updateStyle(key: string, value: unknown) {
|
|
||||||
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="prop-section">
|
<PropSection title="Tarih">
|
||||||
<div class="prop-section__title">Tarih</div>
|
<PropSelect
|
||||||
<div class="prop-row" data-tip="Tarih gosterim formati">
|
label="Format"
|
||||||
<label class="prop-label">Format</label>
|
:model-value="element.format ?? 'DD.MM.YYYY'"
|
||||||
<select class="prop-input prop-select"
|
:options="formatOptions"
|
||||||
:value="element.format ?? 'DD.MM.YYYY'"
|
data-tip="Tarih gosterim formati"
|
||||||
@change="(e) => update({ format: (e.target as HTMLSelectElement).value } as any)">
|
@update:model-value="(v) => update({ format: v } as any)"
|
||||||
<option value="DD.MM.YYYY">30.03.2026</option>
|
/>
|
||||||
<option value="DD/MM/YYYY">30/03/2026</option>
|
<PropTextStyleGroup
|
||||||
<option value="YYYY-MM-DD">2026-03-30</option>
|
:font-size="style().fontSize ?? 10"
|
||||||
<option value="DD.MM.YYYY HH:mm">30.03.2026 14:30</option>
|
:font-weight="style().fontWeight ?? 'normal'"
|
||||||
</select>
|
:font-family="style().fontFamily"
|
||||||
</div>
|
:color="style().color ?? '#666666'"
|
||||||
<div class="prop-row" data-tip="Yazi tipi boyutu (point)">
|
:align="style().align ?? 'left'"
|
||||||
<label class="prop-label">Boyut (pt)</label>
|
@update:font-size="(v) => updateStyle('fontSize', v)"
|
||||||
<input class="prop-input" type="number" step="1" min="1"
|
@update:font-weight="(v) => updateStyle('fontWeight', v)"
|
||||||
:value="(element.style as TextStyle).fontSize ?? 10"
|
@update:font-family="(v) => updateStyle('fontFamily', v)"
|
||||||
@input="(e) => updateStyle('fontSize', parseFloat((e.target as HTMLInputElement).value) || 10)" />
|
@update:color="(v) => updateStyle('color', v)"
|
||||||
</div>
|
@update:align="(v) => updateStyle('align', v)"
|
||||||
<div class="prop-row" data-tip="Metin rengi">
|
/>
|
||||||
<label class="prop-label">Renk</label>
|
</PropSection>
|
||||||
<input class="prop-input prop-color" type="color"
|
|
||||||
:value="(element.style as TextStyle).color ?? '#666666'"
|
|
||||||
@input="(e) => updateStyle('color', (e.target as HTMLInputElement).value)" />
|
|
||||||
</div>
|
|
||||||
<div class="prop-row" data-tip="Metnin yatay hizalamasi">
|
|
||||||
<label class="prop-label">Hizalama</label>
|
|
||||||
<select class="prop-input prop-select"
|
|
||||||
:value="(element.style as TextStyle).align ?? 'left'"
|
|
||||||
@change="(e) => updateStyle('align', (e.target as HTMLSelectElement).value)">
|
|
||||||
<option value="left">Sol</option>
|
|
||||||
<option value="center">Orta</option>
|
|
||||||
<option value="right">Sag</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useTemplateStore } from '../../stores/template'
|
import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
|
||||||
import { useEditorStore } from '../../stores/editor'
|
|
||||||
import { useSchemaStore } from '../../stores/schema'
|
import { useSchemaStore } from '../../stores/schema'
|
||||||
import type { ImageElement, TemplateElement } from '../../core/types'
|
import PropSection from './shared/PropSection.vue'
|
||||||
|
import PropSelect from './shared/PropSelect.vue'
|
||||||
|
import PropFieldSelect from './shared/PropFieldSelect.vue'
|
||||||
|
import type { ImageElement } from '../../core/types'
|
||||||
import '../../styles/properties.css'
|
import '../../styles/properties.css'
|
||||||
|
|
||||||
const props = defineProps<{ element: ImageElement }>()
|
const props = defineProps<{ element: ImageElement }>()
|
||||||
const templateStore = useTemplateStore()
|
const { update, updateStyle } = usePropertyUpdate(() => props.element)
|
||||||
const editorStore = useEditorStore()
|
|
||||||
const schemaStore = useSchemaStore()
|
const schemaStore = useSchemaStore()
|
||||||
|
|
||||||
/** Statik mi dinamik mi? */
|
|
||||||
const isDynamic = computed(() => !!props.element.binding)
|
const isDynamic = computed(() => !!props.element.binding)
|
||||||
|
|
||||||
function update(updates: Partial<TemplateElement>) {
|
const imageScalarFields = computed(() =>
|
||||||
const id = editorStore.selectedElementId
|
schemaStore.scalarFields.filter((f) => f.format === 'image' || f.type === 'string'),
|
||||||
if (!id) return
|
)
|
||||||
templateStore.updateElement(id, updates)
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateStyle(key: string, value: unknown) {
|
const fitOptions = [
|
||||||
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>)
|
{ value: 'contain', label: 'Sigdir' },
|
||||||
}
|
{ value: 'cover', label: 'Kap' },
|
||||||
|
{ value: 'stretch', label: 'Esnet' },
|
||||||
|
]
|
||||||
|
|
||||||
function onImageFileSelect(e: Event) {
|
function onImageFileSelect(e: Event) {
|
||||||
const input = e.target as HTMLInputElement
|
const input = e.target as HTMLInputElement
|
||||||
@@ -30,46 +30,43 @@ function onImageFileSelect(e: Event) {
|
|||||||
if (!file) return
|
if (!file) return
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
update({ src: reader.result as string, binding: undefined } as Partial<TemplateElement>)
|
update({ src: reader.result as string, binding: undefined } as any)
|
||||||
}
|
}
|
||||||
reader.readAsDataURL(file)
|
reader.readAsDataURL(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setMode(mode: 'static' | 'dynamic') {
|
function setMode(mode: 'static' | 'dynamic') {
|
||||||
if (mode === 'static') {
|
if (mode === 'static') {
|
||||||
update({ binding: undefined } as Partial<TemplateElement>)
|
update({ binding: undefined } as any)
|
||||||
} else {
|
} else {
|
||||||
// Dinamik moda geç — ilk uygun alanı seç veya boş bırak
|
const path = imageScalarFields.value.length > 0 ? imageScalarFields.value[0].path : ''
|
||||||
const imageFields = schemaStore.scalarFields.filter(f => f.format === 'image' || f.type === 'string')
|
update({ src: undefined, binding: { type: 'scalar', path } } as any)
|
||||||
const path = imageFields.length > 0 ? imageFields[0].path : ''
|
|
||||||
update({ src: undefined, binding: { type: 'scalar', path } } as Partial<TemplateElement>)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setBindingPath(path: string) {
|
|
||||||
update({ binding: { type: 'scalar', path } } as Partial<TemplateElement>)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Schema'dan görsel olabilecek alanlar (format: image veya string) */
|
|
||||||
const imageScalarFields = computed(() => {
|
|
||||||
return schemaStore.scalarFields.filter(f => f.format === 'image' || f.type === 'string')
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="prop-section">
|
<PropSection title="Gorsel">
|
||||||
<div class="prop-section__title">Gorsel</div>
|
<div class="prop-row" data-tip="Gorsel kaynagi: dosya veya veri alanindan">
|
||||||
|
|
||||||
<!-- Statik / Dinamik toggle -->
|
|
||||||
<div class="prop-row" data-tip="Gorsel kaynagi: dosya veya veri alanından">
|
|
||||||
<label class="prop-label">Mod</label>
|
<label class="prop-label">Mod</label>
|
||||||
<div class="prop-toggle-group">
|
<div class="prop-toggle-group">
|
||||||
<button class="prop-toggle-btn" :class="{ 'prop-toggle-btn--active': !isDynamic }" @click="setMode('static')">Statik</button>
|
<button
|
||||||
<button class="prop-toggle-btn" :class="{ 'prop-toggle-btn--active': isDynamic }" @click="setMode('dynamic')">Dinamik</button>
|
class="prop-toggle-btn"
|
||||||
|
:class="{ 'prop-toggle-btn--active': !isDynamic }"
|
||||||
|
@click="setMode('static')"
|
||||||
|
>
|
||||||
|
Statik
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="prop-toggle-btn"
|
||||||
|
:class="{ 'prop-toggle-btn--active': isDynamic }"
|
||||||
|
@click="setMode('dynamic')"
|
||||||
|
>
|
||||||
|
Dinamik
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Statik: dosya seçimi -->
|
|
||||||
<template v-if="!isDynamic">
|
<template v-if="!isDynamic">
|
||||||
<div class="prop-row" data-tip="Gorsel dosyasi secin (PNG, JPG, SVG)">
|
<div class="prop-row" data-tip="Gorsel dosyasi secin (PNG, JPG, SVG)">
|
||||||
<label class="prop-label">Kaynak</label>
|
<label class="prop-label">Kaynak</label>
|
||||||
@@ -84,43 +81,34 @@ const imageScalarFields = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="element.src" class="prop-row" data-tip="Gorseli kaldirmak icin tiklayin">
|
<div v-if="element.src" class="prop-row" data-tip="Gorseli kaldirmak icin tiklayin">
|
||||||
<label class="prop-label"></label>
|
<label class="prop-label"></label>
|
||||||
<button class="prop-clear" @click="update({ src: undefined } as any)">Gorseli kaldir</button>
|
<button class="prop-clear" @click="update({ src: undefined } as any)">
|
||||||
|
Gorseli kaldir
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Dinamik: schema alan seçimi -->
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="prop-row" data-tip="Gorsel URL'sinin gelecegi veri alani">
|
<PropFieldSelect
|
||||||
<label class="prop-label">Veri Alani</label>
|
label="Veri Alani"
|
||||||
<select class="prop-input prop-select"
|
:model-value="element.binding?.path ?? ''"
|
||||||
:value="element.binding?.path ?? ''"
|
:fields="imageScalarFields"
|
||||||
@change="(e) => setBindingPath((e.target as HTMLSelectElement).value)">
|
data-tip="Gorsel URL'sinin gelecegi veri alani"
|
||||||
<option value="" disabled>Secin...</option>
|
@update:model-value="(v) => update({ binding: { type: 'scalar', path: v } } as any)"
|
||||||
<option
|
/>
|
||||||
v-for="field in imageScalarFields"
|
|
||||||
:key="field.path"
|
|
||||||
:value="field.path"
|
|
||||||
>{{ field.title }} ({{ field.path }})</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div v-if="element.binding?.path" class="prop-row">
|
<div v-if="element.binding?.path" class="prop-row">
|
||||||
<label class="prop-label">Path</label>
|
<label class="prop-label">Path</label>
|
||||||
<span class="prop-info">{{ element.binding.path }}</span>
|
<span class="prop-info">{{ element.binding.path }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Sığdırma modu (ortak) -->
|
<PropSelect
|
||||||
<div class="prop-row" data-tip="Gorselin alana sigdirma modu">
|
label="Sigdirma"
|
||||||
<label class="prop-label">Sigdirma</label>
|
:model-value="element.style.objectFit ?? 'contain'"
|
||||||
<select class="prop-input prop-select"
|
:options="fitOptions"
|
||||||
:value="element.style.objectFit ?? 'contain'"
|
data-tip="Gorselin alana sigdirma modu"
|
||||||
@change="(e) => updateStyle('objectFit', (e.target as HTMLSelectElement).value)">
|
@update:model-value="(v) => updateStyle('objectFit', v)"
|
||||||
<option value="contain">Sigdir</option>
|
/>
|
||||||
<option value="cover">Kap</option>
|
</PropSection>
|
||||||
<option value="stretch">Esnet</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -137,7 +125,9 @@ const imageScalarFields = computed(() => {
|
|||||||
color: #64748b;
|
color: #64748b;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.1s, color 0.1s;
|
transition:
|
||||||
|
background 0.1s,
|
||||||
|
color 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prop-toggle-btn:first-child {
|
.prop-toggle-btn:first-child {
|
||||||
|
|||||||
@@ -1,34 +1,30 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useTemplateStore } from '../../stores/template'
|
import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
|
||||||
import { useEditorStore } from '../../stores/editor'
|
import PropSection from './shared/PropSection.vue'
|
||||||
import type { LineElement, TemplateElement } from '../../core/types'
|
import PropNumberInput from './shared/PropNumberInput.vue'
|
||||||
|
import PropColorInput from './shared/PropColorInput.vue'
|
||||||
|
import type { LineElement } from '../../core/types'
|
||||||
import '../../styles/properties.css'
|
import '../../styles/properties.css'
|
||||||
|
|
||||||
const props = defineProps<{ element: LineElement }>()
|
const props = defineProps<{ element: LineElement }>()
|
||||||
const templateStore = useTemplateStore()
|
const { updateStyle } = usePropertyUpdate(() => props.element)
|
||||||
const editorStore = useEditorStore()
|
|
||||||
|
|
||||||
function updateStyle(key: string, value: unknown) {
|
|
||||||
const id = editorStore.selectedElementId
|
|
||||||
if (!id) return
|
|
||||||
templateStore.updateElement(id, { style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="prop-section">
|
<PropSection title="Cizgi Stili">
|
||||||
<div class="prop-section__title">Cizgi Stili</div>
|
<PropNumberInput
|
||||||
<div class="prop-row" data-tip="Cizgi kalinligi (mm)">
|
label="Kalinlik (mm)"
|
||||||
<label class="prop-label">Kalinlik (mm)</label>
|
:model-value="element.style.strokeWidth ?? 0.5"
|
||||||
<input class="prop-input" type="number" step="0.1" min="0.1"
|
:step="0.1"
|
||||||
:value="element.style.strokeWidth ?? 0.5"
|
:min="0.1"
|
||||||
@input="(e) => updateStyle('strokeWidth', parseFloat((e.target as HTMLInputElement).value) || 0.5)" />
|
data-tip="Cizgi kalinligi (mm)"
|
||||||
</div>
|
@update:model-value="(v) => updateStyle('strokeWidth', v)"
|
||||||
<div class="prop-row" data-tip="Cizgi rengi">
|
/>
|
||||||
<label class="prop-label">Renk</label>
|
<PropColorInput
|
||||||
<input class="prop-input prop-color" type="color"
|
label="Renk"
|
||||||
:value="element.style.strokeColor ?? '#000000'"
|
:model-value="element.style.strokeColor ?? '#000000'"
|
||||||
@input="(e) => updateStyle('strokeColor', (e.target as HTMLInputElement).value)" />
|
data-tip="Cizgi rengi"
|
||||||
</div>
|
@update:model-value="(v) => updateStyle('strokeColor', v)"
|
||||||
</div>
|
/>
|
||||||
|
</PropSection>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -22,10 +22,42 @@ function onInput(side: 'top' | 'right' | 'bottom' | 'left', e: Event) {
|
|||||||
<div class="pb">
|
<div class="pb">
|
||||||
<span class="pb__label">Padding</span>
|
<span class="pb__label">Padding</span>
|
||||||
<div class="pb__box">
|
<div class="pb__box">
|
||||||
<input class="pb__in pb__in--t" type="number" step="1" min="0" :value="props.top" @input="(e) => onInput('top', e)" data-tip="Ust bosluk (mm)" />
|
<input
|
||||||
<input class="pb__in pb__in--r" type="number" step="1" min="0" :value="props.right" @input="(e) => onInput('right', e)" data-tip="Sag bosluk (mm)" />
|
class="pb__in pb__in--t"
|
||||||
<input class="pb__in pb__in--b" type="number" step="1" min="0" :value="props.bottom" @input="(e) => onInput('bottom', e)" data-tip="Alt bosluk (mm)" />
|
type="number"
|
||||||
<input class="pb__in pb__in--l" type="number" step="1" min="0" :value="props.left" @input="(e) => onInput('left', e)" data-tip="Sol bosluk (mm)" />
|
step="1"
|
||||||
|
min="0"
|
||||||
|
:value="props.top"
|
||||||
|
@input="(e) => onInput('top', e)"
|
||||||
|
data-tip="Ust bosluk (mm)"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
class="pb__in pb__in--r"
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
min="0"
|
||||||
|
:value="props.right"
|
||||||
|
@input="(e) => onInput('right', e)"
|
||||||
|
data-tip="Sag bosluk (mm)"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
class="pb__in pb__in--b"
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
min="0"
|
||||||
|
:value="props.bottom"
|
||||||
|
@input="(e) => onInput('bottom', e)"
|
||||||
|
data-tip="Alt bosluk (mm)"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
class="pb__in pb__in--l"
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
min="0"
|
||||||
|
:value="props.left"
|
||||||
|
@input="(e) => onInput('left', e)"
|
||||||
|
data-tip="Sol bosluk (mm)"
|
||||||
|
/>
|
||||||
<div class="pb__center" />
|
<div class="pb__center" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -87,11 +119,32 @@ function onInput(side: 'top' | 'right' | 'bottom' | 'left', e: Event) {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pb__in:hover { background: #f1f5f9; }
|
.pb__in:hover {
|
||||||
.pb__in:focus { background: white; box-shadow: 0 0 0 1px #93c5fd; }
|
background: #f1f5f9;
|
||||||
|
}
|
||||||
|
.pb__in:focus {
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 0 0 1px #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
.pb__in--t { top: 1px; left: 50%; transform: translateX(-50%); }
|
.pb__in--t {
|
||||||
.pb__in--b { bottom: 1px; left: 50%; transform: translateX(-50%); }
|
top: 1px;
|
||||||
.pb__in--l { left: 2px; top: 50%; transform: translateY(-50%); }
|
left: 50%;
|
||||||
.pb__in--r { right: 2px; top: 50%; transform: translateY(-50%); }
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
.pb__in--b {
|
||||||
|
bottom: 1px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
.pb__in--l {
|
||||||
|
left: 2px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
.pb__in--r {
|
||||||
|
right: 2px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,59 +1,43 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useTemplateStore } from '../../stores/template'
|
import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
|
||||||
import { useEditorStore } from '../../stores/editor'
|
import PropSection from './shared/PropSection.vue'
|
||||||
import type { PageNumberElement, TextStyle, TemplateElement } from '../../core/types'
|
import PropSelect from './shared/PropSelect.vue'
|
||||||
|
import PropTextStyleGroup from './shared/PropTextStyleGroup.vue'
|
||||||
|
import type { PageNumberElement, TextStyle } from '../../core/types'
|
||||||
import '../../styles/properties.css'
|
import '../../styles/properties.css'
|
||||||
|
|
||||||
const props = defineProps<{ element: PageNumberElement }>()
|
const props = defineProps<{ element: PageNumberElement }>()
|
||||||
const templateStore = useTemplateStore()
|
const { update, updateStyle } = usePropertyUpdate(() => props.element)
|
||||||
const editorStore = useEditorStore()
|
const style = () => props.element.style as TextStyle
|
||||||
|
|
||||||
function update(updates: Partial<TemplateElement>) {
|
const formatOptions = [
|
||||||
const id = editorStore.selectedElementId
|
{ value: '{current} / {total}', label: '1 / 5' },
|
||||||
if (!id) return
|
{ value: '{current}', label: '1' },
|
||||||
templateStore.updateElement(id, updates)
|
{ value: 'Sayfa {current}', label: 'Sayfa 1' },
|
||||||
}
|
{ value: 'Sayfa {current} / {total}', label: 'Sayfa 1 / 5' },
|
||||||
|
]
|
||||||
function updateStyle(key: string, value: unknown) {
|
|
||||||
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="prop-section">
|
<PropSection title="Sayfa Numarasi">
|
||||||
<div class="prop-section__title">Sayfa Numarasi</div>
|
<PropSelect
|
||||||
<div class="prop-row" data-tip="Sayfa numarasi gosterim formati">
|
label="Format"
|
||||||
<label class="prop-label">Format</label>
|
:model-value="element.format ?? '{current} / {total}'"
|
||||||
<select class="prop-input prop-select"
|
:options="formatOptions"
|
||||||
:value="element.format ?? '{current} / {total}'"
|
data-tip="Sayfa numarasi gosterim formati"
|
||||||
@change="(e) => update({ format: (e.target as HTMLSelectElement).value } as any)">
|
@update:model-value="(v) => update({ format: v } as any)"
|
||||||
<option value="{current} / {total}">1 / 5</option>
|
/>
|
||||||
<option value="{current}">1</option>
|
<PropTextStyleGroup
|
||||||
<option value="Sayfa {current}">Sayfa 1</option>
|
:font-size="style().fontSize ?? 10"
|
||||||
<option value="Sayfa {current} / {total}">Sayfa 1 / 5</option>
|
:font-weight="style().fontWeight ?? 'normal'"
|
||||||
</select>
|
:font-family="style().fontFamily"
|
||||||
</div>
|
:color="style().color ?? '#666666'"
|
||||||
<div class="prop-row" data-tip="Yazi tipi boyutu (point)">
|
:align="style().align ?? 'center'"
|
||||||
<label class="prop-label">Boyut (pt)</label>
|
@update:font-size="(v) => updateStyle('fontSize', v)"
|
||||||
<input class="prop-input" type="number" step="1" min="1"
|
@update:font-weight="(v) => updateStyle('fontWeight', v)"
|
||||||
:value="(element.style as TextStyle).fontSize ?? 10"
|
@update:font-family="(v) => updateStyle('fontFamily', v)"
|
||||||
@input="(e) => updateStyle('fontSize', parseFloat((e.target as HTMLInputElement).value) || 10)" />
|
@update:color="(v) => updateStyle('color', v)"
|
||||||
</div>
|
@update:align="(v) => updateStyle('align', v)"
|
||||||
<div class="prop-row" data-tip="Metin rengi">
|
/>
|
||||||
<label class="prop-label">Renk</label>
|
</PropSection>
|
||||||
<input class="prop-input prop-color" type="color"
|
|
||||||
:value="(element.style as TextStyle).color ?? '#666666'"
|
|
||||||
@input="(e) => updateStyle('color', (e.target as HTMLInputElement).value)" />
|
|
||||||
</div>
|
|
||||||
<div class="prop-row" data-tip="Metnin yatay hizalamasi">
|
|
||||||
<label class="prop-label">Hizalama</label>
|
|
||||||
<select class="prop-input prop-select"
|
|
||||||
:value="(element.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>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useTemplateStore } from '../../stores/template'
|
import { useTemplateStore } from '../../stores/template'
|
||||||
|
import PropSection from './shared/PropSection.vue'
|
||||||
|
import PropSelect from './shared/PropSelect.vue'
|
||||||
|
import PropNumberInput from './shared/PropNumberInput.vue'
|
||||||
import type { TemplateElement } from '../../core/types'
|
import type { TemplateElement } from '../../core/types'
|
||||||
import '../../styles/properties.css'
|
import '../../styles/properties.css'
|
||||||
|
|
||||||
const props = defineProps<{ element: TemplateElement }>()
|
const props = defineProps<{ element: TemplateElement }>()
|
||||||
const templateStore = useTemplateStore()
|
const templateStore = useTemplateStore()
|
||||||
|
|
||||||
function togglePositioning() {
|
const positionOptions = [
|
||||||
if (props.element.position.type === 'flow') {
|
{ value: 'flow', label: 'Flow' },
|
||||||
|
{ value: 'absolute', label: 'Absolute' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function togglePositioning(value: string) {
|
||||||
|
if (value === 'absolute') {
|
||||||
templateStore.updateElementPosition(props.element.id, { type: 'absolute', x: 0, y: 0 })
|
templateStore.updateElementPosition(props.element.id, { type: 'absolute', x: 0, y: 0 })
|
||||||
} else {
|
} else {
|
||||||
templateStore.updateElementPosition(props.element.id, { type: 'flow' })
|
templateStore.updateElementPosition(props.element.id, { type: 'flow' })
|
||||||
@@ -16,28 +24,43 @@ function togglePositioning() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="prop-section">
|
<PropSection title="Pozisyon">
|
||||||
<div class="prop-section__title">Pozisyon</div>
|
<PropSelect
|
||||||
<div class="prop-row" data-tip="Flow: otomatik dizilim, Absolute: sabit konum">
|
label="Mod"
|
||||||
<label class="prop-label">Mod</label>
|
:model-value="element.position.type"
|
||||||
<select class="prop-input prop-select" :value="element.position.type" @change="togglePositioning">
|
:options="positionOptions"
|
||||||
<option value="flow">Flow</option>
|
data-tip="Flow: otomatik dizilim, Absolute: sabit konum"
|
||||||
<option value="absolute">Absolute</option>
|
@update:model-value="togglePositioning"
|
||||||
</select>
|
/>
|
||||||
</div>
|
|
||||||
<template v-if="element.position.type === 'absolute'">
|
<template v-if="element.position.type === 'absolute'">
|
||||||
<div class="prop-row" data-tip="Yatay pozisyon — parent sol kenardan uzaklik (mm)">
|
<PropNumberInput
|
||||||
<label class="prop-label">X (mm)</label>
|
label="X (mm)"
|
||||||
<input class="prop-input" type="number" step="0.5"
|
:model-value="(element.position as any).x ?? 0"
|
||||||
:value="element.position.x"
|
:step="0.5"
|
||||||
@input="(e) => templateStore.updateElementPosition(element.id, { type: 'absolute', x: parseFloat((e.target as HTMLInputElement).value) || 0, y: (element.position as any).y ?? 0 })" />
|
data-tip="Yatay pozisyon — parent sol kenardan uzaklik (mm)"
|
||||||
</div>
|
@update:model-value="
|
||||||
<div class="prop-row" data-tip="Dikey pozisyon — parent ust kenardan uzaklik (mm)">
|
(v) =>
|
||||||
<label class="prop-label">Y (mm)</label>
|
templateStore.updateElementPosition(element.id, {
|
||||||
<input class="prop-input" type="number" step="0.5"
|
type: 'absolute',
|
||||||
:value="element.position.y"
|
x: v,
|
||||||
@input="(e) => templateStore.updateElementPosition(element.id, { type: 'absolute', x: (element.position as any).x ?? 0, y: parseFloat((e.target as HTMLInputElement).value) || 0 })" />
|
y: (element.position as any).y ?? 0,
|
||||||
</div>
|
})
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<PropNumberInput
|
||||||
|
label="Y (mm)"
|
||||||
|
:model-value="(element.position as any).y ?? 0"
|
||||||
|
:step="0.5"
|
||||||
|
data-tip="Dikey pozisyon — parent ust kenardan uzaklik (mm)"
|
||||||
|
@update:model-value="
|
||||||
|
(v) =>
|
||||||
|
templateStore.updateElementPosition(element.id, {
|
||||||
|
type: 'absolute',
|
||||||
|
x: (element.position as any).x ?? 0,
|
||||||
|
y: v,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</PropSection>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,24 +1,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useTemplateStore } from '../../stores/template'
|
import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
|
||||||
import { useEditorStore } from '../../stores/editor'
|
|
||||||
import { useSchemaStore } from '../../stores/schema'
|
import { useSchemaStore } from '../../stores/schema'
|
||||||
import { sz } from '../../core/types'
|
import { sz } from '../../core/types'
|
||||||
import { schemaFormatToFormatType, defaultAlignForSchema } from '../../core/schema-parser'
|
import { schemaFormatToFormatType, defaultAlignForSchema } from '../../core/schema-parser'
|
||||||
import type { RepeatingTableElement, TableColumn, FormatType, TemplateElement } from '../../core/types'
|
import PropSection from './shared/PropSection.vue'
|
||||||
|
import PropFieldSelect from './shared/PropFieldSelect.vue'
|
||||||
|
import TableColumnEditor from './table/TableColumnEditor.vue'
|
||||||
|
import TableStyleEditor from './table/TableStyleEditor.vue'
|
||||||
|
import type { RepeatingTableElement, TableColumn, TableStyle } from '../../core/types'
|
||||||
import '../../styles/properties.css'
|
import '../../styles/properties.css'
|
||||||
|
|
||||||
const props = defineProps<{ element: RepeatingTableElement }>()
|
const props = defineProps<{ element: RepeatingTableElement }>()
|
||||||
const templateStore = useTemplateStore()
|
const { update } = usePropertyUpdate(() => props.element)
|
||||||
const editorStore = useEditorStore()
|
|
||||||
const schemaStore = useSchemaStore()
|
const schemaStore = useSchemaStore()
|
||||||
|
|
||||||
function update(updates: Partial<TemplateElement>) {
|
|
||||||
const id = editorStore.selectedElementId
|
|
||||||
if (!id) return
|
|
||||||
templateStore.updateElement(id, updates)
|
|
||||||
}
|
|
||||||
|
|
||||||
let colIdCounter = Date.now()
|
let colIdCounter = Date.now()
|
||||||
function nextColId() {
|
function nextColId() {
|
||||||
return `col_${(++colIdCounter).toString(36)}`
|
return `col_${(++colIdCounter).toString(36)}`
|
||||||
@@ -27,7 +23,7 @@ function nextColId() {
|
|||||||
function updateTableDataSource(path: string) {
|
function updateTableDataSource(path: string) {
|
||||||
const itemFields = schemaStore.getArrayItemFields(path)
|
const itemFields = schemaStore.getArrayItemFields(path)
|
||||||
if (itemFields.length > 0) {
|
if (itemFields.length > 0) {
|
||||||
const columns: TableColumn[] = itemFields.map(field => ({
|
const columns: TableColumn[] = itemFields.map((field) => ({
|
||||||
id: nextColId(),
|
id: nextColId(),
|
||||||
field: field.key,
|
field: field.key,
|
||||||
title: field.title,
|
title: field.title,
|
||||||
@@ -35,24 +31,21 @@ function updateTableDataSource(path: string) {
|
|||||||
align: defaultAlignForSchema(field),
|
align: defaultAlignForSchema(field),
|
||||||
format: schemaFormatToFormatType(field.format),
|
format: schemaFormatToFormatType(field.format),
|
||||||
}))
|
}))
|
||||||
update({
|
update({ dataSource: { type: 'array', path }, columns } as any)
|
||||||
dataSource: { type: 'array', path },
|
|
||||||
columns,
|
|
||||||
} as Partial<TemplateElement>)
|
|
||||||
} else {
|
} else {
|
||||||
update({ dataSource: { type: 'array', path } } as Partial<TemplateElement>)
|
update({ dataSource: { type: 'array', path } } as any)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateTableStyle(key: string, value: unknown) {
|
function updateTableStyle(key: string, value: unknown) {
|
||||||
const newStyle = { ...props.element.style, [key]: value }
|
const newStyle = { ...props.element.style, [key]: value }
|
||||||
if (value === undefined || value === '') delete (newStyle as Record<string, unknown>)[key]
|
if (value === undefined || value === '') delete (newStyle as Record<string, unknown>)[key]
|
||||||
update({ style: newStyle } as Partial<TemplateElement>)
|
update({ style: newStyle } as any)
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateColumn(colId: string, updates: Partial<TableColumn>) {
|
function updateColumn(colId: string, updates: Partial<TableColumn>) {
|
||||||
const columns = props.element.columns.map(c => c.id === colId ? { ...c, ...updates } : c)
|
const columns = props.element.columns.map((c) => (c.id === colId ? { ...c, ...updates } : c))
|
||||||
update({ columns } as Partial<TemplateElement>)
|
update({ columns } as any)
|
||||||
}
|
}
|
||||||
|
|
||||||
function addColumn() {
|
function addColumn() {
|
||||||
@@ -63,20 +56,20 @@ function addColumn() {
|
|||||||
width: sz.auto(),
|
width: sz.auto(),
|
||||||
align: 'left',
|
align: 'left',
|
||||||
}
|
}
|
||||||
update({ columns: [...props.element.columns, newCol] } as Partial<TemplateElement>)
|
update({ columns: [...props.element.columns, newCol] } as any)
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeColumn(colId: string) {
|
function removeColumn(colId: string) {
|
||||||
update({ columns: props.element.columns.filter(c => c.id !== colId) } as Partial<TemplateElement>)
|
update({ columns: props.element.columns.filter((c) => c.id !== colId) } as any)
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveColumn(colId: string, direction: -1 | 1) {
|
function moveColumn(colId: string, direction: -1 | 1) {
|
||||||
const cols = [...props.element.columns]
|
const cols = [...props.element.columns]
|
||||||
const idx = cols.findIndex(c => c.id === colId)
|
const idx = cols.findIndex((c) => c.id === colId)
|
||||||
const newIdx = idx + direction
|
const newIdx = idx + direction
|
||||||
if (newIdx < 0 || newIdx >= cols.length) return
|
if (newIdx < 0 || newIdx >= cols.length) return
|
||||||
;[cols[idx], cols[newIdx]] = [cols[newIdx], cols[idx]]
|
;[cols[idx], cols[newIdx]] = [cols[newIdx], cols[idx]]
|
||||||
update({ columns: cols } as Partial<TemplateElement>)
|
update({ columns: cols } as any)
|
||||||
}
|
}
|
||||||
|
|
||||||
const tableItemFields = computed(() => {
|
const tableItemFields = computed(() => {
|
||||||
@@ -86,594 +79,39 @@ const tableItemFields = computed(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- Data source -->
|
<!-- Data source -->
|
||||||
<div class="prop-section">
|
<PropSection title="Veri Kaynagi">
|
||||||
<div class="prop-section__title">Veri Kaynagi</div>
|
<PropFieldSelect
|
||||||
<div class="prop-row" data-tip="Tablonun baglanacagi array veri kaynagi">
|
label="Kaynak"
|
||||||
<label class="prop-label">Kaynak</label>
|
:model-value="element.dataSource.path"
|
||||||
<select class="prop-input prop-select"
|
:fields="schemaStore.arrayFields"
|
||||||
:value="element.dataSource.path"
|
data-tip="Tablonun baglanacagi array veri kaynagi"
|
||||||
@change="(e) => updateTableDataSource((e.target as HTMLSelectElement).value)">
|
@update:model-value="updateTableDataSource"
|
||||||
<option value="" disabled>Secin...</option>
|
/>
|
||||||
<option
|
</PropSection>
|
||||||
v-for="arr in schemaStore.arrayFields"
|
|
||||||
:key="arr.path"
|
|
||||||
:value="arr.path"
|
|
||||||
>{{ arr.title }} ({{ arr.path }})</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Columns -->
|
<!-- Columns -->
|
||||||
<div class="prop-section">
|
<PropSection title="Sutunlar">
|
||||||
<div class="prop-section__title">
|
<template #actions>
|
||||||
Sutunlar
|
|
||||||
<button class="prop-add-btn" @click="addColumn">+</button>
|
<button class="prop-add-btn" @click="addColumn">+</button>
|
||||||
</div>
|
</template>
|
||||||
<div
|
<TableColumnEditor
|
||||||
v-for="col in element.columns"
|
v-for="col in element.columns"
|
||||||
:key="col.id"
|
:key="col.id"
|
||||||
class="tbl-col"
|
:column="col"
|
||||||
>
|
:item-fields="tableItemFields"
|
||||||
<!-- Row 1: title + actions -->
|
@update="updateColumn"
|
||||||
<div class="tbl-col__head">
|
@remove="removeColumn"
|
||||||
<input class="tbl-col__title" type="text" :value="col.title"
|
@move="moveColumn"
|
||||||
@change="(e) => updateColumn(col.id, { title: (e.target as HTMLInputElement).value })"
|
/>
|
||||||
:placeholder="col.field"
|
</PropSection>
|
||||||
data-tip="Sutun basligi" />
|
|
||||||
<div class="tbl-col__actions">
|
|
||||||
<button class="tbl-col__act" @click="moveColumn(col.id, -1)" data-tip="Yukari tasi">
|
|
||||||
<svg width="10" height="10" viewBox="0 0 10 10"><path d="M5 2L2 6h6L5 2z" fill="currentColor"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="tbl-col__act" @click="moveColumn(col.id, 1)" data-tip="Asagi tasi">
|
|
||||||
<svg width="10" height="10" viewBox="0 0 10 10"><path d="M5 8L2 4h6L5 8z" fill="currentColor"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="tbl-col__act tbl-col__act--del" @click="removeColumn(col.id)" data-tip="Sutunu sil">
|
|
||||||
<svg width="10" height="10" viewBox="0 0 10 10"><path d="M2 2l6 6M8 2l-6 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Row 2: field + align + format + width compact -->
|
|
||||||
<div class="tbl-col__controls">
|
|
||||||
<!-- Field -->
|
|
||||||
<select v-if="tableItemFields.length > 0" class="tbl-col__field" :value="col.field" data-tip="Veri alani"
|
|
||||||
@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.key }}</option>
|
|
||||||
</select>
|
|
||||||
<input v-else class="tbl-col__field" type="text" :value="col.field"
|
|
||||||
@change="(e) => updateColumn(col.id, { field: (e.target as HTMLInputElement).value })"
|
|
||||||
data-tip="Veri alani" />
|
|
||||||
|
|
||||||
<!-- Alignment icons -->
|
|
||||||
<div class="tbl-col__align">
|
|
||||||
<button class="tbl-col__align-btn" :class="{ 'tbl-col__align-btn--on': col.align === 'left' }" @click="updateColumn(col.id, { align: 'left' })" data-tip="Sola hizala">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12"><line x1="1" y1="3" x2="11" y2="3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><line x1="1" y1="6" x2="8" y2="6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><line x1="1" y1="9" x2="10" y2="9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="tbl-col__align-btn" :class="{ 'tbl-col__align-btn--on': col.align === 'center' }" @click="updateColumn(col.id, { align: 'center' })" data-tip="Ortala">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12"><line x1="1" y1="3" x2="11" y2="3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><line x1="2.5" y1="6" x2="9.5" y2="6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><line x1="1.5" y1="9" x2="10.5" y2="9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="tbl-col__align-btn" :class="{ 'tbl-col__align-btn--on': col.align === 'right' }" @click="updateColumn(col.id, { align: 'right' })" data-tip="Saga hizala">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12"><line x1="1" y1="3" x2="11" y2="3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><line x1="4" y1="6" x2="11" y2="6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><line x1="2" y1="9" x2="11" y2="9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Row 3: format + width -->
|
|
||||||
<div class="tbl-col__extra" data-tip="Veri gosterim formati">
|
|
||||||
<label class="tbl-col__elabel">Format</label>
|
|
||||||
<select class="tbl-col__fmt" :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="tbl-col__extra" data-tip="Sutun genislik modu">
|
|
||||||
<label class="tbl-col__elabel">Genislik</label>
|
|
||||||
<select class="tbl-col__wtype" :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>
|
|
||||||
<span v-if="col.width.type === 'fixed' || col.width.type === 'fr'" class="ts-tip-wrap" :data-tip="col.width.type === 'fixed' ? 'Sabit genislik (mm)' : 'Oran degeri (fr)'">
|
|
||||||
<input class="tbl-col__wval" type="number" step="1"
|
|
||||||
:min="col.width.type === 'fixed' ? 5 : 1"
|
|
||||||
:value="(col.width as any).value"
|
|
||||||
@change="(e) => updateColumn(col.id, { width: { type: col.width.type, value: parseFloat((e.target as HTMLInputElement).value) || (col.width.type === 'fixed' ? 30 : 1) } as any })" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Table style -->
|
<!-- Table style -->
|
||||||
<div class="prop-section">
|
<PropSection title="Tablo Stili">
|
||||||
<div class="prop-section__title">Tablo Stili</div>
|
<TableStyleEditor
|
||||||
|
:style="element.style as TableStyle"
|
||||||
<div class="ts-form">
|
:repeat-header="element.repeatHeader !== false"
|
||||||
<!-- Font sizes -->
|
@update:style="updateTableStyle"
|
||||||
<label class="ts-lbl" data-tip="Icerik ve header yazi boyutu (pt)">Yazi boyutu</label>
|
@update:repeat-header="(v) => update({ repeatHeader: v } as any)"
|
||||||
<div class="ts-val ts-val--pair">
|
/>
|
||||||
<span class="ts-sep">Icerik</span>
|
</PropSection>
|
||||||
<span class="ts-tip-wrap" data-tip="Icerik yazi boyutu (pt)">
|
|
||||||
<input class="ts-num" type="number" step="1" min="6" max="99"
|
|
||||||
:value="element.style.fontSize ?? 10"
|
|
||||||
@input="(e) => updateTableStyle('fontSize', parseFloat((e.target as HTMLInputElement).value) || 10)" />
|
|
||||||
</span>
|
|
||||||
<span class="ts-sep">Header</span>
|
|
||||||
<span class="ts-tip-wrap" data-tip="Header yazi boyutu (pt)">
|
|
||||||
<input class="ts-num" type="number" step="1" min="6" max="99"
|
|
||||||
:value="element.style.headerFontSize ?? element.style.fontSize ?? 10"
|
|
||||||
@input="(e) => updateTableStyle('headerFontSize', parseFloat((e.target as HTMLInputElement).value) || 10)" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Colors -->
|
|
||||||
<label class="ts-lbl" data-tip="Header, metin ve zebra satirlari renkleri">Renkler</label>
|
|
||||||
<div class="ts-val ts-val--colors">
|
|
||||||
<div class="ts-color-item" data-tip="Header arkaplan rengi">
|
|
||||||
<input class="ts-swatch" type="color"
|
|
||||||
:value="element.style.headerBg ?? '#f0f0f0'"
|
|
||||||
@input="(e) => updateTableStyle('headerBg', (e.target as HTMLInputElement).value)" />
|
|
||||||
<span class="ts-clbl">Arkaplan</span>
|
|
||||||
</div>
|
|
||||||
<div class="ts-color-item" data-tip="Header metin rengi">
|
|
||||||
<input class="ts-swatch" type="color"
|
|
||||||
:value="element.style.headerColor ?? '#000000'"
|
|
||||||
@input="(e) => updateTableStyle('headerColor', (e.target as HTMLInputElement).value)" />
|
|
||||||
<span class="ts-clbl">Metin</span>
|
|
||||||
</div>
|
|
||||||
<div class="ts-color-item" data-tip="Zebra satir rengi — tek satirlar">
|
|
||||||
<div class="ts-swatch-wrap">
|
|
||||||
<input class="ts-swatch" type="color"
|
|
||||||
:value="element.style.zebraOdd ?? '#fafafa'"
|
|
||||||
@input="(e) => updateTableStyle('zebraOdd', (e.target as HTMLInputElement).value)" />
|
|
||||||
<button v-if="element.style.zebraOdd" class="ts-swatch-clr" @click="updateTableStyle('zebraOdd', undefined)">×</button>
|
|
||||||
</div>
|
|
||||||
<span class="ts-clbl">Zebra</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Border -->
|
|
||||||
<label class="ts-lbl" data-tip="Tablo kenarlik rengi ve kalinligi">Kenarlik</label>
|
|
||||||
<div class="ts-val ts-val--pair">
|
|
||||||
<div class="ts-swatch-wrap" data-tip="Kenarlik rengi">
|
|
||||||
<input class="ts-swatch" type="color"
|
|
||||||
:value="element.style.borderColor ?? '#cccccc'"
|
|
||||||
@input="(e) => updateTableStyle('borderColor', (e.target as HTMLInputElement).value)" />
|
|
||||||
<button v-if="element.style.borderColor" class="ts-swatch-clr" @click="updateTableStyle('borderColor', undefined)">×</button>
|
|
||||||
</div>
|
|
||||||
<span class="ts-tip-wrap" data-tip="Kenarlik kalinligi (mm)">
|
|
||||||
<input class="ts-num" type="number" step="0.1" min="0" max="99"
|
|
||||||
:value="element.style.borderWidth ?? 0.5"
|
|
||||||
@input="(e) => updateTableStyle('borderWidth', parseFloat((e.target as HTMLInputElement).value) || 0)" />
|
|
||||||
</span>
|
|
||||||
<span class="ts-unit">mm</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Cell padding -->
|
|
||||||
<label class="ts-lbl" data-tip="Hucre ic bosluklari — yatay ve dikey (mm)">Ic bosluk</label>
|
|
||||||
<div class="ts-val ts-val--pair">
|
|
||||||
<span class="ts-pad-icon" data-tip="Yatay bosluk (mm)">↔</span>
|
|
||||||
<span class="ts-tip-wrap" data-tip="Yatay ic bosluk (mm)">
|
|
||||||
<input class="ts-num" type="number" step="0.5" min="0" max="99"
|
|
||||||
:value="element.style.cellPaddingH ?? 2"
|
|
||||||
@input="(e) => updateTableStyle('cellPaddingH', parseFloat((e.target as HTMLInputElement).value) || 0)" />
|
|
||||||
</span>
|
|
||||||
<span class="ts-pad-icon" data-tip="Dikey bosluk (mm)">↕</span>
|
|
||||||
<span class="ts-tip-wrap" data-tip="Dikey ic bosluk (mm)">
|
|
||||||
<input class="ts-num" type="number" step="0.5" min="0" max="99"
|
|
||||||
:value="element.style.cellPaddingV ?? 1"
|
|
||||||
@input="(e) => updateTableStyle('cellPaddingV', parseFloat((e.target as HTMLInputElement).value) || 0)" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Header padding -->
|
|
||||||
<label class="ts-lbl" data-tip="Header hucre bosluklari — yatay ve dikey (mm)">Header bosluk</label>
|
|
||||||
<div class="ts-val ts-val--pair">
|
|
||||||
<span class="ts-pad-icon" data-tip="Yatay bosluk (mm)">↔</span>
|
|
||||||
<span class="ts-tip-wrap" data-tip="Header yatay bosluk (mm)">
|
|
||||||
<input class="ts-num" type="number" step="0.5" min="0" max="99"
|
|
||||||
:value="element.style.headerPaddingH ?? element.style.cellPaddingH ?? 2"
|
|
||||||
@input="(e) => updateTableStyle('headerPaddingH', parseFloat((e.target as HTMLInputElement).value) || 0)" />
|
|
||||||
</span>
|
|
||||||
<span class="ts-pad-icon" data-tip="Dikey bosluk (mm)">↕</span>
|
|
||||||
<span class="ts-tip-wrap" data-tip="Header dikey bosluk (mm)">
|
|
||||||
<input class="ts-num" type="number" step="0.5" min="0" max="99"
|
|
||||||
:value="element.style.headerPaddingV ?? element.style.cellPaddingV ?? 1"
|
|
||||||
@input="(e) => updateTableStyle('headerPaddingV', parseFloat((e.target as HTMLInputElement).value) || 0)" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Repeat header -->
|
|
||||||
<label class="ts-lbl" data-tip="Cok sayfali tablolarda header'i her sayfada tekrarla">Header tekrarla</label>
|
|
||||||
<div class="ts-val">
|
|
||||||
<label class="ts-toggle">
|
|
||||||
<input type="checkbox"
|
|
||||||
:checked="element.repeatHeader !== false"
|
|
||||||
@change="(e) => update({ repeatHeader: (e.target as HTMLInputElement).checked } as any)" />
|
|
||||||
<span class="ts-toggle__track"></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* Column card - compact */
|
|
||||||
.tbl-col {
|
|
||||||
background: #f8fafc;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 5px;
|
|
||||||
padding: 5px 6px;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tbl-col__head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tbl-col__title {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #334155;
|
|
||||||
padding: 1px 0;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tbl-col__title:focus {
|
|
||||||
border-bottom: 1px solid #93c5fd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tbl-col__actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 1px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tbl-col__act {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 3px;
|
|
||||||
background: transparent;
|
|
||||||
color: #94a3b8;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tbl-col__act:hover {
|
|
||||||
background: #e2e8f0;
|
|
||||||
color: #475569;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tbl-col__act--del:hover {
|
|
||||||
background: #fef2f2;
|
|
||||||
color: #dc2626;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tbl-col__controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
margin-bottom: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tbl-col__field {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 2px 4px;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 3px;
|
|
||||||
font-size: 11px;
|
|
||||||
background: white;
|
|
||||||
color: #334155;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tbl-col__field:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #93c5fd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tbl-col__align {
|
|
||||||
display: flex;
|
|
||||||
gap: 0;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tbl-col__align-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
background: white;
|
|
||||||
color: #94a3b8;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tbl-col__align-btn:first-child {
|
|
||||||
border-radius: 3px 0 0 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tbl-col__align-btn:last-child {
|
|
||||||
border-radius: 0 3px 3px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tbl-col__align-btn:not(:first-child) {
|
|
||||||
border-left: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tbl-col__align-btn--on {
|
|
||||||
background: #3b82f6;
|
|
||||||
color: white;
|
|
||||||
border-color: #3b82f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tbl-col__extra {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
margin-bottom: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tbl-col__elabel {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #64748b;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tbl-col__fmt {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 2px 4px;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 3px;
|
|
||||||
font-size: 11px;
|
|
||||||
background: white;
|
|
||||||
color: #334155;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tbl-col__wtype {
|
|
||||||
width: 80px;
|
|
||||||
padding: 2px 4px;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 3px;
|
|
||||||
font-size: 11px;
|
|
||||||
background: white;
|
|
||||||
color: #334155;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tbl-col__wval {
|
|
||||||
width: 36px;
|
|
||||||
padding: 2px 3px;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 3px;
|
|
||||||
font-size: 11px;
|
|
||||||
background: white;
|
|
||||||
color: #334155;
|
|
||||||
text-align: center;
|
|
||||||
-moz-appearance: textfield;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tbl-col__wval::-webkit-inner-spin-button,
|
|
||||||
.tbl-col__wval::-webkit-outer-spin-button {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tbl-col__wval:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #93c5fd;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Table style — aligned 2-column form */
|
|
||||||
.ts-form {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto 1fr;
|
|
||||||
gap: 5px 8px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ts-lbl {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #64748b;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ts-val {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ts-val--pair {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ts-val--colors {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ts-sep {
|
|
||||||
font-size: 10px;
|
|
||||||
color: #94a3b8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ts-num {
|
|
||||||
width: 32px;
|
|
||||||
padding: 2px 3px;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 3px;
|
|
||||||
font-size: 11px;
|
|
||||||
background: white;
|
|
||||||
color: #334155;
|
|
||||||
text-align: center;
|
|
||||||
-moz-appearance: textfield;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ts-num::-webkit-inner-spin-button,
|
|
||||||
.ts-num::-webkit-outer-spin-button {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ts-num:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #93c5fd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ts-unit {
|
|
||||||
font-size: 10px;
|
|
||||||
color: #94a3b8;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Color swatches */
|
|
||||||
.ts-color-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ts-clbl {
|
|
||||||
font-size: 9px;
|
|
||||||
color: #94a3b8;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ts-swatch {
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
padding: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ts-swatch-wrap {
|
|
||||||
position: relative;
|
|
||||||
display: inline-flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ts-swatch-clr {
|
|
||||||
position: absolute;
|
|
||||||
top: -4px;
|
|
||||||
right: -4px;
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #f1f5f9;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
font-size: 9px;
|
|
||||||
line-height: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
color: #94a3b8;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ts-swatch-clr:hover {
|
|
||||||
background: #fef2f2;
|
|
||||||
color: #dc2626;
|
|
||||||
border-color: #fecaca;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ts-pad-icon {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #94a3b8;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ts-tip-wrap {
|
|
||||||
position: relative;
|
|
||||||
display: inline-flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Toggle switch */
|
|
||||||
.ts-toggle {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ts-toggle input {
|
|
||||||
position: absolute;
|
|
||||||
opacity: 0;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ts-toggle__track {
|
|
||||||
display: block;
|
|
||||||
width: 28px;
|
|
||||||
height: 16px;
|
|
||||||
background: #e2e8f0;
|
|
||||||
border-radius: 8px;
|
|
||||||
transition: background 0.15s;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ts-toggle__track::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 2px;
|
|
||||||
left: 2px;
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
background: white;
|
|
||||||
border-radius: 50%;
|
|
||||||
transition: transform 0.15s;
|
|
||||||
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ts-toggle input:checked + .ts-toggle__track {
|
|
||||||
background: #3b82f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ts-toggle input:checked + .ts-toggle__track::after {
|
|
||||||
transform: translateX(12px);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,27 +1,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useTemplateStore } from '../../stores/template'
|
import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
|
||||||
import { useEditorStore } from '../../stores/editor'
|
import { useSchemaStore } from '../../stores/schema'
|
||||||
|
import PropSection from './shared/PropSection.vue'
|
||||||
|
import PropColorInput from './shared/PropColorInput.vue'
|
||||||
|
import PropSelect from './shared/PropSelect.vue'
|
||||||
|
import PropFieldSelect from './shared/PropFieldSelect.vue'
|
||||||
|
import PropTextStyleGroup from './shared/PropTextStyleGroup.vue'
|
||||||
import type { RichTextElement, RichTextSpan, TextStyle } from '../../core/types'
|
import type { RichTextElement, RichTextSpan, TextStyle } from '../../core/types'
|
||||||
import '../../styles/properties.css'
|
import '../../styles/properties.css'
|
||||||
|
|
||||||
const props = defineProps<{ element: RichTextElement }>()
|
const props = defineProps<{ element: RichTextElement }>()
|
||||||
const templateStore = useTemplateStore()
|
const { update, updateStyle } = usePropertyUpdate(() => props.element)
|
||||||
const editorStore = useEditorStore()
|
const schemaStore = useSchemaStore()
|
||||||
|
|
||||||
function update(updates: Partial<RichTextElement>) {
|
|
||||||
const id = editorStore.selectedElementId
|
|
||||||
if (!id) return
|
|
||||||
templateStore.updateElement(id, updates as any)
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateStyle(key: string, value: unknown) {
|
|
||||||
update({ style: { ...props.element.style, [key]: value } } as Partial<RichTextElement>)
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSpan(index: number, updates: Partial<RichTextSpan>) {
|
function updateSpan(index: number, updates: Partial<RichTextSpan>) {
|
||||||
const content = [...props.element.content]
|
const content = [...props.element.content]
|
||||||
content[index] = { ...content[index], ...updates }
|
content[index] = { ...content[index], ...updates }
|
||||||
update({ content })
|
update({ content } as any)
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSpanStyle(index: number, key: string, value: unknown) {
|
function updateSpanStyle(index: number, key: string, value: unknown) {
|
||||||
@@ -31,48 +26,42 @@ function updateSpanStyle(index: number, key: string, value: unknown) {
|
|||||||
|
|
||||||
function addSpan() {
|
function addSpan() {
|
||||||
const content = [...props.element.content, { text: 'yeni', style: {} }]
|
const content = [...props.element.content, { text: 'yeni', style: {} }]
|
||||||
update({ content })
|
update({ content } as any)
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeSpan(index: number) {
|
function removeSpan(index: number) {
|
||||||
if (props.element.content.length <= 1) return
|
if (props.element.content.length <= 1) return
|
||||||
const content = props.element.content.filter((_, i) => i !== index)
|
const content = props.element.content.filter((_, i) => i !== index)
|
||||||
update({ content })
|
update({ content } as any)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const weightOptions = [
|
||||||
|
{ value: '', label: 'Varsayilan' },
|
||||||
|
{ value: 'normal', label: 'Normal' },
|
||||||
|
{ value: 'bold', label: 'Kalin' },
|
||||||
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="prop-section">
|
<PropSection title="Varsayilan Stil">
|
||||||
<div class="prop-section__title">Varsayilan Stil</div>
|
<PropTextStyleGroup
|
||||||
<div class="prop-row" data-tip="Varsayilan yazi tipi boyutu (point)">
|
:font-size="element.style.fontSize ?? 11"
|
||||||
<label class="prop-label">Boyut (pt)</label>
|
:font-weight="element.style.fontWeight ?? 'normal'"
|
||||||
<input class="prop-input" type="number" step="1" min="1"
|
:font-family="element.style.fontFamily"
|
||||||
:value="element.style.fontSize ?? 11"
|
:color="element.style.color ?? '#000000'"
|
||||||
@input="(e) => updateStyle('fontSize', parseFloat((e.target as HTMLInputElement).value) || 11)" />
|
:align="element.style.align ?? 'left'"
|
||||||
</div>
|
@update:font-size="(v) => updateStyle('fontSize', v)"
|
||||||
<div class="prop-row" data-tip="Varsayilan metin rengi">
|
@update:font-weight="(v) => updateStyle('fontWeight', v)"
|
||||||
<label class="prop-label">Renk</label>
|
@update:font-family="(v) => updateStyle('fontFamily', v)"
|
||||||
<input class="prop-input prop-color" type="color"
|
@update:color="(v) => updateStyle('color', v)"
|
||||||
:value="element.style.color ?? '#000000'"
|
@update:align="(v) => updateStyle('align', v)"
|
||||||
@input="(e) => updateStyle('color', (e.target as HTMLInputElement).value)" />
|
/>
|
||||||
</div>
|
</PropSection>
|
||||||
<div class="prop-row" data-tip="Metnin yatay hizalamasi">
|
|
||||||
<label class="prop-label">Hizalama</label>
|
|
||||||
<select class="prop-input prop-select"
|
|
||||||
:value="element.style.align ?? 'left'"
|
|
||||||
@change="(e) => updateStyle('align', (e.target as HTMLSelectElement).value)">
|
|
||||||
<option value="left">Sol</option>
|
|
||||||
<option value="center">Orta</option>
|
|
||||||
<option value="right">Sag</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="prop-section">
|
<PropSection title="Span'lar">
|
||||||
<div class="prop-section__title">
|
<template #actions>
|
||||||
Span'lar
|
|
||||||
<button class="prop-add-btn" @click="addSpan" title="Span ekle">+</button>
|
<button class="prop-add-btn" @click="addSpan" title="Span ekle">+</button>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<div v-for="(span, idx) in element.content" :key="idx" class="prop-span-card">
|
<div v-for="(span, idx) in element.content" :key="idx" class="prop-span-card">
|
||||||
<div class="prop-span-card__header">
|
<div class="prop-span-card__header">
|
||||||
@@ -82,69 +71,71 @@ function removeSpan(index: number) {
|
|||||||
class="prop-span-card__remove"
|
class="prop-span-card__remove"
|
||||||
@click="removeSpan(idx)"
|
@click="removeSpan(idx)"
|
||||||
title="Sil"
|
title="Sil"
|
||||||
>×</button>
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="prop-row" data-tip="Span metin icerigi">
|
<div class="prop-row" data-tip="Span metin icerigi">
|
||||||
<label class="prop-label">Metin</label>
|
<label class="prop-label">Metin</label>
|
||||||
<input class="prop-input" type="text"
|
<input
|
||||||
|
class="prop-input"
|
||||||
|
type="text"
|
||||||
:value="span.text ?? ''"
|
:value="span.text ?? ''"
|
||||||
@input="(e) => updateSpan(idx, { text: (e.target as HTMLInputElement).value })" />
|
@input="(e) => updateSpan(idx, { text: (e.target as HTMLInputElement).value })"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<PropFieldSelect
|
||||||
|
label="Binding"
|
||||||
|
:model-value="span.binding?.path ?? ''"
|
||||||
|
:fields="schemaStore.scalarFields"
|
||||||
|
:allow-empty="true"
|
||||||
|
empty-label="Yok (statik)"
|
||||||
|
data-tip="Span'in baglanacagi veri alani"
|
||||||
|
@update:model-value="(v) => updateSpan(idx, { binding: v ? { type: 'scalar', path: v } : undefined })"
|
||||||
|
/>
|
||||||
<div class="prop-row" data-tip="Span yazi boyutu — bos birakilirsa varsayilan kullanilir">
|
<div class="prop-row" data-tip="Span yazi boyutu — bos birakilirsa varsayilan kullanilir">
|
||||||
<label class="prop-label">Boyut</label>
|
<label class="prop-label">Boyut</label>
|
||||||
<input class="prop-input" type="number" step="1" min="1"
|
<input
|
||||||
|
class="prop-input"
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
min="1"
|
||||||
:value="(span.style as TextStyle).fontSize ?? ''"
|
:value="(span.style as TextStyle).fontSize ?? ''"
|
||||||
placeholder="varsayilan"
|
placeholder="varsayilan"
|
||||||
@input="(e) => {
|
@input="
|
||||||
|
(e) => {
|
||||||
const v = (e.target as HTMLInputElement).value
|
const v = (e.target as HTMLInputElement).value
|
||||||
updateSpanStyle(idx, 'fontSize', v ? parseFloat(v) : undefined)
|
updateSpanStyle(idx, 'fontSize', v ? parseFloat(v) : undefined)
|
||||||
}" />
|
}
|
||||||
</div>
|
"
|
||||||
<div class="prop-row" data-tip="Span yazi kalinligi">
|
/>
|
||||||
<label class="prop-label">Kalinlik</label>
|
|
||||||
<select class="prop-input prop-select"
|
|
||||||
:value="(span.style as TextStyle).fontWeight ?? ''"
|
|
||||||
@change="(e) => {
|
|
||||||
const v = (e.target as HTMLSelectElement).value
|
|
||||||
updateSpanStyle(idx, 'fontWeight', v || undefined)
|
|
||||||
}">
|
|
||||||
<option value="">Varsayilan</option>
|
|
||||||
<option value="normal">Normal</option>
|
|
||||||
<option value="bold">Kalin</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="prop-row" data-tip="Span metin rengi">
|
|
||||||
<label class="prop-label">Renk</label>
|
|
||||||
<input class="prop-input prop-color" type="color"
|
|
||||||
:value="(span.style as TextStyle).color ?? element.style.color ?? '#000000'"
|
|
||||||
@input="(e) => updateSpanStyle(idx, 'color', (e.target as HTMLInputElement).value)" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<PropSelect
|
||||||
|
label="Kalinlik"
|
||||||
|
:model-value="(span.style as TextStyle).fontWeight ?? ''"
|
||||||
|
:options="weightOptions"
|
||||||
|
data-tip="Span yazi kalinligi"
|
||||||
|
@update:model-value="(v) => updateSpanStyle(idx, 'fontWeight', v || undefined)"
|
||||||
|
/>
|
||||||
|
<PropColorInput
|
||||||
|
label="Renk"
|
||||||
|
:model-value="(span.style as TextStyle).color ?? element.style.color ?? '#000000'"
|
||||||
|
data-tip="Span metin rengi"
|
||||||
|
@update:model-value="(v) => updateSpanStyle(idx, 'color', v)"
|
||||||
|
/>
|
||||||
|
<PropSelect
|
||||||
|
label="Hizalama"
|
||||||
|
:model-value="(span.style as TextStyle).align ?? ''"
|
||||||
|
:options="[{ value: '', label: 'Varsayilan' }, { value: 'left', label: 'Sol' }, { value: 'center', label: 'Orta' }, { value: 'right', label: 'Sag' }]"
|
||||||
|
data-tip="Span hizalamasi"
|
||||||
|
@update:model-value="(v) => updateSpanStyle(idx, 'align', v || undefined)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</PropSection>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.prop-add-btn {
|
|
||||||
float: right;
|
|
||||||
background: #3b82f6;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prop-add-btn:hover {
|
|
||||||
background: #2563eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prop-span-card {
|
.prop-span-card {
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
|
|||||||
@@ -1,60 +1,72 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useTemplateStore } from '../../stores/template'
|
import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
|
||||||
import { useEditorStore } from '../../stores/editor'
|
import PropSection from './shared/PropSection.vue'
|
||||||
import type { ShapeElement, TemplateElement } from '../../core/types'
|
import PropSelect from './shared/PropSelect.vue'
|
||||||
|
import PropNumberInput from './shared/PropNumberInput.vue'
|
||||||
|
import PropColorInput from './shared/PropColorInput.vue'
|
||||||
|
import type { ShapeElement } from '../../core/types'
|
||||||
import '../../styles/properties.css'
|
import '../../styles/properties.css'
|
||||||
|
|
||||||
const props = defineProps<{ element: ShapeElement }>()
|
const props = defineProps<{ element: ShapeElement }>()
|
||||||
const templateStore = useTemplateStore()
|
const { update, updateStyle } = usePropertyUpdate(() => props.element)
|
||||||
const editorStore = useEditorStore()
|
|
||||||
|
|
||||||
function update(updates: Partial<TemplateElement>) {
|
const shapeOptions = [
|
||||||
const id = editorStore.selectedElementId
|
{ value: 'rectangle', label: 'Dikdortgen' },
|
||||||
if (!id) return
|
{ value: 'rounded_rectangle', label: 'Yuvarlak Dikdortgen' },
|
||||||
templateStore.updateElement(id, updates)
|
{ value: 'ellipse', label: 'Elips' },
|
||||||
}
|
]
|
||||||
|
|
||||||
function updateStyle(key: string, value: unknown) {
|
const borderStyleOptions = [
|
||||||
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>)
|
{ value: 'solid', label: 'Duz' },
|
||||||
}
|
{ value: 'dashed', label: 'Kesikli' },
|
||||||
|
{ value: 'dotted', label: 'Noktali' },
|
||||||
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="prop-section">
|
<PropSection title="Sekil">
|
||||||
<div class="prop-section__title">Sekil</div>
|
<PropSelect
|
||||||
<div class="prop-row" data-tip="Sekil tipi">
|
label="Tip"
|
||||||
<label class="prop-label">Tip</label>
|
:model-value="element.shapeType"
|
||||||
<select class="prop-input prop-select"
|
:options="shapeOptions"
|
||||||
:value="element.shapeType"
|
data-tip="Sekil tipi"
|
||||||
@change="(e) => update({ shapeType: (e.target as HTMLSelectElement).value } as any)">
|
@update:model-value="(v) => update({ shapeType: v } as any)"
|
||||||
<option value="rectangle">Dikdortgen</option>
|
/>
|
||||||
<option value="rounded_rectangle">Yuvarlak Dikdortgen</option>
|
<PropColorInput
|
||||||
<option value="ellipse">Elips</option>
|
label="Arka Plan"
|
||||||
</select>
|
:model-value="element.style.backgroundColor ?? '#f0f0f0'"
|
||||||
</div>
|
data-tip="Sekil arka plan rengi"
|
||||||
<div class="prop-row" data-tip="Sekil arka plan rengi">
|
@update:model-value="(v) => updateStyle('backgroundColor', v)"
|
||||||
<label class="prop-label">Arka Plan</label>
|
/>
|
||||||
<input class="prop-input prop-color" type="color"
|
<PropColorInput
|
||||||
:value="element.style.backgroundColor ?? '#f0f0f0'"
|
label="Kenar Rengi"
|
||||||
@input="(e) => updateStyle('backgroundColor', (e.target as HTMLInputElement).value)" />
|
:model-value="element.style.borderColor ?? '#333333'"
|
||||||
</div>
|
data-tip="Kenarlik cizgisi rengi"
|
||||||
<div class="prop-row" data-tip="Kenarlik cizgisi rengi">
|
@update:model-value="(v) => updateStyle('borderColor', v)"
|
||||||
<label class="prop-label">Kenar Rengi</label>
|
/>
|
||||||
<input class="prop-input prop-color" type="color"
|
<PropNumberInput
|
||||||
:value="element.style.borderColor ?? '#333333'"
|
label="Kenar Kalinligi"
|
||||||
@input="(e) => updateStyle('borderColor', (e.target as HTMLInputElement).value)" />
|
:model-value="element.style.borderWidth ?? 0.5"
|
||||||
</div>
|
:step="0.25"
|
||||||
<div class="prop-row" data-tip="Kenarlik cizgi kalinligi (mm)">
|
:min="0"
|
||||||
<label class="prop-label">Kenar Kalinligi</label>
|
data-tip="Kenarlik cizgi kalinligi (mm)"
|
||||||
<input class="prop-input" type="number" step="0.25" min="0"
|
@update:model-value="(v) => updateStyle('borderWidth', v)"
|
||||||
:value="element.style.borderWidth ?? 0.5"
|
/>
|
||||||
@input="(e) => updateStyle('borderWidth', parseFloat((e.target as HTMLInputElement).value) || 0)" />
|
<PropSelect
|
||||||
</div>
|
label="Kenar Stili"
|
||||||
<div v-if="element.shapeType === 'rounded_rectangle'" class="prop-row" data-tip="Kose yuvarlakligi (mm)">
|
:model-value="element.style.borderStyle ?? 'solid'"
|
||||||
<label class="prop-label">Kose Yuvarlakligi</label>
|
:options="borderStyleOptions"
|
||||||
<input class="prop-input" type="number" step="0.5" min="0"
|
data-tip="Kenarlik cizgi stili"
|
||||||
:value="element.style.borderRadius ?? 2"
|
@update:model-value="(v) => updateStyle('borderStyle', v)"
|
||||||
@input="(e) => updateStyle('borderRadius', parseFloat((e.target as HTMLInputElement).value) || 0)" />
|
/>
|
||||||
</div>
|
<PropNumberInput
|
||||||
</div>
|
v-if="element.shapeType === 'rounded_rectangle'"
|
||||||
|
label="Kose Yuvarlakligi"
|
||||||
|
:model-value="element.style.borderRadius ?? 2"
|
||||||
|
:step="0.5"
|
||||||
|
:min="0"
|
||||||
|
data-tip="Kose yuvarlakligi (mm)"
|
||||||
|
@update:model-value="(v) => updateStyle('borderRadius', v)"
|
||||||
|
/>
|
||||||
|
</PropSection>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,67 +1,131 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useTemplateStore } from '../../stores/template'
|
import { useTemplateStore } from '../../stores/template'
|
||||||
|
import PropSection from './shared/PropSection.vue'
|
||||||
|
import PropNumberInput from './shared/PropNumberInput.vue'
|
||||||
import type { TemplateElement, SizeValue } from '../../core/types'
|
import type { TemplateElement, SizeValue } from '../../core/types'
|
||||||
import '../../styles/properties.css'
|
import '../../styles/properties.css'
|
||||||
|
|
||||||
const props = defineProps<{ element: TemplateElement }>()
|
const props = defineProps<{ element: TemplateElement }>()
|
||||||
const templateStore = useTemplateStore()
|
const templateStore = useTemplateStore()
|
||||||
|
|
||||||
|
const sizeOptions = [
|
||||||
|
{ value: 'auto', label: 'Otomatik' },
|
||||||
|
{ value: 'fixed', label: 'Sabit (mm)' },
|
||||||
|
{ value: 'fr', label: 'Oran (fr)' },
|
||||||
|
]
|
||||||
|
|
||||||
function updateSize(axis: 'width' | 'height', sv: SizeValue) {
|
function updateSize(axis: 'width' | 'height', sv: SizeValue) {
|
||||||
templateStore.updateElementSize(props.element.id, { [axis]: sv })
|
templateStore.updateElementSize(props.element.id, { [axis]: sv })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateSizeConstraint(key: string, value: number | undefined) {
|
||||||
|
templateStore.updateElementSize(props.element.id, { [key]: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTypeChange(axis: 'width' | 'height', type: string) {
|
||||||
|
if (type === 'auto') updateSize(axis, { type: 'auto' })
|
||||||
|
else if (type === 'fr') updateSize(axis, { type: 'fr', value: 1 })
|
||||||
|
else updateSize(axis, { type: 'fixed', value: axis === 'width' ? 50 : 20 })
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="prop-section">
|
<PropSection title="Boyut">
|
||||||
<div class="prop-section__title">Boyut</div>
|
|
||||||
<div class="prop-row" data-tip="Genislik boyutlandirma modu">
|
<div class="prop-row" data-tip="Genislik boyutlandirma modu">
|
||||||
<label class="prop-label">Genislik</label>
|
<label class="prop-label">Genislik</label>
|
||||||
<select class="prop-input prop-select"
|
<select
|
||||||
|
class="prop-input prop-select"
|
||||||
:value="element.size.width.type"
|
:value="element.size.width.type"
|
||||||
@change="(e) => {
|
@change="(e) => onTypeChange('width', (e.target as HTMLSelectElement).value)"
|
||||||
const t = (e.target as HTMLSelectElement).value
|
>
|
||||||
if (t === 'auto') updateSize('width', { type: 'auto' })
|
<option v-for="opt in sizeOptions" :key="opt.value" :value="opt.value">
|
||||||
else if (t === 'fr') updateSize('width', { type: 'fr', value: 1 })
|
{{ opt.label }}
|
||||||
else updateSize('width', { type: 'fixed', value: 50 })
|
</option>
|
||||||
}">
|
|
||||||
<option value="auto">Otomatik</option>
|
|
||||||
<option value="fixed">Sabit (mm)</option>
|
|
||||||
<option value="fr">Oran (fr)</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="element.size.width.type === 'fixed'" class="prop-row" data-tip="Sabit genislik degeri (mm)">
|
<PropNumberInput
|
||||||
<label class="prop-label">mm</label>
|
v-if="element.size.width.type === 'fixed'"
|
||||||
<input class="prop-input" type="number" step="1" min="1"
|
label="mm"
|
||||||
:value="(element.size.width as any).value"
|
:model-value="(element.size.width as any).value"
|
||||||
@input="(e) => updateSize('width', { type: 'fixed', value: parseFloat((e.target as HTMLInputElement).value) || 10 })" />
|
:step="1"
|
||||||
</div>
|
:min="1"
|
||||||
<div v-if="element.size.width.type === 'fr'" class="prop-row" data-tip="Kalan alani oransal doldurma degeri">
|
data-tip="Sabit genislik degeri (mm)"
|
||||||
<label class="prop-label">fr</label>
|
@update:model-value="(v) => updateSize('width', { type: 'fixed', value: v })"
|
||||||
<input class="prop-input" type="number" step="1" min="1"
|
/>
|
||||||
:value="(element.size.width as any).value"
|
<PropNumberInput
|
||||||
@input="(e) => updateSize('width', { type: 'fr', value: parseFloat((e.target as HTMLInputElement).value) || 1 })" />
|
v-if="element.size.width.type === 'fr'"
|
||||||
</div>
|
label="fr"
|
||||||
|
:model-value="(element.size.width as any).value"
|
||||||
|
:step="1"
|
||||||
|
:min="1"
|
||||||
|
data-tip="Kalan alani oransal doldurma degeri"
|
||||||
|
@update:model-value="(v) => updateSize('width', { type: 'fr', value: v })"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="prop-row" data-tip="Yukseklik boyutlandirma modu">
|
<div class="prop-row" data-tip="Yukseklik boyutlandirma modu">
|
||||||
<label class="prop-label">Yukseklik</label>
|
<label class="prop-label">Yukseklik</label>
|
||||||
<select class="prop-input prop-select"
|
<select
|
||||||
|
class="prop-input prop-select"
|
||||||
:value="element.size.height.type"
|
:value="element.size.height.type"
|
||||||
@change="(e) => {
|
@change="(e) => onTypeChange('height', (e.target as HTMLSelectElement).value)"
|
||||||
const t = (e.target as HTMLSelectElement).value
|
>
|
||||||
if (t === 'auto') updateSize('height', { type: 'auto' })
|
<option v-for="opt in sizeOptions" :key="opt.value" :value="opt.value">
|
||||||
else if (t === 'fr') updateSize('height', { type: 'fr', value: 1 })
|
{{ opt.label }}
|
||||||
else updateSize('height', { type: 'fixed', value: 20 })
|
</option>
|
||||||
}">
|
|
||||||
<option value="auto">Otomatik</option>
|
|
||||||
<option value="fixed">Sabit (mm)</option>
|
|
||||||
<option value="fr">Oran (fr)</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="element.size.height.type === 'fixed'" class="prop-row" data-tip="Sabit yukseklik degeri (mm)">
|
<PropNumberInput
|
||||||
<label class="prop-label">mm</label>
|
v-if="element.size.height.type === 'fixed'"
|
||||||
<input class="prop-input" type="number" step="1" min="1"
|
label="mm"
|
||||||
:value="(element.size.height as any).value"
|
:model-value="(element.size.height as any).value"
|
||||||
@input="(e) => updateSize('height', { type: 'fixed', value: parseFloat((e.target as HTMLInputElement).value) || 10 })" />
|
:step="1"
|
||||||
</div>
|
:min="1"
|
||||||
</div>
|
data-tip="Sabit yukseklik degeri (mm)"
|
||||||
|
@update:model-value="(v) => updateSize('height', { type: 'fixed', value: v })"
|
||||||
|
/>
|
||||||
|
<PropNumberInput
|
||||||
|
v-if="element.size.height.type === 'fr'"
|
||||||
|
label="fr"
|
||||||
|
:model-value="(element.size.height as any).value"
|
||||||
|
:step="1"
|
||||||
|
:min="1"
|
||||||
|
data-tip="Kalan alani oransal doldurma degeri"
|
||||||
|
@update:model-value="(v) => updateSize('height', { type: 'fr', value: v })"
|
||||||
|
/>
|
||||||
|
</PropSection>
|
||||||
|
|
||||||
|
<PropSection title="Min / Max">
|
||||||
|
<PropNumberInput
|
||||||
|
label="Min Gen."
|
||||||
|
:model-value="element.size.minWidth ?? 0"
|
||||||
|
:step="1"
|
||||||
|
:min="0"
|
||||||
|
data-tip="Minimum genislik (mm) — 0 = sinir yok"
|
||||||
|
@update:model-value="(v) => updateSizeConstraint('minWidth', v > 0 ? v : undefined)"
|
||||||
|
/>
|
||||||
|
<PropNumberInput
|
||||||
|
label="Max Gen."
|
||||||
|
:model-value="element.size.maxWidth ?? 0"
|
||||||
|
:step="1"
|
||||||
|
:min="0"
|
||||||
|
data-tip="Maksimum genislik (mm) — 0 = sinir yok"
|
||||||
|
@update:model-value="(v) => updateSizeConstraint('maxWidth', v > 0 ? v : undefined)"
|
||||||
|
/>
|
||||||
|
<PropNumberInput
|
||||||
|
label="Min Yuk."
|
||||||
|
:model-value="element.size.minHeight ?? 0"
|
||||||
|
:step="1"
|
||||||
|
:min="0"
|
||||||
|
data-tip="Minimum yukseklik (mm) — 0 = sinir yok"
|
||||||
|
@update:model-value="(v) => updateSizeConstraint('minHeight', v > 0 ? v : undefined)"
|
||||||
|
/>
|
||||||
|
<PropNumberInput
|
||||||
|
label="Max Yuk."
|
||||||
|
:model-value="element.size.maxHeight ?? 0"
|
||||||
|
:step="1"
|
||||||
|
:min="0"
|
||||||
|
data-tip="Maksimum yukseklik (mm) — 0 = sinir yok"
|
||||||
|
@update:model-value="(v) => updateSizeConstraint('maxHeight', v > 0 ? v : undefined)"
|
||||||
|
/>
|
||||||
|
</PropSection>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,65 +1,66 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useTemplateStore } from '../../stores/template'
|
import { computed } from 'vue'
|
||||||
import { useEditorStore } from '../../stores/editor'
|
import { usePropertyUpdate } from '../../composables/usePropertyUpdate'
|
||||||
import type { StaticTextElement, TextStyle, TemplateElement } from '../../core/types'
|
import { useSchemaStore } from '../../stores/schema'
|
||||||
|
import PropSection from './shared/PropSection.vue'
|
||||||
|
import PropFieldSelect from './shared/PropFieldSelect.vue'
|
||||||
|
import PropTextStyleGroup from './shared/PropTextStyleGroup.vue'
|
||||||
|
import type { StaticTextElement, TextElement, TextStyle, TemplateElement } from '../../core/types'
|
||||||
import '../../styles/properties.css'
|
import '../../styles/properties.css'
|
||||||
|
|
||||||
const props = defineProps<{ element: TemplateElement }>()
|
const props = defineProps<{ element: TemplateElement }>()
|
||||||
const templateStore = useTemplateStore()
|
const { update, updateStyle } = usePropertyUpdate(() => props.element)
|
||||||
const editorStore = useEditorStore()
|
const schemaStore = useSchemaStore()
|
||||||
|
const style = () => props.element.style as TextStyle
|
||||||
|
|
||||||
function update(updates: Partial<TemplateElement>) {
|
const isText = computed(() => props.element.type === 'text')
|
||||||
const id = editorStore.selectedElementId
|
|
||||||
if (!id) return
|
|
||||||
templateStore.updateElement(id, updates)
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateStyle(key: string, value: unknown) {
|
|
||||||
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="prop-section">
|
<PropSection title="Metin">
|
||||||
<div class="prop-section__title">Metin Stili</div>
|
|
||||||
|
|
||||||
<div v-if="element.type === 'static_text'" class="prop-row" data-tip="Sabit metin icerigi">
|
<div v-if="element.type === 'static_text'" class="prop-row" data-tip="Sabit metin icerigi">
|
||||||
<label class="prop-label">Metin</label>
|
<label class="prop-label">Metin</label>
|
||||||
<input class="prop-input" type="text"
|
<input
|
||||||
|
class="prop-input"
|
||||||
|
type="text"
|
||||||
:value="(element as StaticTextElement).content"
|
:value="(element as StaticTextElement).content"
|
||||||
@input="(e) => update({ content: (e.target as HTMLInputElement).value } as any)" />
|
@input="(e) => update({ content: (e.target as HTMLInputElement).value } as any)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="prop-row" data-tip="Yazi tipi boyutu (point)">
|
<template v-if="isText">
|
||||||
<label class="prop-label">Boyut (pt)</label>
|
<PropFieldSelect
|
||||||
<input class="prop-input" type="number" step="1" min="1"
|
label="Veri Alani"
|
||||||
:value="(element.style as TextStyle).fontSize ?? 11"
|
:model-value="(element as TextElement).binding?.path ?? ''"
|
||||||
@input="(e) => updateStyle('fontSize', parseFloat((e.target as HTMLInputElement).value) || 11)" />
|
:fields="schemaStore.scalarFields"
|
||||||
</div>
|
data-tip="Metnin baglanacagi veri alani"
|
||||||
<div class="prop-row" data-tip="Yazi tipi kalinligi">
|
@update:model-value="(v) => update({ binding: { type: 'scalar', path: v } } as any)"
|
||||||
<label class="prop-label">Kalinlik</label>
|
/>
|
||||||
<select class="prop-input prop-select"
|
<div class="prop-row" data-tip="Veri alaninin onune eklenecek sabit metin">
|
||||||
:value="(element.style as TextStyle).fontWeight ?? 'normal'"
|
<label class="prop-label">Ön Ek</label>
|
||||||
@change="(e) => updateStyle('fontWeight', (e.target as HTMLSelectElement).value)">
|
<input
|
||||||
<option value="normal">Normal</option>
|
class="prop-input"
|
||||||
<option value="bold">Kalin</option>
|
type="text"
|
||||||
</select>
|
:value="(element as TextElement).content ?? ''"
|
||||||
</div>
|
placeholder="ör: Fatura No: "
|
||||||
<div class="prop-row" data-tip="Metin rengi">
|
@input="(e) => update({ content: (e.target as HTMLInputElement).value || undefined } as any)"
|
||||||
<label class="prop-label">Renk</label>
|
/>
|
||||||
<input class="prop-input prop-color" type="color"
|
|
||||||
:value="(element.style as TextStyle).color ?? '#000000'"
|
|
||||||
@input="(e) => updateStyle('color', (e.target as HTMLInputElement).value)" />
|
|
||||||
</div>
|
|
||||||
<div class="prop-row" data-tip="Metnin yatay hizalamasi">
|
|
||||||
<label class="prop-label">Hizalama</label>
|
|
||||||
<select class="prop-input prop-select"
|
|
||||||
:value="(element.style as TextStyle).align ?? 'left'"
|
|
||||||
@change="(e) => updateStyle('align', (e.target as HTMLSelectElement).value)">
|
|
||||||
<option value="left">Sol</option>
|
|
||||||
<option value="center">Orta</option>
|
|
||||||
<option value="right">Sag</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
</PropSection>
|
||||||
|
|
||||||
|
<PropSection title="Metin Stili">
|
||||||
|
<PropTextStyleGroup
|
||||||
|
:font-size="style().fontSize ?? 11"
|
||||||
|
:font-weight="style().fontWeight ?? 'normal'"
|
||||||
|
:font-family="style().fontFamily"
|
||||||
|
:color="style().color ?? '#000000'"
|
||||||
|
:align="style().align ?? 'left'"
|
||||||
|
@update:font-size="(v) => updateStyle('fontSize', v)"
|
||||||
|
@update:font-weight="(v) => updateStyle('fontWeight', v)"
|
||||||
|
@update:font-family="(v) => updateStyle('fontFamily', v)"
|
||||||
|
@update:color="(v) => updateStyle('color', v)"
|
||||||
|
@update:align="(v) => updateStyle('align', v)"
|
||||||
|
/>
|
||||||
|
</PropSection>
|
||||||
|
</template>
|
||||||
|
|||||||
20
frontend/src/components/properties/shared/PropCheckbox.vue
Normal file
20
frontend/src/components/properties/shared/PropCheckbox.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
label: string
|
||||||
|
modelValue: boolean
|
||||||
|
dataTip?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="prop-row" :data-tip="dataTip">
|
||||||
|
<label class="prop-label">{{ label }}</label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="modelValue"
|
||||||
|
@change="(e) => emit('update:modelValue', (e.target as HTMLInputElement).checked)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
38
frontend/src/components/properties/shared/PropColorInput.vue
Normal file
38
frontend/src/components/properties/shared/PropColorInput.vue
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
label: string
|
||||||
|
modelValue: string | undefined
|
||||||
|
defaultColor?: string
|
||||||
|
clearable?: boolean
|
||||||
|
dataTip?: string
|
||||||
|
}>(),
|
||||||
|
{ defaultColor: '#000000', clearable: false },
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: string | undefined] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="prop-row" :data-tip="dataTip">
|
||||||
|
<label class="prop-label">{{ label }}</label>
|
||||||
|
<div v-if="clearable" class="prop-row-inline">
|
||||||
|
<input
|
||||||
|
class="prop-input prop-color"
|
||||||
|
type="color"
|
||||||
|
:value="modelValue ?? defaultColor"
|
||||||
|
@input="(e) => emit('update:modelValue', (e.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
<button v-if="modelValue" class="prop-clear" @click="emit('update:modelValue', undefined)">
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
class="prop-input prop-color"
|
||||||
|
type="color"
|
||||||
|
:value="modelValue ?? defaultColor"
|
||||||
|
@input="(e) => emit('update:modelValue', (e.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
84
frontend/src/components/properties/shared/PropCondition.vue
Normal file
84
frontend/src/components/properties/shared/PropCondition.vue
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useSchemaStore } from '../../../stores/schema'
|
||||||
|
import PropFieldSelect from './PropFieldSelect.vue'
|
||||||
|
import PropSelect from './PropSelect.vue'
|
||||||
|
import PropSection from './PropSection.vue'
|
||||||
|
import type { Condition } from '../../../core/types'
|
||||||
|
import '../../../styles/properties.css'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
condition?: Condition
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:condition': [value: Condition | undefined]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const schemaStore = useSchemaStore()
|
||||||
|
|
||||||
|
const enabled = computed(() => !!props.condition)
|
||||||
|
|
||||||
|
const operatorOptions = [
|
||||||
|
{ value: 'eq', label: '= Esit' },
|
||||||
|
{ value: 'neq', label: '≠ Esit Degil' },
|
||||||
|
{ value: 'gt', label: '> Buyuk' },
|
||||||
|
{ value: 'gte', label: '>= Buyuk Esit' },
|
||||||
|
{ value: 'lt', label: '< Kucuk' },
|
||||||
|
{ value: 'lte', label: '<= Kucuk Esit' },
|
||||||
|
{ value: 'truthy', label: 'Dolu (truthy)' },
|
||||||
|
{ value: 'falsy', label: 'Bos (falsy)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const needsValue = computed(() => {
|
||||||
|
const op = props.condition?.operator
|
||||||
|
return op && op !== 'truthy' && op !== 'falsy'
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggle(on: boolean) {
|
||||||
|
if (on) {
|
||||||
|
emit('update:condition', { path: '', operator: 'truthy' })
|
||||||
|
} else {
|
||||||
|
emit('update:condition', undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateField(key: keyof Condition, value: unknown) {
|
||||||
|
emit('update:condition', { ...props.condition!, [key]: value })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PropSection title="Kosullu Gosterim">
|
||||||
|
<div class="prop-row" data-tip="Elemani belirli bir kosulla goster/gizle">
|
||||||
|
<label class="prop-label">Aktif</label>
|
||||||
|
<input type="checkbox" :checked="enabled" @change="toggle(($event.target as HTMLInputElement).checked)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="enabled">
|
||||||
|
<PropFieldSelect
|
||||||
|
label="Alan"
|
||||||
|
:model-value="condition!.path"
|
||||||
|
:fields="schemaStore.scalarFields"
|
||||||
|
data-tip="Kosulun degerlendirilecegi veri alani"
|
||||||
|
@update:model-value="(v) => updateField('path', v)"
|
||||||
|
/>
|
||||||
|
<PropSelect
|
||||||
|
label="Operator"
|
||||||
|
:model-value="condition!.operator"
|
||||||
|
:options="operatorOptions"
|
||||||
|
data-tip="Karsilastirma operatoru"
|
||||||
|
@update:model-value="(v) => updateField('operator', v)"
|
||||||
|
/>
|
||||||
|
<div v-if="needsValue" class="prop-row" data-tip="Karsilastirilacak deger">
|
||||||
|
<label class="prop-label">Deger</label>
|
||||||
|
<input
|
||||||
|
class="prop-input"
|
||||||
|
type="text"
|
||||||
|
:value="condition!.value ?? ''"
|
||||||
|
@input="(e) => updateField('value', (e.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</PropSection>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
label: string
|
||||||
|
modelValue: string
|
||||||
|
fields: Array<{ path?: string; key?: string; title?: string; type?: string }>
|
||||||
|
placeholder?: string
|
||||||
|
allowEmpty?: boolean
|
||||||
|
emptyLabel?: string
|
||||||
|
dataTip?: string
|
||||||
|
}>(),
|
||||||
|
{ placeholder: 'Secin...', allowEmpty: false, emptyLabel: 'Yok' },
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="prop-row" :data-tip="dataTip">
|
||||||
|
<label class="prop-label">{{ label }}</label>
|
||||||
|
<select
|
||||||
|
class="prop-input prop-select"
|
||||||
|
:value="modelValue"
|
||||||
|
@change="(e) => emit('update:modelValue', (e.target as HTMLSelectElement).value)"
|
||||||
|
>
|
||||||
|
<option v-if="allowEmpty" value="">{{ emptyLabel }}</option>
|
||||||
|
<option v-else value="" disabled>{{ placeholder }}</option>
|
||||||
|
<option
|
||||||
|
v-for="field in fields"
|
||||||
|
:key="field.path ?? field.key"
|
||||||
|
:value="field.path ?? field.key"
|
||||||
|
>
|
||||||
|
{{ field.title ?? field.path ?? field.key }}
|
||||||
|
<template v-if="field.path">({{ field.path }})</template>
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
label: string
|
||||||
|
modelValue: number
|
||||||
|
step?: number
|
||||||
|
min?: number
|
||||||
|
max?: number
|
||||||
|
dataTip?: string
|
||||||
|
}>(),
|
||||||
|
{ step: 1, min: 0 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: number] }>()
|
||||||
|
|
||||||
|
function onInput(e: Event) {
|
||||||
|
const val = parseFloat((e.target as HTMLInputElement).value)
|
||||||
|
if (!isNaN(val)) emit('update:modelValue', val)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="prop-row" :data-tip="dataTip">
|
||||||
|
<label class="prop-label">{{ label }}</label>
|
||||||
|
<input
|
||||||
|
class="prop-input"
|
||||||
|
type="number"
|
||||||
|
:step="step"
|
||||||
|
:min="min"
|
||||||
|
:max="max"
|
||||||
|
:value="modelValue"
|
||||||
|
@input="onInput"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
46
frontend/src/components/properties/shared/PropSection.vue
Normal file
46
frontend/src/components/properties/shared/PropSection.vue
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{ title: string; defaultOpen?: boolean }>(), {
|
||||||
|
defaultOpen: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const open = ref(props.defaultOpen)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="prop-section">
|
||||||
|
<div class="prop-section__title prop-section__title--collapsible" @click="open = !open">
|
||||||
|
<span class="prop-section__chevron" :class="{ 'prop-section__chevron--closed': !open }"
|
||||||
|
>▾</span
|
||||||
|
>
|
||||||
|
{{ title }}
|
||||||
|
<span class="prop-section__actions" @click.stop><slot name="actions" /></span>
|
||||||
|
</div>
|
||||||
|
<template v-if="open"><slot /></template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.prop-section__title--collapsible {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-section__chevron {
|
||||||
|
font-size: 8px;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-section__chevron--closed {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-section__actions {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
23
frontend/src/components/properties/shared/PropSelect.vue
Normal file
23
frontend/src/components/properties/shared/PropSelect.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
label: string
|
||||||
|
modelValue: string
|
||||||
|
options: Array<{ value: string; label: string }>
|
||||||
|
dataTip?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="prop-row" :data-tip="dataTip">
|
||||||
|
<label class="prop-label">{{ label }}</label>
|
||||||
|
<select
|
||||||
|
class="prop-input prop-select"
|
||||||
|
:value="modelValue"
|
||||||
|
@change="(e) => emit('update:modelValue', (e.target as HTMLSelectElement).value)"
|
||||||
|
>
|
||||||
|
<option v-for="opt in options" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useTemplateStore } from '../../../stores/template'
|
||||||
|
import PropNumberInput from './PropNumberInput.vue'
|
||||||
|
import PropColorInput from './PropColorInput.vue'
|
||||||
|
import PropSelect from './PropSelect.vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
fontSize: number
|
||||||
|
fontWeight?: string
|
||||||
|
fontFamily?: string
|
||||||
|
color: string
|
||||||
|
align: string
|
||||||
|
showWeight?: boolean
|
||||||
|
}>(),
|
||||||
|
{ fontWeight: 'normal', fontFamily: undefined, showWeight: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
'update:fontSize': [value: number]
|
||||||
|
'update:fontWeight': [value: string]
|
||||||
|
'update:fontFamily': [value: string | undefined]
|
||||||
|
'update:color': [value: string]
|
||||||
|
'update:align': [value: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const templateStore = useTemplateStore()
|
||||||
|
|
||||||
|
const fontOptions = computed(() =>
|
||||||
|
templateStore.template.fonts.map((f) => ({ value: f, label: f })),
|
||||||
|
)
|
||||||
|
|
||||||
|
const weightOptions = [
|
||||||
|
{ value: 'normal', label: 'Normal' },
|
||||||
|
{ value: 'bold', label: 'Kalin' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const alignOptions = [
|
||||||
|
{ value: 'left', label: 'Sol' },
|
||||||
|
{ value: 'center', label: 'Orta' },
|
||||||
|
{ value: 'right', label: 'Sag' },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PropSelect
|
||||||
|
v-if="fontOptions.length > 1"
|
||||||
|
label="Font"
|
||||||
|
:model-value="fontFamily ?? fontOptions[0]?.value ?? ''"
|
||||||
|
:options="fontOptions"
|
||||||
|
data-tip="Yazi tipi ailesi"
|
||||||
|
@update:model-value="$emit('update:fontFamily', $event)"
|
||||||
|
/>
|
||||||
|
<PropNumberInput
|
||||||
|
label="Boyut (pt)"
|
||||||
|
:model-value="fontSize"
|
||||||
|
:step="1"
|
||||||
|
:min="1"
|
||||||
|
data-tip="Yazi tipi boyutu (point)"
|
||||||
|
@update:model-value="$emit('update:fontSize', $event)"
|
||||||
|
/>
|
||||||
|
<PropSelect
|
||||||
|
v-if="showWeight"
|
||||||
|
label="Kalinlik"
|
||||||
|
:model-value="fontWeight!"
|
||||||
|
:options="weightOptions"
|
||||||
|
data-tip="Yazi tipi kalinligi"
|
||||||
|
@update:model-value="$emit('update:fontWeight', $event)"
|
||||||
|
/>
|
||||||
|
<PropColorInput
|
||||||
|
label="Renk"
|
||||||
|
:model-value="color"
|
||||||
|
data-tip="Metin rengi"
|
||||||
|
@update:model-value="$emit('update:color', $event!)"
|
||||||
|
/>
|
||||||
|
<PropSelect
|
||||||
|
label="Hizalama"
|
||||||
|
:model-value="align"
|
||||||
|
:options="alignOptions"
|
||||||
|
data-tip="Metnin yatay hizalamasi"
|
||||||
|
@update:model-value="$emit('update:align', $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
387
frontend/src/components/properties/table/TableColumnEditor.vue
Normal file
387
frontend/src/components/properties/table/TableColumnEditor.vue
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { defaultAlignForSchema, schemaFormatToFormatType } from '../../../core/schema-parser'
|
||||||
|
import type { TableColumn, FormatType } from '../../../core/types'
|
||||||
|
|
||||||
|
type ItemField = { key: string; title: string; type?: string; format?: string }
|
||||||
|
import '../../../styles/properties.css'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
column: TableColumn
|
||||||
|
itemFields: ItemField[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
update: [colId: string, updates: Partial<TableColumn>]
|
||||||
|
remove: [colId: string]
|
||||||
|
move: [colId: string, direction: -1 | 1]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="tbl-col">
|
||||||
|
<!-- Row 1: title + actions -->
|
||||||
|
<div class="tbl-col__head">
|
||||||
|
<input
|
||||||
|
class="tbl-col__title"
|
||||||
|
type="text"
|
||||||
|
:value="column.title"
|
||||||
|
@change="(e) => emit('update', column.id, { title: (e.target as HTMLInputElement).value })"
|
||||||
|
:placeholder="column.field"
|
||||||
|
data-tip="Sutun basligi"
|
||||||
|
/>
|
||||||
|
<div class="tbl-col__actions">
|
||||||
|
<button class="tbl-col__act" @click="emit('move', column.id, -1)" data-tip="Yukari tasi">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||||
|
<path d="M5 2L2 6h6L5 2z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="tbl-col__act" @click="emit('move', column.id, 1)" data-tip="Asagi tasi">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||||
|
<path d="M5 8L2 4h6L5 8z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tbl-col__act tbl-col__act--del"
|
||||||
|
@click="emit('remove', column.id)"
|
||||||
|
data-tip="Sutunu sil"
|
||||||
|
>
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||||
|
<path
|
||||||
|
d="M2 2l6 6M8 2l-6 6"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 2: field + align -->
|
||||||
|
<div class="tbl-col__controls">
|
||||||
|
<select
|
||||||
|
v-if="itemFields.length > 0"
|
||||||
|
class="tbl-col__field"
|
||||||
|
:value="column.field"
|
||||||
|
data-tip="Veri alani"
|
||||||
|
@change="
|
||||||
|
(e) => {
|
||||||
|
const field = (e.target as HTMLSelectElement).value
|
||||||
|
const node = itemFields.find((f) => f.key === field)
|
||||||
|
if (node) {
|
||||||
|
emit('update', column.id, {
|
||||||
|
field,
|
||||||
|
title: node.title,
|
||||||
|
align: defaultAlignForSchema(node as any),
|
||||||
|
format: schemaFormatToFormatType(node.format),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
emit('update', column.id, { field })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<option v-for="f in itemFields" :key="f.key" :value="f.key">{{ f.key }}</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
class="tbl-col__field"
|
||||||
|
type="text"
|
||||||
|
:value="column.field"
|
||||||
|
@change="(e) => emit('update', column.id, { field: (e.target as HTMLInputElement).value })"
|
||||||
|
data-tip="Veri alani"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Alignment icons -->
|
||||||
|
<div class="tbl-col__align">
|
||||||
|
<button
|
||||||
|
class="tbl-col__align-btn"
|
||||||
|
:class="{ 'tbl-col__align-btn--on': column.align === 'left' }"
|
||||||
|
@click="emit('update', column.id, { align: 'left' })"
|
||||||
|
data-tip="Sola hizala"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12">
|
||||||
|
<line x1="1" y1="3" x2="11" y2="3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
|
||||||
|
<line x1="1" y1="6" x2="8" y2="6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
|
||||||
|
<line x1="1" y1="9" x2="10" y2="9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tbl-col__align-btn"
|
||||||
|
:class="{ 'tbl-col__align-btn--on': column.align === 'center' }"
|
||||||
|
@click="emit('update', column.id, { align: 'center' })"
|
||||||
|
data-tip="Ortala"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12">
|
||||||
|
<line x1="1" y1="3" x2="11" y2="3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
|
||||||
|
<line x1="2.5" y1="6" x2="9.5" y2="6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
|
||||||
|
<line x1="1.5" y1="9" x2="10.5" y2="9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tbl-col__align-btn"
|
||||||
|
:class="{ 'tbl-col__align-btn--on': column.align === 'right' }"
|
||||||
|
@click="emit('update', column.id, { align: 'right' })"
|
||||||
|
data-tip="Saga hizala"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12">
|
||||||
|
<line x1="1" y1="3" x2="11" y2="3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
|
||||||
|
<line x1="4" y1="6" x2="11" y2="6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
|
||||||
|
<line x1="2" y1="9" x2="11" y2="9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 3: format + width -->
|
||||||
|
<div class="tbl-col__extra" data-tip="Veri gosterim formati">
|
||||||
|
<label class="tbl-col__elabel">Format</label>
|
||||||
|
<select
|
||||||
|
class="tbl-col__fmt"
|
||||||
|
:value="column.format ?? ''"
|
||||||
|
@change="
|
||||||
|
(e) =>
|
||||||
|
emit('update', column.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="tbl-col__extra" data-tip="Sutun genislik modu">
|
||||||
|
<label class="tbl-col__elabel">Genislik</label>
|
||||||
|
<select
|
||||||
|
class="tbl-col__wtype"
|
||||||
|
:value="column.width.type"
|
||||||
|
@change="
|
||||||
|
(e) => {
|
||||||
|
const t = (e.target as HTMLSelectElement).value
|
||||||
|
if (t === 'auto') emit('update', column.id, { width: { type: 'auto' } })
|
||||||
|
else if (t === 'fr') emit('update', column.id, { width: { type: 'fr', value: 1 } })
|
||||||
|
else emit('update', column.id, { width: { type: 'fixed', value: 30 } })
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<option value="auto">Otomatik</option>
|
||||||
|
<option value="fixed">Sabit (mm)</option>
|
||||||
|
<option value="fr">Oran (fr)</option>
|
||||||
|
</select>
|
||||||
|
<span
|
||||||
|
v-if="column.width.type === 'fixed' || column.width.type === 'fr'"
|
||||||
|
class="ts-tip-wrap"
|
||||||
|
:data-tip="column.width.type === 'fixed' ? 'Sabit genislik (mm)' : 'Oran degeri (fr)'"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
class="tbl-col__wval"
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
:min="column.width.type === 'fixed' ? 5 : 1"
|
||||||
|
:value="(column.width as any).value"
|
||||||
|
@change="
|
||||||
|
(e) =>
|
||||||
|
emit('update', column.id, {
|
||||||
|
width: {
|
||||||
|
type: column.width.type,
|
||||||
|
value:
|
||||||
|
parseFloat((e.target as HTMLInputElement).value) ||
|
||||||
|
(column.width.type === 'fixed' ? 30 : 1),
|
||||||
|
} as any,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tbl-col {
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 5px 6px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tbl-col__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tbl-col__title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #334155;
|
||||||
|
padding: 1px 0;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tbl-col__title:focus {
|
||||||
|
border-bottom: 1px solid #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tbl-col__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tbl-col__act {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: transparent;
|
||||||
|
color: #94a3b8;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tbl-col__act:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tbl-col__act--del:hover {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tbl-col__controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tbl-col__field {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
background: white;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tbl-col__field:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tbl-col__align {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tbl-col__align-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: white;
|
||||||
|
color: #94a3b8;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tbl-col__align-btn:first-child {
|
||||||
|
border-radius: 3px 0 0 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tbl-col__align-btn:last-child {
|
||||||
|
border-radius: 0 3px 3px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tbl-col__align-btn:not(:first-child) {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tbl-col__align-btn--on {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tbl-col__extra {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tbl-col__elabel {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tbl-col__fmt {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
background: white;
|
||||||
|
color: #334155;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tbl-col__wtype {
|
||||||
|
width: 80px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
background: white;
|
||||||
|
color: #334155;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tbl-col__wval {
|
||||||
|
width: 36px;
|
||||||
|
padding: 2px 3px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
background: white;
|
||||||
|
color: #334155;
|
||||||
|
text-align: center;
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tbl-col__wval::-webkit-inner-spin-button,
|
||||||
|
.tbl-col__wval::-webkit-outer-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tbl-col__wval:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-tip-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
384
frontend/src/components/properties/table/TableStyleEditor.vue
Normal file
384
frontend/src/components/properties/table/TableStyleEditor.vue
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { TableStyle } from '../../../core/types'
|
||||||
|
import '../../../styles/properties.css'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
style: TableStyle
|
||||||
|
repeatHeader: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:style': [key: string, value: unknown]
|
||||||
|
'update:repeatHeader': [value: boolean]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ts-form">
|
||||||
|
<!-- Font sizes -->
|
||||||
|
<label class="ts-lbl" data-tip="Icerik ve header yazi boyutu (pt)">Yazi boyutu</label>
|
||||||
|
<div class="ts-val ts-val--pair">
|
||||||
|
<span class="ts-sep">Icerik</span>
|
||||||
|
<span class="ts-tip-wrap" data-tip="Icerik yazi boyutu (pt)">
|
||||||
|
<input
|
||||||
|
class="ts-num"
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
min="6"
|
||||||
|
max="99"
|
||||||
|
:value="style.fontSize ?? 10"
|
||||||
|
@input="(e) => emit('update:style', 'fontSize', parseFloat((e.target as HTMLInputElement).value) || 10)"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span class="ts-sep">Header</span>
|
||||||
|
<span class="ts-tip-wrap" data-tip="Header yazi boyutu (pt)">
|
||||||
|
<input
|
||||||
|
class="ts-num"
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
min="6"
|
||||||
|
max="99"
|
||||||
|
:value="style.headerFontSize ?? style.fontSize ?? 10"
|
||||||
|
@input="(e) => emit('update:style', 'headerFontSize', parseFloat((e.target as HTMLInputElement).value) || 10)"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Colors -->
|
||||||
|
<label class="ts-lbl" data-tip="Header, metin ve zebra satirlari renkleri">Renkler</label>
|
||||||
|
<div class="ts-val ts-val--colors">
|
||||||
|
<div class="ts-color-item" data-tip="Header arkaplan rengi">
|
||||||
|
<input
|
||||||
|
class="ts-swatch"
|
||||||
|
type="color"
|
||||||
|
:value="style.headerBg ?? '#f0f0f0'"
|
||||||
|
@input="(e) => emit('update:style', 'headerBg', (e.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
<span class="ts-clbl">Arkaplan</span>
|
||||||
|
</div>
|
||||||
|
<div class="ts-color-item" data-tip="Header metin rengi">
|
||||||
|
<input
|
||||||
|
class="ts-swatch"
|
||||||
|
type="color"
|
||||||
|
:value="style.headerColor ?? '#000000'"
|
||||||
|
@input="(e) => emit('update:style', 'headerColor', (e.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
<span class="ts-clbl">Metin</span>
|
||||||
|
</div>
|
||||||
|
<div class="ts-color-item" data-tip="Zebra satir rengi — tek satirlar">
|
||||||
|
<div class="ts-swatch-wrap">
|
||||||
|
<input
|
||||||
|
class="ts-swatch"
|
||||||
|
type="color"
|
||||||
|
:value="style.zebraOdd ?? '#fafafa'"
|
||||||
|
@input="(e) => emit('update:style', 'zebraOdd', (e.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="style.zebraOdd"
|
||||||
|
class="ts-swatch-clr"
|
||||||
|
@click="emit('update:style', 'zebraOdd', undefined)"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span class="ts-clbl">Tek</span>
|
||||||
|
</div>
|
||||||
|
<div class="ts-color-item" data-tip="Zebra satir rengi — cift satirlar">
|
||||||
|
<div class="ts-swatch-wrap">
|
||||||
|
<input
|
||||||
|
class="ts-swatch"
|
||||||
|
type="color"
|
||||||
|
:value="style.zebraEven ?? '#ffffff'"
|
||||||
|
@input="(e) => emit('update:style', 'zebraEven', (e.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="style.zebraEven"
|
||||||
|
class="ts-swatch-clr"
|
||||||
|
@click="emit('update:style', 'zebraEven', undefined)"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span class="ts-clbl">Cift</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Border -->
|
||||||
|
<label class="ts-lbl" data-tip="Tablo kenarlik rengi ve kalinligi">Kenarlik</label>
|
||||||
|
<div class="ts-val ts-val--pair">
|
||||||
|
<div class="ts-swatch-wrap" data-tip="Kenarlik rengi">
|
||||||
|
<input
|
||||||
|
class="ts-swatch"
|
||||||
|
type="color"
|
||||||
|
:value="style.borderColor ?? '#cccccc'"
|
||||||
|
@input="(e) => emit('update:style', 'borderColor', (e.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="style.borderColor"
|
||||||
|
class="ts-swatch-clr"
|
||||||
|
@click="emit('update:style', 'borderColor', undefined)"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span class="ts-tip-wrap" data-tip="Kenarlik kalinligi (mm)">
|
||||||
|
<input
|
||||||
|
class="ts-num"
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min="0"
|
||||||
|
max="99"
|
||||||
|
:value="style.borderWidth ?? 0.5"
|
||||||
|
@input="(e) => emit('update:style', 'borderWidth', parseFloat((e.target as HTMLInputElement).value) || 0)"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span class="ts-unit">mm</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cell padding -->
|
||||||
|
<label class="ts-lbl" data-tip="Hucre ic bosluklari — yatay ve dikey (mm)">Ic bosluk</label>
|
||||||
|
<div class="ts-val ts-val--pair">
|
||||||
|
<span class="ts-pad-icon" data-tip="Yatay bosluk (mm)">↔</span>
|
||||||
|
<span class="ts-tip-wrap" data-tip="Yatay ic bosluk (mm)">
|
||||||
|
<input
|
||||||
|
class="ts-num"
|
||||||
|
type="number"
|
||||||
|
step="0.5"
|
||||||
|
min="0"
|
||||||
|
max="99"
|
||||||
|
:value="style.cellPaddingH ?? 2"
|
||||||
|
@input="(e) => emit('update:style', 'cellPaddingH', parseFloat((e.target as HTMLInputElement).value) || 0)"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span class="ts-pad-icon" data-tip="Dikey bosluk (mm)">↕</span>
|
||||||
|
<span class="ts-tip-wrap" data-tip="Dikey ic bosluk (mm)">
|
||||||
|
<input
|
||||||
|
class="ts-num"
|
||||||
|
type="number"
|
||||||
|
step="0.5"
|
||||||
|
min="0"
|
||||||
|
max="99"
|
||||||
|
:value="style.cellPaddingV ?? 1"
|
||||||
|
@input="(e) => emit('update:style', 'cellPaddingV', parseFloat((e.target as HTMLInputElement).value) || 0)"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Header padding -->
|
||||||
|
<label class="ts-lbl" data-tip="Header hucre bosluklari — yatay ve dikey (mm)">Header bosluk</label>
|
||||||
|
<div class="ts-val ts-val--pair">
|
||||||
|
<span class="ts-pad-icon" data-tip="Yatay bosluk (mm)">↔</span>
|
||||||
|
<span class="ts-tip-wrap" data-tip="Header yatay bosluk (mm)">
|
||||||
|
<input
|
||||||
|
class="ts-num"
|
||||||
|
type="number"
|
||||||
|
step="0.5"
|
||||||
|
min="0"
|
||||||
|
max="99"
|
||||||
|
:value="style.headerPaddingH ?? style.cellPaddingH ?? 2"
|
||||||
|
@input="(e) => emit('update:style', 'headerPaddingH', parseFloat((e.target as HTMLInputElement).value) || 0)"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span class="ts-pad-icon" data-tip="Dikey bosluk (mm)">↕</span>
|
||||||
|
<span class="ts-tip-wrap" data-tip="Header dikey bosluk (mm)">
|
||||||
|
<input
|
||||||
|
class="ts-num"
|
||||||
|
type="number"
|
||||||
|
step="0.5"
|
||||||
|
min="0"
|
||||||
|
max="99"
|
||||||
|
:value="style.headerPaddingV ?? style.cellPaddingV ?? 1"
|
||||||
|
@input="(e) => emit('update:style', 'headerPaddingV', parseFloat((e.target as HTMLInputElement).value) || 0)"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Repeat header -->
|
||||||
|
<label class="ts-lbl" data-tip="Cok sayfali tablolarda header'i her sayfada tekrarla">Header tekrarla</label>
|
||||||
|
<div class="ts-val">
|
||||||
|
<label class="ts-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="repeatHeader"
|
||||||
|
@change="(e) => emit('update:repeatHeader', (e.target as HTMLInputElement).checked)"
|
||||||
|
/>
|
||||||
|
<span class="ts-toggle__track"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ts-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 5px 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-lbl {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-val {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-val--pair {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-val--colors {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-sep {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-num {
|
||||||
|
width: 32px;
|
||||||
|
padding: 2px 3px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
background: white;
|
||||||
|
color: #334155;
|
||||||
|
text-align: center;
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-num::-webkit-inner-spin-button,
|
||||||
|
.ts-num::-webkit-outer-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-num:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-unit {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-color-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-clbl {
|
||||||
|
font-size: 9px;
|
||||||
|
color: #94a3b8;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-swatch {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-swatch-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-swatch-clr {
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
right: -4px;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f1f5f9;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
font-size: 9px;
|
||||||
|
line-height: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #94a3b8;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-swatch-clr:hover {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
border-color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-pad-icon {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-tip-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-toggle {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-toggle input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-toggle__track {
|
||||||
|
display: block;
|
||||||
|
width: 28px;
|
||||||
|
height: 16px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-toggle__track::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-toggle input:checked + .ts-toggle__track {
|
||||||
|
background: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-toggle input:checked + .ts-toggle__track::after {
|
||||||
|
transform: translateX(12px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
189
frontend/src/composables/__tests__/useSnapGuides.test.ts
Normal file
189
frontend/src/composables/__tests__/useSnapGuides.test.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { useSnapGuides } from '../useSnapGuides'
|
||||||
|
import type { ElementLayout } from '../../core/layout-types'
|
||||||
|
|
||||||
|
function makeLayout(
|
||||||
|
id: string,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
): ElementLayout {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
x_mm: x,
|
||||||
|
y_mm: y,
|
||||||
|
width_mm: w,
|
||||||
|
height_mm: h,
|
||||||
|
element_type: 'static_text',
|
||||||
|
style: {},
|
||||||
|
} as ElementLayout
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useSnapGuides', () => {
|
||||||
|
let guides: ReturnType<typeof useSnapGuides>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
guides = useSnapGuides()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('collectEdges', () => {
|
||||||
|
it('collects page edges and element edges', () => {
|
||||||
|
const layoutMap: Record<string, ElementLayout> = {
|
||||||
|
el1: makeLayout('el1', 10, 20, 50, 30),
|
||||||
|
}
|
||||||
|
|
||||||
|
guides.collectEdges(layoutMap, 'excluded', 210, 297)
|
||||||
|
|
||||||
|
// After collecting, calculateSnap should work
|
||||||
|
const result = guides.calculateSnap(0, 0, 10, 10)
|
||||||
|
expect(result).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes the dragged element', () => {
|
||||||
|
const layoutMap: Record<string, ElementLayout> = {
|
||||||
|
dragged: makeLayout('dragged', 50, 50, 20, 20),
|
||||||
|
other: makeLayout('other', 100, 100, 30, 30),
|
||||||
|
}
|
||||||
|
|
||||||
|
guides.collectEdges(layoutMap, 'dragged', 210, 297)
|
||||||
|
|
||||||
|
// Snap to "other" element's left edge (100mm)
|
||||||
|
const result = guides.calculateSnap(99.5, 50, 20, 20)
|
||||||
|
expect(result.snappedX_mm).toBe(100) // snaps to other's left edge
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('calculateSnap', () => {
|
||||||
|
it('returns proposed position when no edges cached', () => {
|
||||||
|
const result = guides.calculateSnap(42, 73, 10, 10)
|
||||||
|
|
||||||
|
expect(result.snappedX_mm).toBe(42)
|
||||||
|
expect(result.snappedY_mm).toBe(73)
|
||||||
|
expect(result.guides).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('snaps left edge to page left (0)', () => {
|
||||||
|
guides.collectEdges({}, 'none', 210, 297)
|
||||||
|
|
||||||
|
// Proposed x=0.5 → should snap to 0 (within 1.5mm threshold)
|
||||||
|
const result = guides.calculateSnap(0.5, 50, 20, 20)
|
||||||
|
expect(result.snappedX_mm).toBe(0)
|
||||||
|
expect(result.guides).toContainEqual({ type: 'vertical', position_mm: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('snaps right edge to page right', () => {
|
||||||
|
guides.collectEdges({}, 'none', 210, 297)
|
||||||
|
|
||||||
|
// Element 20mm wide, proposed x=189 → right edge = 209, should snap to 210
|
||||||
|
const result = guides.calculateSnap(189, 50, 20, 20)
|
||||||
|
expect(result.snappedX_mm).toBe(190) // 210 - 20 = 190
|
||||||
|
expect(result.guides).toContainEqual({ type: 'vertical', position_mm: 210 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('snaps center to page center', () => {
|
||||||
|
guides.collectEdges({}, 'none', 210, 297)
|
||||||
|
|
||||||
|
// Element 20mm wide, center at 105mm → x = 95
|
||||||
|
// Proposed x=94.5 → center = 104.5, should snap to 105 → x = 95
|
||||||
|
const result = guides.calculateSnap(94.5, 50, 20, 20)
|
||||||
|
expect(result.snappedX_mm).toBe(95) // 105 - 10 = 95
|
||||||
|
})
|
||||||
|
|
||||||
|
it('snaps top edge to page top', () => {
|
||||||
|
guides.collectEdges({}, 'none', 210, 297)
|
||||||
|
|
||||||
|
const result = guides.calculateSnap(50, 1.0, 20, 20)
|
||||||
|
expect(result.snappedY_mm).toBe(0)
|
||||||
|
expect(result.guides).toContainEqual({ type: 'horizontal', position_mm: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not snap when outside threshold', () => {
|
||||||
|
guides.collectEdges({}, 'none', 210, 297)
|
||||||
|
|
||||||
|
// Proposed x=50, far from any edge → no snap
|
||||||
|
const result = guides.calculateSnap(50, 50, 20, 20)
|
||||||
|
expect(result.snappedX_mm).toBe(50)
|
||||||
|
expect(result.snappedY_mm).toBe(50)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('snaps to other element edges', () => {
|
||||||
|
const layoutMap: Record<string, ElementLayout> = {
|
||||||
|
ref: makeLayout('ref', 30, 40, 50, 20),
|
||||||
|
}
|
||||||
|
guides.collectEdges(layoutMap, 'dragged', 210, 297)
|
||||||
|
|
||||||
|
// Snap dragged element's left to ref's right (30+50=80)
|
||||||
|
const result = guides.calculateSnap(79.5, 50, 20, 20)
|
||||||
|
expect(result.snappedX_mm).toBe(80)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('snaps both axes simultaneously', () => {
|
||||||
|
guides.collectEdges({}, 'none', 210, 297)
|
||||||
|
|
||||||
|
// Near page origin
|
||||||
|
const result = guides.calculateSnap(0.5, 0.5, 20, 20)
|
||||||
|
expect(result.snappedX_mm).toBe(0)
|
||||||
|
expect(result.snappedY_mm).toBe(0)
|
||||||
|
expect(result.guides).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates activeGuides ref', () => {
|
||||||
|
guides.collectEdges({}, 'none', 210, 297)
|
||||||
|
|
||||||
|
guides.calculateSnap(0.5, 0.5, 20, 20)
|
||||||
|
expect(guides.activeGuides.value.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('calculateResizeSnap', () => {
|
||||||
|
it('returns proposed value when no edges', () => {
|
||||||
|
const result = guides.calculateResizeSnap('right', 42)
|
||||||
|
expect(result).toBe(42)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('snaps right edge to nearest vertical', () => {
|
||||||
|
const layoutMap: Record<string, ElementLayout> = {
|
||||||
|
ref: makeLayout('ref', 100, 50, 40, 20),
|
||||||
|
}
|
||||||
|
guides.collectEdges(layoutMap, 'resizing', 210, 297)
|
||||||
|
|
||||||
|
// Snap to ref's left edge (100mm)
|
||||||
|
const result = guides.calculateResizeSnap('right', 99.5)
|
||||||
|
expect(result).toBe(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('snaps bottom edge to nearest horizontal', () => {
|
||||||
|
const layoutMap: Record<string, ElementLayout> = {
|
||||||
|
ref: makeLayout('ref', 50, 80, 40, 20),
|
||||||
|
}
|
||||||
|
guides.collectEdges(layoutMap, 'resizing', 210, 297)
|
||||||
|
|
||||||
|
// Snap to ref's top edge (80mm)
|
||||||
|
const result = guides.calculateResizeSnap('bottom', 79.5)
|
||||||
|
expect(result).toBe(80)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not snap when outside threshold', () => {
|
||||||
|
guides.collectEdges({}, 'none', 210, 297)
|
||||||
|
|
||||||
|
const result = guides.calculateResizeSnap('right', 50)
|
||||||
|
expect(result).toBe(50) // no edge near 50mm
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('clearGuides', () => {
|
||||||
|
it('clears active guides and cached edges', () => {
|
||||||
|
guides.collectEdges({}, 'none', 210, 297)
|
||||||
|
guides.calculateSnap(0.5, 0.5, 10, 10)
|
||||||
|
expect(guides.activeGuides.value.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
guides.clearGuides()
|
||||||
|
expect(guides.activeGuides.value).toHaveLength(0)
|
||||||
|
|
||||||
|
// After clear, calculateSnap should return unsnapped
|
||||||
|
const result = guides.calculateSnap(0.5, 0.5, 10, 10)
|
||||||
|
expect(result.snappedX_mm).toBe(0.5)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
152
frontend/src/composables/__tests__/useUndoRedo.test.ts
Normal file
152
frontend/src/composables/__tests__/useUndoRedo.test.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useUndoRedo } from '../useUndoRedo'
|
||||||
|
|
||||||
|
describe('useUndoRedo', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts with initial snapshot', () => {
|
||||||
|
const source = ref({ value: 1 })
|
||||||
|
const { canUndo, canRedo } = useUndoRedo(source)
|
||||||
|
|
||||||
|
expect(canUndo()).toBe(false) // only 1 snapshot (initial)
|
||||||
|
expect(canRedo()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('records snapshot after debounce', async () => {
|
||||||
|
const source = ref({ value: 1 })
|
||||||
|
const { canUndo } = useUndoRedo(source)
|
||||||
|
|
||||||
|
source.value = { value: 2 }
|
||||||
|
await vi.advanceTimersByTimeAsync(350) // debounce = 300ms
|
||||||
|
|
||||||
|
expect(canUndo()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('undo restores previous state', async () => {
|
||||||
|
const source = ref({ count: 0 })
|
||||||
|
const { undo, canUndo } = useUndoRedo(source)
|
||||||
|
|
||||||
|
source.value = { count: 1 }
|
||||||
|
await vi.advanceTimersByTimeAsync(350)
|
||||||
|
|
||||||
|
source.value = { count: 2 }
|
||||||
|
await vi.advanceTimersByTimeAsync(350)
|
||||||
|
|
||||||
|
expect(source.value.count).toBe(2)
|
||||||
|
|
||||||
|
undo()
|
||||||
|
expect(source.value.count).toBe(1)
|
||||||
|
|
||||||
|
undo()
|
||||||
|
expect(source.value.count).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redo restores undone state', async () => {
|
||||||
|
const source = ref({ count: 0 })
|
||||||
|
const { undo, redo, canRedo } = useUndoRedo(source)
|
||||||
|
|
||||||
|
source.value = { count: 1 }
|
||||||
|
await vi.advanceTimersByTimeAsync(350)
|
||||||
|
|
||||||
|
undo()
|
||||||
|
expect(source.value.count).toBe(0)
|
||||||
|
expect(canRedo()).toBe(true)
|
||||||
|
|
||||||
|
redo()
|
||||||
|
expect(source.value.count).toBe(1)
|
||||||
|
expect(canRedo()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('new mutation clears redo stack', async () => {
|
||||||
|
const source = ref({ v: 'a' })
|
||||||
|
const { undo, redo, canRedo } = useUndoRedo(source)
|
||||||
|
|
||||||
|
source.value = { v: 'b' }
|
||||||
|
await vi.advanceTimersByTimeAsync(350)
|
||||||
|
|
||||||
|
undo()
|
||||||
|
expect(canRedo()).toBe(true)
|
||||||
|
|
||||||
|
// New mutation after undo → clears redo
|
||||||
|
source.value = { v: 'c' }
|
||||||
|
await vi.advanceTimersByTimeAsync(350)
|
||||||
|
|
||||||
|
expect(canRedo()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('respects maxHistory limit', async () => {
|
||||||
|
const source = ref({ n: 0 })
|
||||||
|
const { canUndo, undo } = useUndoRedo(source, 3) // max 3 snapshots
|
||||||
|
|
||||||
|
source.value = { n: 1 }
|
||||||
|
await vi.advanceTimersByTimeAsync(350)
|
||||||
|
|
||||||
|
source.value = { n: 2 }
|
||||||
|
await vi.advanceTimersByTimeAsync(350)
|
||||||
|
|
||||||
|
source.value = { n: 3 }
|
||||||
|
await vi.advanceTimersByTimeAsync(350)
|
||||||
|
|
||||||
|
// Stack: [1, 2, 3] (initial 0 was shifted out)
|
||||||
|
// 3 snapshots, can undo twice (back to 1)
|
||||||
|
undo()
|
||||||
|
expect(source.value.n).toBe(2)
|
||||||
|
|
||||||
|
undo()
|
||||||
|
expect(source.value.n).toBe(1)
|
||||||
|
|
||||||
|
// Can't undo further (stack has only 1 left)
|
||||||
|
expect(canUndo()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips duplicate snapshots', async () => {
|
||||||
|
const source = ref({ x: 1 })
|
||||||
|
const { canUndo } = useUndoRedo(source)
|
||||||
|
|
||||||
|
// Set same value
|
||||||
|
source.value = { x: 1 }
|
||||||
|
await vi.advanceTimersByTimeAsync(350)
|
||||||
|
|
||||||
|
expect(canUndo()).toBe(false) // no new snapshot since value same
|
||||||
|
})
|
||||||
|
|
||||||
|
it('debounces rapid changes into one snapshot', async () => {
|
||||||
|
const source = ref({ n: 0 })
|
||||||
|
const { undo } = useUndoRedo(source)
|
||||||
|
|
||||||
|
// Rapid changes within debounce window
|
||||||
|
source.value = { n: 1 }
|
||||||
|
await vi.advanceTimersByTimeAsync(100)
|
||||||
|
source.value = { n: 2 }
|
||||||
|
await vi.advanceTimersByTimeAsync(100)
|
||||||
|
source.value = { n: 3 }
|
||||||
|
await vi.advanceTimersByTimeAsync(350) // trigger debounce
|
||||||
|
|
||||||
|
// Only one snapshot recorded (n=3), so one undo goes to initial
|
||||||
|
undo()
|
||||||
|
expect(source.value.n).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('undo with only initial snapshot does nothing', () => {
|
||||||
|
const source = ref({ v: 'init' })
|
||||||
|
const { undo } = useUndoRedo(source)
|
||||||
|
|
||||||
|
undo() // should not crash
|
||||||
|
expect(source.value.v).toBe('init')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redo with empty redo stack does nothing', () => {
|
||||||
|
const source = ref({ v: 'init' })
|
||||||
|
const { redo } = useUndoRedo(source)
|
||||||
|
|
||||||
|
redo() // should not crash
|
||||||
|
expect(source.value.v).toBe('init')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -121,11 +121,20 @@ export function useLayoutEngine(
|
|||||||
|
|
||||||
// --- Barcode üretimi (WASM üzerinden) ---
|
// --- Barcode üretimi (WASM üzerinden) ---
|
||||||
let barcodeReqId = 0
|
let barcodeReqId = 0
|
||||||
const barcodeCallbacks = new Map<number, (result: { width: number; height: number; rgba: ArrayBuffer } | null) => void>()
|
const barcodeCallbacks = new Map<
|
||||||
|
number,
|
||||||
|
(result: { width: number; height: number; rgba: ArrayBuffer } | null) => void
|
||||||
|
>()
|
||||||
|
|
||||||
function generateBarcode(format: string, value: string, width: number, height: number, includeText: boolean = false): Promise<{ width: number; height: number; rgba: ArrayBuffer } | null> {
|
function generateBarcode(
|
||||||
|
format: string,
|
||||||
|
value: string,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
includeText: boolean = false,
|
||||||
|
): Promise<{ width: number; height: number; rgba: ArrayBuffer } | null> {
|
||||||
if (!worker) initWorker()
|
if (!worker) initWorker()
|
||||||
return new Promise(resolve => {
|
return new Promise((resolve) => {
|
||||||
barcodeReqId++
|
barcodeReqId++
|
||||||
const id = barcodeReqId
|
const id = barcodeReqId
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
@@ -140,11 +149,17 @@ export function useLayoutEngine(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBarcodeResponse(msg: Extract<WorkerResponse, { type: 'barcode-result' } | { type: 'barcode-error' }>) {
|
function handleBarcodeResponse(
|
||||||
|
msg: Extract<WorkerResponse, { type: 'barcode-result' } | { type: 'barcode-error' }>,
|
||||||
|
) {
|
||||||
const cb = barcodeCallbacks.get(msg.id)
|
const cb = barcodeCallbacks.get(msg.id)
|
||||||
if (cb) {
|
if (cb) {
|
||||||
barcodeCallbacks.delete(msg.id)
|
barcodeCallbacks.delete(msg.id)
|
||||||
cb(msg.type === 'barcode-result' ? { width: msg.width, height: msg.height, rgba: msg.rgba } : null)
|
cb(
|
||||||
|
msg.type === 'barcode-result'
|
||||||
|
? { width: msg.width, height: msg.height, rgba: msg.rgba }
|
||||||
|
: null,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
30
frontend/src/composables/usePropertyUpdate.ts
Normal file
30
frontend/src/composables/usePropertyUpdate.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { useTemplateStore } from '../stores/template'
|
||||||
|
import { useEditorStore } from '../stores/editor'
|
||||||
|
import type { TemplateElement } from '../core/types'
|
||||||
|
|
||||||
|
export function usePropertyUpdate(elementRef: () => TemplateElement) {
|
||||||
|
const templateStore = useTemplateStore()
|
||||||
|
const editorStore = useEditorStore()
|
||||||
|
|
||||||
|
function update(updates: Partial<TemplateElement>) {
|
||||||
|
const id = editorStore.selectedElementId
|
||||||
|
if (!id) return
|
||||||
|
templateStore.updateElement(id, updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStyle(key: string, value: unknown) {
|
||||||
|
update({ style: { ...elementRef().style, [key]: value } } as Partial<TemplateElement>)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateNested(
|
||||||
|
field: string,
|
||||||
|
key: string,
|
||||||
|
value: unknown,
|
||||||
|
defaults: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
|
const current = (elementRef() as any)[field] ?? defaults
|
||||||
|
update({ [field]: { ...current, [key]: value } } as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { update, updateStyle, updateNested }
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ export function useSnapGuides() {
|
|||||||
layoutMap: Record<string, ElementLayout>,
|
layoutMap: Record<string, ElementLayout>,
|
||||||
excludeId: string,
|
excludeId: string,
|
||||||
pageWidth: number,
|
pageWidth: number,
|
||||||
pageHeight: number
|
pageHeight: number,
|
||||||
) {
|
) {
|
||||||
const verticals: number[] = [0, pageWidth / 2, pageWidth] // page edges + center
|
const verticals: number[] = [0, pageWidth / 2, pageWidth] // page edges + center
|
||||||
const horizontals: number[] = [0, pageHeight / 2, pageHeight]
|
const horizontals: number[] = [0, pageHeight / 2, pageHeight]
|
||||||
@@ -48,7 +48,7 @@ export function useSnapGuides() {
|
|||||||
proposedX_mm: number,
|
proposedX_mm: number,
|
||||||
proposedY_mm: number,
|
proposedY_mm: number,
|
||||||
width_mm: number,
|
width_mm: number,
|
||||||
height_mm: number
|
height_mm: number,
|
||||||
): SnapResult {
|
): SnapResult {
|
||||||
if (!cachedEdges) {
|
if (!cachedEdges) {
|
||||||
return { snappedX_mm: proposedX_mm, snappedY_mm: proposedY_mm, guides: [] }
|
return { snappedX_mm: proposedX_mm, snappedY_mm: proposedY_mm, guides: [] }
|
||||||
@@ -132,13 +132,12 @@ export function useSnapGuides() {
|
|||||||
/** Calculate snap for resize edge */
|
/** Calculate snap for resize edge */
|
||||||
function calculateResizeSnap(
|
function calculateResizeSnap(
|
||||||
edge: 'left' | 'right' | 'top' | 'bottom',
|
edge: 'left' | 'right' | 'top' | 'bottom',
|
||||||
proposedValue_mm: number
|
proposedValue_mm: number,
|
||||||
): number {
|
): number {
|
||||||
if (!cachedEdges) return proposedValue_mm
|
if (!cachedEdges) return proposedValue_mm
|
||||||
|
|
||||||
const targets = (edge === 'left' || edge === 'right')
|
const targets =
|
||||||
? cachedEdges.verticals
|
edge === 'left' || edge === 'right' ? cachedEdges.verticals : cachedEdges.horizontals
|
||||||
: cachedEdges.horizontals
|
|
||||||
|
|
||||||
const guides: SnapGuide[] = []
|
const guides: SnapGuide[] = []
|
||||||
let snapped = proposedValue_mm
|
let snapped = proposedValue_mm
|
||||||
@@ -154,7 +153,7 @@ export function useSnapGuides() {
|
|||||||
|
|
||||||
if (snapped !== proposedValue_mm) {
|
if (snapped !== proposedValue_mm) {
|
||||||
guides.push({
|
guides.push({
|
||||||
type: (edge === 'left' || edge === 'right') ? 'vertical' : 'horizontal',
|
type: edge === 'left' || edge === 'right' ? 'vertical' : 'horizontal',
|
||||||
position_mm: snapped,
|
position_mm: snapped,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function useUndoRedo<T>(source: Ref<T>, maxHistory = 50) {
|
|||||||
redoStack.value = []
|
redoStack.value = []
|
||||||
}, 300)
|
}, 300)
|
||||||
},
|
},
|
||||||
{ deep: true }
|
{ deep: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
function undo() {
|
function undo() {
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ describe('findScalarFields', () => {
|
|||||||
// firma.unvan, firma.vergiNo, fatura.no, fatura.tutar, fatura.tarih = 5
|
// firma.unvan, firma.vergiNo, fatura.no, fatura.tutar, fatura.tarih = 5
|
||||||
expect(scalars).toHaveLength(5)
|
expect(scalars).toHaveLength(5)
|
||||||
|
|
||||||
const paths = scalars.map(s => s.path)
|
const paths = scalars.map((s) => s.path)
|
||||||
expect(paths).toContain('firma.unvan')
|
expect(paths).toContain('firma.unvan')
|
||||||
expect(paths).toContain('firma.vergiNo')
|
expect(paths).toContain('firma.vergiNo')
|
||||||
expect(paths).toContain('fatura.no')
|
expect(paths).toContain('fatura.no')
|
||||||
@@ -159,7 +159,7 @@ describe('findScalarFields', () => {
|
|||||||
it('does not include object or array nodes', () => {
|
it('does not include object or array nodes', () => {
|
||||||
const tree = parseSchema(testSchema)
|
const tree = parseSchema(testSchema)
|
||||||
const scalars = findScalarFields(tree)
|
const scalars = findScalarFields(tree)
|
||||||
const types = scalars.map(s => s.type)
|
const types = scalars.map((s) => s.type)
|
||||||
|
|
||||||
expect(types).not.toContain('object')
|
expect(types).not.toContain('object')
|
||||||
expect(types).not.toContain('array')
|
expect(types).not.toContain('array')
|
||||||
@@ -195,17 +195,38 @@ describe('defaultAlignForSchema', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('returns right for currency format', () => {
|
it('returns right for currency format', () => {
|
||||||
const node: SchemaNode = { path: 'x', key: 'x', title: 'X', type: 'string', format: 'currency', children: [] }
|
const node: SchemaNode = {
|
||||||
|
path: 'x',
|
||||||
|
key: 'x',
|
||||||
|
title: 'X',
|
||||||
|
type: 'string',
|
||||||
|
format: 'currency',
|
||||||
|
children: [],
|
||||||
|
}
|
||||||
expect(defaultAlignForSchema(node)).toBe('right')
|
expect(defaultAlignForSchema(node)).toBe('right')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns right for percentage format', () => {
|
it('returns right for percentage format', () => {
|
||||||
const node: SchemaNode = { path: 'x', key: 'x', title: 'X', type: 'string', format: 'percentage', children: [] }
|
const node: SchemaNode = {
|
||||||
|
path: 'x',
|
||||||
|
key: 'x',
|
||||||
|
title: 'X',
|
||||||
|
type: 'string',
|
||||||
|
format: 'percentage',
|
||||||
|
children: [],
|
||||||
|
}
|
||||||
expect(defaultAlignForSchema(node)).toBe('right')
|
expect(defaultAlignForSchema(node)).toBe('right')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns center for date format', () => {
|
it('returns center for date format', () => {
|
||||||
const node: SchemaNode = { path: 'x', key: 'x', title: 'X', type: 'string', format: 'date', children: [] }
|
const node: SchemaNode = {
|
||||||
|
path: 'x',
|
||||||
|
key: 'x',
|
||||||
|
title: 'X',
|
||||||
|
type: 'string',
|
||||||
|
format: 'date',
|
||||||
|
children: [],
|
||||||
|
}
|
||||||
expect(defaultAlignForSchema(node)).toBe('center')
|
expect(defaultAlignForSchema(node)).toBe('center')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export type ResolvedContent =
|
|||||||
| { type: 'checkbox'; checked: boolean }
|
| { type: 'checkbox'; checked: boolean }
|
||||||
| { type: 'rich_text'; spans: ResolvedRichSpan[] }
|
| { type: 'rich_text'; spans: ResolvedRichSpan[] }
|
||||||
| { type: 'table'; headers: TableHeaderCell[]; rows: TableCell[][]; column_widths_mm: number[] }
|
| { type: 'table'; headers: TableHeaderCell[]; rows: TableCell[][]; column_widths_mm: number[] }
|
||||||
|
| { type: 'chart'; svg: string }
|
||||||
|
|
||||||
export interface TableHeaderCell {
|
export interface TableHeaderCell {
|
||||||
text: string
|
text: string
|
||||||
|
|||||||
@@ -57,10 +57,13 @@ function mockColumnValue(field: string, format: string | undefined, index: numbe
|
|||||||
const lower = field.toLowerCase()
|
const lower = field.toLowerCase()
|
||||||
if (lower.includes('sira') || lower.includes('no') || lower === 'id') return index + 1
|
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('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('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('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('tarih') || lower.includes('date'))
|
||||||
if (lower.includes('ad') || lower.includes('isim') || lower.includes('name')) return ['Kalem A', 'Kalem B', 'Kalem C'][index % 3]
|
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}`
|
return `Ornek ${index + 1}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,12 @@ export function findArrayFields(node: SchemaNode): SchemaNode[] {
|
|||||||
/** Schema ağacından tüm scalar alanları bulur (metin binding için) */
|
/** Schema ağacından tüm scalar alanları bulur (metin binding için) */
|
||||||
export function findScalarFields(node: SchemaNode): SchemaNode[] {
|
export function findScalarFields(node: SchemaNode): SchemaNode[] {
|
||||||
const result: SchemaNode[] = []
|
const result: SchemaNode[] = []
|
||||||
if (node.type === 'string' || node.type === 'number' || node.type === 'integer' || node.type === 'boolean') {
|
if (
|
||||||
|
node.type === 'string' ||
|
||||||
|
node.type === 'number' ||
|
||||||
|
node.type === 'integer' ||
|
||||||
|
node.type === 'boolean'
|
||||||
|
) {
|
||||||
result.push(node)
|
result.push(node)
|
||||||
}
|
}
|
||||||
for (const child of node.children) {
|
for (const child of node.children) {
|
||||||
@@ -83,13 +88,19 @@ export function findScalarFields(node: SchemaNode): SchemaNode[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Format tipinden FormatType'a dönüşüm */
|
/** Format tipinden FormatType'a dönüşüm */
|
||||||
export function schemaFormatToFormatType(format?: string): 'currency' | 'date' | 'percentage' | 'number' | undefined {
|
export function schemaFormatToFormatType(
|
||||||
|
format?: string,
|
||||||
|
): 'currency' | 'date' | 'percentage' | 'number' | undefined {
|
||||||
if (!format) return undefined
|
if (!format) return undefined
|
||||||
switch (format) {
|
switch (format) {
|
||||||
case 'currency': return 'currency'
|
case 'currency':
|
||||||
case 'date': return 'date'
|
return 'currency'
|
||||||
case 'percentage': return 'percentage'
|
case 'date':
|
||||||
default: return undefined
|
return 'date'
|
||||||
|
case 'percentage':
|
||||||
|
return 'percentage'
|
||||||
|
default:
|
||||||
|
return undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -116,10 +116,19 @@ export interface BarcodeStyle {
|
|||||||
includeText?: boolean // barkod altına değer yazılsın mı (QR hariç)
|
includeText?: boolean // barkod altına değer yazılsın mı (QR hariç)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Condition (koşullu gösterim) ---
|
||||||
|
|
||||||
|
export interface Condition {
|
||||||
|
path: string
|
||||||
|
operator: string
|
||||||
|
value?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
// --- Element tipleri ---
|
// --- Element tipleri ---
|
||||||
|
|
||||||
interface BaseElement {
|
interface BaseElement {
|
||||||
id: string
|
id: string
|
||||||
|
condition?: Condition
|
||||||
position: PositionMode
|
position: PositionMode
|
||||||
size: SizeConstraint
|
size: SizeConstraint
|
||||||
}
|
}
|
||||||
@@ -241,11 +250,22 @@ export interface ChartLabels {
|
|||||||
color?: string
|
color?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChartReferenceLine {
|
||||||
|
categoryIndex: number
|
||||||
|
color?: string
|
||||||
|
width?: number
|
||||||
|
label?: string
|
||||||
|
dash?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChartAxis {
|
export interface ChartAxis {
|
||||||
xLabel?: string
|
xLabel?: string
|
||||||
yLabel?: string
|
yLabel?: string
|
||||||
showGrid?: boolean
|
showGrid?: boolean
|
||||||
gridColor?: string
|
gridColor?: string
|
||||||
|
showVerticalGrid?: boolean
|
||||||
|
verticalGridColor?: string
|
||||||
|
referenceLines?: ChartReferenceLine[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChartStyle {
|
export interface ChartStyle {
|
||||||
@@ -293,7 +313,21 @@ export interface RepeatingTableElement extends BaseElement {
|
|||||||
repeatHeader?: boolean
|
repeatHeader?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LeafElement = StaticTextElement | TextElement | LineElement | RepeatingTableElement | ImageElement | PageNumberElement | BarcodeElement | PageBreakElement | CurrentDateElement | ShapeElement | CheckboxElement | CalculatedTextElement | RichTextElement | ChartElement
|
export type LeafElement =
|
||||||
|
| StaticTextElement
|
||||||
|
| TextElement
|
||||||
|
| LineElement
|
||||||
|
| RepeatingTableElement
|
||||||
|
| ImageElement
|
||||||
|
| PageNumberElement
|
||||||
|
| BarcodeElement
|
||||||
|
| PageBreakElement
|
||||||
|
| CurrentDateElement
|
||||||
|
| ShapeElement
|
||||||
|
| CheckboxElement
|
||||||
|
| CalculatedTextElement
|
||||||
|
| RichTextElement
|
||||||
|
| ChartElement
|
||||||
export type TemplateElement = LeafElement | ContainerElement
|
export type TemplateElement = LeafElement | ContainerElement
|
||||||
|
|
||||||
// --- Template ---
|
// --- Template ---
|
||||||
@@ -330,10 +364,7 @@ export function isLeaf(el: TemplateElement): el is LeafElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Ağaçta bir element'i ID ile bulur */
|
/** Ağaçta bir element'i ID ile bulur */
|
||||||
export function findElementById(
|
export function findElementById(root: ContainerElement, id: string): TemplateElement | undefined {
|
||||||
root: ContainerElement,
|
|
||||||
id: string
|
|
||||||
): TemplateElement | undefined {
|
|
||||||
if (root.id === id) return root
|
if (root.id === id) return root
|
||||||
for (const child of root.children) {
|
for (const child of root.children) {
|
||||||
if (child.id === id) return child
|
if (child.id === id) return child
|
||||||
@@ -346,10 +377,7 @@ export function findElementById(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Bir element'in parent container'ını bulur */
|
/** Bir element'in parent container'ını bulur */
|
||||||
export function findParent(
|
export function findParent(root: ContainerElement, id: string): ContainerElement | undefined {
|
||||||
root: ContainerElement,
|
|
||||||
id: string
|
|
||||||
): ContainerElement | undefined {
|
|
||||||
for (const child of root.children) {
|
for (const child of root.children) {
|
||||||
if (child.id === id) return root
|
if (child.id === id) return root
|
||||||
if (isContainer(child)) {
|
if (isContainer(child)) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user