M0.5: layout (auto layout + constraints), format, svg, engine (commands, history, tools, components, styles, variables)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b367392c4b
commit
56a312ba5f
|
|
@ -8,6 +8,15 @@ version = "2.0.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
|
|
@ -29,12 +38,39 @@ version = "1.5.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.2"
|
||||
|
|
@ -73,6 +109,15 @@ version = "1.1.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
|
|
@ -82,6 +127,66 @@ dependencies = [
|
|||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-url"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "euclid"
|
||||
version = "0.22.14"
|
||||
|
|
@ -120,6 +225,38 @@ dependencies = [
|
|||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-engine"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
"figcraft-format",
|
||||
"figcraft-geom",
|
||||
"figcraft-layout",
|
||||
"figcraft-pathops",
|
||||
"figcraft-render",
|
||||
"figcraft-svg",
|
||||
"figcraft-text",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-format"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"image",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-geom"
|
||||
version = "0.1.0"
|
||||
|
|
@ -128,6 +265,14 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-layout"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-pathops"
|
||||
version = "0.1.0"
|
||||
|
|
@ -151,15 +296,28 @@ dependencies = [
|
|||
"tiny-skia",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-svg"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
"figcraft-pathops",
|
||||
"figcraft-render",
|
||||
"usvg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-text"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
"harfrust",
|
||||
"harfrust 0.13.3",
|
||||
"log",
|
||||
"skrifa",
|
||||
"skrifa 0.47.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -173,6 +331,12 @@ dependencies = [
|
|||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "float-cmp"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
|
||||
|
||||
[[package]]
|
||||
name = "font-types"
|
||||
version = "0.12.6"
|
||||
|
|
@ -182,6 +346,38 @@ dependencies = [
|
|||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fontconfig-parser"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
|
||||
dependencies = [
|
||||
"roxmltree 0.20.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fontdb"
|
||||
version = "0.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2660c5e9157bf76d2db1294e4a9feba604ef610819a3b591088d0d8392a3290f"
|
||||
dependencies = [
|
||||
"fontconfig-parser",
|
||||
"log",
|
||||
"memmap2",
|
||||
"slotmap",
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gif"
|
||||
version = "0.14.2"
|
||||
|
|
@ -192,6 +388,18 @@ dependencies = [
|
|||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "harfrust"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bytemuck",
|
||||
"read-fonts 0.41.0",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "harfrust"
|
||||
version = "0.13.3"
|
||||
|
|
@ -205,6 +413,12 @@ dependencies = [
|
|||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
|
|
@ -233,6 +447,22 @@ dependencies = [
|
|||
"quick-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "imagesize"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
|
|
@ -252,6 +482,12 @@ dependencies = [
|
|||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.190"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
|
||||
|
||||
[[package]]
|
||||
name = "linesweeper"
|
||||
version = "0.4.0"
|
||||
|
|
@ -277,6 +513,15 @@ version = "2.8.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "memmap2"
|
||||
version = "0.9.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
|
|
@ -322,6 +567,12 @@ version = "1.21.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "pico-args"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.1"
|
||||
|
|
@ -374,6 +625,17 @@ dependencies = [
|
|||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "read-fonts"
|
||||
version = "0.41.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"font-types",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "read-fonts"
|
||||
version = "0.43.3"
|
||||
|
|
@ -396,6 +658,21 @@ dependencies = [
|
|||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "roxmltree"
|
||||
version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
||||
|
||||
[[package]]
|
||||
name = "roxmltree"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
|
|
@ -445,12 +722,48 @@ dependencies = [
|
|||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "simplecss"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c"
|
||||
dependencies = [
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
|
||||
|
||||
[[package]]
|
||||
name = "skrifa"
|
||||
version = "0.44.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"read-fonts 0.41.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "skrifa"
|
||||
version = "0.47.0"
|
||||
|
|
@ -461,6 +774,15 @@ dependencies = [
|
|||
"read-fonts 0.44.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slotmap"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
|
||||
dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.16.2"
|
||||
|
|
@ -475,6 +797,19 @@ name = "strict-num"
|
|||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
|
||||
dependencies = [
|
||||
"float-cmp",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "svgtypes"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
|
|
@ -533,18 +868,88 @@ dependencies = [
|
|||
"strict-num",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-script"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-vo"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94"
|
||||
|
||||
[[package]]
|
||||
name = "usvg"
|
||||
version = "0.48.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "977d0a4abdef933f424a99fe09f95576e089b90aebc6f016a3bc813762493e91"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"data-url",
|
||||
"flate2",
|
||||
"fontdb",
|
||||
"harfrust 0.12.0",
|
||||
"imagesize",
|
||||
"kurbo",
|
||||
"log",
|
||||
"pico-args",
|
||||
"roxmltree 0.21.1",
|
||||
"simplecss",
|
||||
"siphasher",
|
||||
"skrifa 0.44.0",
|
||||
"strict-num",
|
||||
"svgtypes",
|
||||
"tiny-skia-path",
|
||||
"unicode-bidi",
|
||||
"unicode-script",
|
||||
"unicode-vo",
|
||||
"xmlwriter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||
|
||||
[[package]]
|
||||
name = "xmlwriter"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.0"
|
||||
|
|
@ -552,6 +957,23 @@ dependencies = [
|
|||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "2.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"displaydoc",
|
||||
"flate2",
|
||||
"indexmap",
|
||||
"memchr",
|
||||
"thiserror",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.8"
|
||||
|
|
@ -564,6 +986,18 @@ version = "1.0.23"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.3"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
[package]
|
||||
name = "figcraft-engine"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "FigCraft engine: sessions, undo history, the command registry every surface (UI, CLI, control channel, MCP) dispatches through, pointer tools, components and the Figma menu catalog"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
figcraft-geom = { workspace = true }
|
||||
figcraft-color = { workspace = true }
|
||||
figcraft-doc = { workspace = true }
|
||||
figcraft-layout = { workspace = true }
|
||||
figcraft-text = { workspace = true }
|
||||
figcraft-pathops = { workspace = true }
|
||||
figcraft-render = { workspace = true }
|
||||
figcraft-format = { workspace = true }
|
||||
figcraft-svg = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
|
|
@ -0,0 +1,284 @@
|
|||
//! The incumbent's main-menu tree with stable FigCraft command ids. A command registered under a
|
||||
//! catalog id lights up its menu item, and `cargo xtask parity` measures coverage against it.
|
||||
//!
|
||||
//! Entries: (menu path, label, command id, default shortcut). `/` separates submenus; a label of
|
||||
//! `-` is a separator.
|
||||
|
||||
pub struct MenuItem {
|
||||
pub menu: &'static str,
|
||||
pub label: &'static str,
|
||||
pub id: &'static str,
|
||||
pub shortcut: &'static str,
|
||||
}
|
||||
|
||||
macro_rules! m {
|
||||
($menu:expr, $label:expr, $id:expr, $sc:expr) => {
|
||||
MenuItem { menu: $menu, label: $label, id: $id, shortcut: $sc }
|
||||
};
|
||||
}
|
||||
|
||||
pub const SEP: &str = "-";
|
||||
|
||||
pub static MENU: &[MenuItem] = &[
|
||||
// File
|
||||
m!("File", "New design file", "file.new", "Cmd+N"),
|
||||
m!("File", "Open…", "file.open", "Cmd+O"),
|
||||
m!("File", "Import from file…", "file.import", "Shift+Cmd+I"),
|
||||
m!("File", "Place image…", "file.placeImage", "Shift+Cmd+K"),
|
||||
m!("File", SEP, "", ""),
|
||||
m!("File", "Save", "file.save", "Cmd+S"),
|
||||
m!("File", "Save as…", "file.saveAs", "Shift+Cmd+S"),
|
||||
m!("File", "Save to version history…", "file.saveVersion", "Alt+Cmd+S"),
|
||||
m!("File", "Show version history", "file.versionHistory", ""),
|
||||
m!("File", SEP, "", ""),
|
||||
m!("File", "Export…", "file.export", "Shift+Cmd+E"),
|
||||
m!("File", "Export frames to PDF…", "file.exportFramesPdf", ""),
|
||||
m!("File", "Export selection as SVG…", "file.exportSvg", ""),
|
||||
m!("File", SEP, "", ""),
|
||||
m!("File", "Duplicate", "file.duplicate", ""),
|
||||
m!("File", "Rename", "file.rename", ""),
|
||||
m!("File", "Revert to saved", "file.revert", ""),
|
||||
m!("File", "Close tab", "file.close", "Cmd+W"),
|
||||
m!("File", "Open recent", "file.openRecent", ""),
|
||||
// Edit
|
||||
m!("Edit", "Undo", "edit.undo", "Cmd+Z"),
|
||||
m!("Edit", "Redo", "edit.redo", "Shift+Cmd+Z"),
|
||||
m!("Edit", SEP, "", ""),
|
||||
m!("Edit/Copy as", "Copy as text", "edit.copyAsText", ""),
|
||||
m!("Edit/Copy as", "Copy as CSS", "edit.copyAsCss", ""),
|
||||
m!("Edit/Copy as", "Copy as SVG", "edit.copyAsSvg", ""),
|
||||
m!("Edit/Copy as", "Copy as PNG", "edit.copyAsPng", "Shift+Cmd+C"),
|
||||
m!("Edit/Copy as", "Copy link to selection", "edit.copyLink", ""),
|
||||
m!("Edit", "Cut", "edit.cut", "Cmd+X"),
|
||||
m!("Edit", "Copy", "edit.copy", "Cmd+C"),
|
||||
m!("Edit", "Paste", "edit.paste", "Cmd+V"),
|
||||
m!("Edit", "Paste over selection", "edit.pasteOver", "Shift+Cmd+V"),
|
||||
m!("Edit", "Paste to replace", "edit.pasteReplace", "Shift+Cmd+R"),
|
||||
m!("Edit", "Duplicate", "edit.duplicate", "Cmd+D"),
|
||||
m!("Edit", "Delete", "edit.delete", "Delete"),
|
||||
m!("Edit", SEP, "", ""),
|
||||
m!("Edit", "Find and replace…", "edit.findReplace", "Cmd+F"),
|
||||
m!("Edit", "Copy properties", "edit.copyProperties", "Alt+Cmd+C"),
|
||||
m!("Edit", "Paste properties", "edit.pasteProperties", "Alt+Cmd+V"),
|
||||
m!("Edit", "Pick color", "edit.pickColor", "I"),
|
||||
m!("Edit", "Set default properties", "edit.setDefaultProperties", ""),
|
||||
m!("Edit", SEP, "", ""),
|
||||
m!("Edit", "Select all", "select.all", "Cmd+A"),
|
||||
m!("Edit", "Select matching layers", "select.matching", "Alt+Cmd+A"),
|
||||
m!("Edit", "Select none", "select.none", "Escape"),
|
||||
m!("Edit", "Select inverse", "select.inverse", "Shift+Cmd+A"),
|
||||
m!("Edit/Select all with same", "Fill", "select.sameFill", ""),
|
||||
m!("Edit/Select all with same", "Stroke", "select.sameStroke", ""),
|
||||
m!("Edit/Select all with same", "Effect", "select.sameEffect", ""),
|
||||
m!("Edit/Select all with same", "Text properties", "select.sameText", ""),
|
||||
m!("Edit/Select all with same", "Font", "select.sameFont", ""),
|
||||
m!("Edit/Select all with same", "Instance", "select.sameInstance", ""),
|
||||
m!("Edit/Select all with same", "Properties", "select.sameProperties", ""),
|
||||
// View
|
||||
m!("View", "Pixel grid", "view.pixelGrid", "Shift+'"),
|
||||
m!("View", "Layout grids", "view.layoutGrids", "Ctrl+G"),
|
||||
m!("View", "Rulers", "view.rulers", "Shift+R"),
|
||||
m!("View", "Slices", "view.slices", ""),
|
||||
m!("View", "Comments", "view.comments", "Shift+C"),
|
||||
m!("View", SEP, "", ""),
|
||||
m!("View/Outlines", "Show outlines", "view.outlines", "Cmd+Y"),
|
||||
m!("View/Outlines", "Show outlines for selection", "view.outlinesSelection", ""),
|
||||
m!("View", "Pixel preview", "view.pixelPreview", ""),
|
||||
m!("View", "Mask outlines", "view.maskOutlines", ""),
|
||||
m!("View", "Frame outlines", "view.frameOutlines", ""),
|
||||
m!("View", SEP, "", ""),
|
||||
m!("View/Panels", "Show/Hide UI", "view.toggleUi", "Cmd+\\"),
|
||||
m!("View/Panels", "Layers", "view.layers", "Alt+1"),
|
||||
m!("View/Panels", "Assets", "view.assets", "Alt+2"),
|
||||
m!("View/Panels", "Design", "view.design", "Alt+8"),
|
||||
m!("View/Panels", "Prototype", "view.prototype", "Alt+9"),
|
||||
m!("View", SEP, "", ""),
|
||||
m!("View", "Zoom in", "view.zoomIn", "Cmd+="),
|
||||
m!("View", "Zoom out", "view.zoomOut", "Cmd+-"),
|
||||
m!("View", "Zoom to 100%", "view.zoom100", "Shift+0"),
|
||||
m!("View", "Zoom to 50%", "view.zoom50", ""),
|
||||
m!("View", "Zoom to 200%", "view.zoom200", ""),
|
||||
m!("View", "Zoom to fit", "view.zoomFit", "Shift+1"),
|
||||
m!("View", "Zoom to selection", "view.zoomSelection", "Shift+2"),
|
||||
m!("View", "Zoom to previous frame", "view.prevFrame", "Shift+N"),
|
||||
m!("View", "Zoom to next frame", "view.nextFrame", "N"),
|
||||
m!("View", SEP, "", ""),
|
||||
m!("View", "Previous page", "view.prevPage", "PageUp"),
|
||||
m!("View", "Next page", "view.nextPage", "PageDown"),
|
||||
m!("View/Theme", "Light", "view.themeLight", ""),
|
||||
m!("View/Theme", "Dark", "view.themeDark", ""),
|
||||
m!("View/Theme", "Follow system", "view.themeSystem", ""),
|
||||
// Object
|
||||
m!("Object", "Frame selection", "object.frameSelection", "Alt+Cmd+G"),
|
||||
m!("Object", "Group selection", "object.group", "Cmd+G"),
|
||||
m!("Object", "Ungroup selection", "object.ungroup", "Shift+Cmd+G"),
|
||||
m!("Object", "Remove frame", "object.removeFrame", ""),
|
||||
m!("Object", SEP, "", ""),
|
||||
m!("Object", "Add auto layout", "layout.add", "Shift+A"),
|
||||
m!("Object", "Remove auto layout", "layout.remove", "Alt+Shift+A"),
|
||||
m!("Object", "Suggest auto layout", "layout.suggest", ""),
|
||||
m!("Object", SEP, "", ""),
|
||||
m!("Object", "Create component", "component.create", "Alt+Cmd+K"),
|
||||
m!("Object", "Create multiple components", "component.createMultiple", ""),
|
||||
m!("Object", "Combine as variants", "component.combineVariants", ""),
|
||||
m!("Object", "Reset all changes", "component.resetInstance", ""),
|
||||
m!("Object", "Detach instance", "component.detach", "Alt+Cmd+B"),
|
||||
m!("Object", "Go to main component", "component.goToMain", ""),
|
||||
m!("Object", "Push changes to main component", "component.pushChanges", ""),
|
||||
m!("Object", "Restore main component", "component.restoreMain", ""),
|
||||
m!("Object", SEP, "", ""),
|
||||
m!("Object", "Use as mask", "object.mask", "Ctrl+Cmd+M"),
|
||||
m!("Object", "Release mask", "object.releaseMask", ""),
|
||||
m!("Object", "Set as thumbnail", "object.setThumbnail", ""),
|
||||
m!("Object", SEP, "", ""),
|
||||
m!("Object", "Bring to front", "arrange.front", "Alt+Cmd+]"),
|
||||
m!("Object", "Bring forward", "arrange.forward", "Cmd+]"),
|
||||
m!("Object", "Send backward", "arrange.backward", "Cmd+["),
|
||||
m!("Object", "Send to back", "arrange.back", "Alt+Cmd+["),
|
||||
m!("Object", SEP, "", ""),
|
||||
m!("Object", "Flip horizontal", "object.flipH", "Shift+H"),
|
||||
m!("Object", "Flip vertical", "object.flipV", "Shift+V"),
|
||||
m!("Object", "Rotate 180°", "object.rotate180", ""),
|
||||
m!("Object", "Rotate 90° left", "object.rotateLeft", ""),
|
||||
m!("Object", "Rotate 90° right", "object.rotateRight", ""),
|
||||
m!("Object", SEP, "", ""),
|
||||
m!("Object", "Flatten", "vector.flatten", "Cmd+E"),
|
||||
m!("Object", "Outline stroke", "vector.outlineStroke", "Alt+Cmd+O"),
|
||||
m!("Object/Boolean groups", "Union", "bool.union", ""),
|
||||
m!("Object/Boolean groups", "Subtract", "bool.subtract", ""),
|
||||
m!("Object/Boolean groups", "Intersect", "bool.intersect", ""),
|
||||
m!("Object/Boolean groups", "Exclude", "bool.exclude", ""),
|
||||
m!("Object/Boolean groups", "Flatten", "bool.flatten", ""),
|
||||
m!("Object", "Rasterize selection", "object.rasterize", ""),
|
||||
m!("Object", SEP, "", ""),
|
||||
m!("Object", "Show/Hide selection", "object.toggleVisible", "Shift+Cmd+H"),
|
||||
m!("Object", "Lock/Unlock selection", "object.toggleLock", "Shift+Cmd+L"),
|
||||
m!("Object", "Hide other layers", "object.hideOthers", ""),
|
||||
m!("Object", "Collapse layers", "layers.collapseAll", "Alt+L"),
|
||||
m!("Object", SEP, "", ""),
|
||||
m!("Object", "Remove fill", "object.removeFill", ""),
|
||||
m!("Object", "Remove stroke", "object.removeStroke", ""),
|
||||
m!("Object", "Swap fill and stroke", "object.swapFillStroke", "Shift+X"),
|
||||
m!("Object", SEP, "", ""),
|
||||
m!("Object", "Rename", "object.rename", "Cmd+R"),
|
||||
m!("Object", "Batch rename…", "object.batchRename", ""),
|
||||
m!("Object", "Move to page", "object.moveToPage", ""),
|
||||
m!("Object/Select layer", "Select children", "select.children", "Enter"),
|
||||
m!("Object/Select layer", "Select parent", "select.parent", "Shift+Enter"),
|
||||
m!("Object/Select layer", "Select next sibling", "select.next", "Tab"),
|
||||
m!("Object/Select layer", "Select previous sibling", "select.previous", "Shift+Tab"),
|
||||
// Text
|
||||
m!("Text", "Bold", "text.bold", "Cmd+B"),
|
||||
m!("Text", "Italic", "text.italic", "Cmd+I"),
|
||||
m!("Text", "Underline", "text.underline", "Cmd+U"),
|
||||
m!("Text", "Strikethrough", "text.strikethrough", "Shift+Cmd+X"),
|
||||
m!("Text", "Create link", "text.link", "Cmd+K"),
|
||||
m!("Text", SEP, "", ""),
|
||||
m!("Text", "Bulleted list", "text.bulletList", "Shift+Cmd+8"),
|
||||
m!("Text", "Numbered list", "text.numberList", "Shift+Cmd+7"),
|
||||
m!("Text", SEP, "", ""),
|
||||
m!("Text/Alignment", "Text align left", "text.alignLeft", "Alt+Cmd+L"),
|
||||
m!("Text/Alignment", "Text align center", "text.alignCenter", "Alt+Cmd+T"),
|
||||
m!("Text/Alignment", "Text align right", "text.alignRight", "Alt+Cmd+R"),
|
||||
m!("Text/Alignment", "Text align justified", "text.alignJustify", "Alt+Cmd+J"),
|
||||
m!("Text/Adjust", "Increase font size", "text.sizeUp", "Shift+Cmd+."),
|
||||
m!("Text/Adjust", "Decrease font size", "text.sizeDown", "Shift+Cmd+,"),
|
||||
m!("Text/Adjust", "Increase font weight", "text.weightUp", "Alt+Cmd+."),
|
||||
m!("Text/Adjust", "Decrease font weight", "text.weightDown", "Alt+Cmd+,"),
|
||||
m!("Text/Adjust", "Increase line height", "text.lineHeightUp", "Alt+Shift+."),
|
||||
m!("Text/Adjust", "Decrease line height", "text.lineHeightDown", "Alt+Shift+,"),
|
||||
m!("Text/Adjust", "Increase letter spacing", "text.trackingUp", "Alt+."),
|
||||
m!("Text/Adjust", "Decrease letter spacing", "text.trackingDown", "Alt+,"),
|
||||
m!("Text/Case", "Original case", "text.caseOriginal", ""),
|
||||
m!("Text/Case", "Uppercase", "text.caseUpper", ""),
|
||||
m!("Text/Case", "Lowercase", "text.caseLower", ""),
|
||||
m!("Text/Case", "Title case", "text.caseTitle", ""),
|
||||
// Arrange
|
||||
m!("Arrange", "Align left", "align.left", "Alt+A"),
|
||||
m!("Arrange", "Align horizontal centers", "align.hcenter", "Alt+H"),
|
||||
m!("Arrange", "Align right", "align.right", "Alt+D"),
|
||||
m!("Arrange", "Align top", "align.top", "Alt+W"),
|
||||
m!("Arrange", "Align vertical centers", "align.vcenter", "Alt+V"),
|
||||
m!("Arrange", "Align bottom", "align.bottom", "Alt+S"),
|
||||
m!("Arrange", SEP, "", ""),
|
||||
m!("Arrange", "Distribute horizontal spacing", "distribute.horizontal", "Alt+Shift+H"),
|
||||
m!("Arrange", "Distribute vertical spacing", "distribute.vertical", "Alt+Shift+V"),
|
||||
m!("Arrange", "Tidy up", "arrange.tidy", "Ctrl+Alt+T"),
|
||||
m!("Arrange", SEP, "", ""),
|
||||
m!("Arrange", "Pack horizontal", "arrange.packH", ""),
|
||||
m!("Arrange", "Pack vertical", "arrange.packV", ""),
|
||||
m!("Arrange", SEP, "", ""),
|
||||
m!("Arrange", "Round to pixels", "arrange.roundToPixels", ""),
|
||||
// Vector
|
||||
m!("Vector", "Join selection", "vector.join", "Cmd+J"),
|
||||
m!("Vector", "Smooth join selection", "vector.smoothJoin", "Shift+Cmd+J"),
|
||||
m!("Vector", "Delete and heal selection", "vector.deleteHeal", "Shift+Delete"),
|
||||
m!("Vector", "Simplify vector", "vector.simplify", ""),
|
||||
m!("Vector", "Reverse path direction", "vector.reverse", ""),
|
||||
m!("Vector", "Offset path", "vector.offset", ""),
|
||||
// Plugins
|
||||
m!("Plugins", "Run last plugin", "plugins.runLast", "Alt+Cmd+P"),
|
||||
m!("Plugins", "Manage plugins…", "plugins.manage", ""),
|
||||
// Preferences
|
||||
m!("Preferences", "Snap to geometry", "view.snapGeometry", ""),
|
||||
m!("Preferences", "Snap to objects", "view.snapObjects", ""),
|
||||
m!("Preferences", "Snap to pixel grid", "view.snapPixel", "Shift+Cmd+'"),
|
||||
m!("Preferences", "Keep tool selected after use", "pref.keepTool", ""),
|
||||
m!("Preferences", "Highlight layers on hover", "pref.highlightHover", ""),
|
||||
m!("Preferences", "Rename duplicated layers", "pref.renameDuplicates", ""),
|
||||
m!("Preferences", "Show dimensions on objects", "pref.showDimensions", ""),
|
||||
m!("Preferences", "Use number keys for opacity", "pref.numberKeysOpacity", ""),
|
||||
m!("Preferences", "Nudge amount…", "pref.nudge", ""),
|
||||
m!("Preferences", "Preferences…", "app.preferences", "Cmd+,"),
|
||||
// Libraries
|
||||
m!("Libraries", "Open libraries", "libraries.open", "Alt+3"),
|
||||
m!("Libraries", "Publish library…", "libraries.publish", ""),
|
||||
// Help
|
||||
m!("Help", "Keyboard shortcuts", "help.shortcuts", "Ctrl+Shift+?"),
|
||||
m!("Help", "Help page", "help.page", ""),
|
||||
m!("Help", "About FigCraft", "help.about", ""),
|
||||
// Toolbar
|
||||
m!("Tools", "Move", "tool.move", "V"),
|
||||
m!("Tools", "Scale", "tool.scale", "K"),
|
||||
m!("Tools", "Hand", "tool.hand", "H"),
|
||||
m!("Tools", "Frame", "tool.frame", "F"),
|
||||
m!("Tools", "Section", "tool.section", "Shift+S"),
|
||||
m!("Tools", "Slice", "tool.slice", "S"),
|
||||
m!("Tools", "Rectangle", "tool.rectangle", "R"),
|
||||
m!("Tools", "Line", "tool.line", "L"),
|
||||
m!("Tools", "Arrow", "tool.arrow", "Shift+L"),
|
||||
m!("Tools", "Ellipse", "tool.ellipse", "O"),
|
||||
m!("Tools", "Polygon", "tool.polygon", ""),
|
||||
m!("Tools", "Star", "tool.star", ""),
|
||||
m!("Tools", "Pen", "tool.pen", "P"),
|
||||
m!("Tools", "Pencil", "tool.pencil", "Shift+P"),
|
||||
m!("Tools", "Text", "tool.text", "T"),
|
||||
m!("Tools", "Comment", "tool.comment", "C"),
|
||||
m!("Tools", "Actions", "app.actions", "Cmd+K"),
|
||||
];
|
||||
|
||||
/// Items that are real commands (not separators).
|
||||
pub fn items() -> impl Iterator<Item = &'static MenuItem> {
|
||||
MENU.iter().filter(|m| m.label != SEP)
|
||||
}
|
||||
|
||||
/// Coverage: (implemented, total, missing ids). `extra` lists ids implemented by the UI layer.
|
||||
pub fn parity(extra: &[&str]) -> (usize, usize, Vec<&'static str>) {
|
||||
let mut done = 0;
|
||||
let mut missing = Vec::new();
|
||||
let mut total = 0;
|
||||
for m in items() {
|
||||
total += 1;
|
||||
if crate::find_command(m.id).is_some() || extra.contains(&m.id) {
|
||||
done += 1;
|
||||
} else {
|
||||
missing.push(m.id);
|
||||
}
|
||||
}
|
||||
(done, total, missing)
|
||||
}
|
||||
|
||||
/// The default shortcut for a command id.
|
||||
pub fn shortcut(id: &str) -> Option<&'static str> {
|
||||
MENU.iter().find(|m| m.id == id && !m.shortcut.is_empty()).map(|m| m.shortcut)
|
||||
}
|
||||
|
|
@ -0,0 +1,416 @@
|
|||
//! Arrange: align, distribute, tidy, z-order, flips, rotations and numeric transforms.
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_geom::{Affine, Point, Rect};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::edit::{roots, z_sorted};
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::{Result, Session, bad, spec};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!("align.left", "Align left", "{ids?}", true, |s, p| align(s, p, Al::Left)),
|
||||
spec!("align.hcenter", "Align horizontal centers", "{ids?}", true, |s, p| align(s, p, Al::HCenter)),
|
||||
spec!("align.right", "Align right", "{ids?}", true, |s, p| align(s, p, Al::Right)),
|
||||
spec!("align.top", "Align top", "{ids?}", true, |s, p| align(s, p, Al::Top)),
|
||||
spec!("align.vcenter", "Align vertical centers", "{ids?}", true, |s, p| align(s, p, Al::VCenter)),
|
||||
spec!("align.bottom", "Align bottom", "{ids?}", true, |s, p| align(s, p, Al::Bottom)),
|
||||
spec!("distribute.horizontal", "Distribute horizontal spacing", "{ids?, spacing?}", true, |s, p| distribute(s, p, true)),
|
||||
spec!("distribute.vertical", "Distribute vertical spacing", "{ids?, spacing?}", true, |s, p| distribute(s, p, false)),
|
||||
spec!("arrange.tidy", "Tidy up", "{ids?, spacing?}", true, tidy),
|
||||
spec!("arrange.packH", "Pack horizontal", "{ids?}", true, |s, p| pack(s, p, true)),
|
||||
spec!("arrange.packV", "Pack vertical", "{ids?}", true, |s, p| pack(s, p, false)),
|
||||
spec!("arrange.roundToPixels", "Round to pixels", "{ids?}", true, round_px),
|
||||
spec!("arrange.front", "Bring to front", "{ids?}", true, |s, p| zorder(s, p, Z::Front)),
|
||||
spec!("arrange.forward", "Bring forward", "{ids?}", true, |s, p| zorder(s, p, Z::Forward)),
|
||||
spec!("arrange.backward", "Send backward", "{ids?}", true, |s, p| zorder(s, p, Z::Backward)),
|
||||
spec!("arrange.back", "Send to back", "{ids?}", true, |s, p| zorder(s, p, Z::Back)),
|
||||
spec!("object.flipH", "Flip horizontal", "{ids?}", true, |s, p| flip(s, p, true)),
|
||||
spec!("object.flipV", "Flip vertical", "{ids?}", true, |s, p| flip(s, p, false)),
|
||||
spec!("object.rotate180", "Rotate 180°", "{ids?}", true, |s, p| rotate_by(s, p, 180.0)),
|
||||
spec!("object.rotateLeft", "Rotate 90° left", "{ids?}", true, |s, p| rotate_by(s, p, 90.0)),
|
||||
spec!("object.rotateRight", "Rotate 90° right", "{ids?}", true, |s, p| rotate_by(s, p, -90.0)),
|
||||
spec!("transform.move", "Move", "{ids?, dx, dy} (page units)", true, |s, p| {
|
||||
let (dx, dy) = (num_or(p, "dx", 0.0), num_or(p, "dy", 0.0));
|
||||
let ids = req_targets("transform.move", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
for i in roots(doc, &ids) {
|
||||
move_world(doc, i, dx, dy)?;
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("transform.moveTo", "Move to", "{ids?, x, y} (page coordinates of the selection's top-left)", true, |s, p| {
|
||||
let ids = req_targets("transform.moveTo", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let ids = roots(doc, &ids);
|
||||
let b = doc.bounds_of(&ids).ok_or_else(|| bad("transform.moveTo", "nothing to move"))?;
|
||||
let (dx, dy) = (num_or(p, "x", b.x0) - b.x0, num_or(p, "y", b.y0) - b.y0);
|
||||
for i in ids {
|
||||
move_world(doc, i, dx, dy)?;
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("transform.rotate", "Rotate", "{ids?, degrees, cx?, cy? (pivot, page coords; default the selection centre)}", true, |s, p| {
|
||||
let deg = req_num("transform.rotate", p, "degrees")?;
|
||||
let ids = req_targets("transform.rotate", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let ids = roots(doc, &ids);
|
||||
let c = doc.bounds_of(&ids).map(|b| b.center()).unwrap_or_default();
|
||||
let pivot = Point::new(num_or(p, "cx", c.x), num_or(p, "cy", c.y));
|
||||
for i in ids {
|
||||
rotate_world(doc, i, pivot, deg)?;
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("transform.scale", "Scale", "{ids?, factor, cx?, cy? (pivot; default top-left)} — scales size, strokes, effects, text (the K tool)", true, scale_cmd),
|
||||
spec!("transform.resize", "Resize", "{ids?, width?, height?, anchor? (tl|t|tr|l|c|r|bl|b|br)}", true, resize_cmd),
|
||||
]
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Al {
|
||||
Left,
|
||||
HCenter,
|
||||
Right,
|
||||
Top,
|
||||
VCenter,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
/// Move a node by `(dx, dy)` in page units.
|
||||
pub fn move_world(doc: &mut Document, id: NodeId, dx: f64, dy: f64) -> Result<()> {
|
||||
let n = doc.get(id)?;
|
||||
let pw = n.parent.map(|p| doc.world_transform(p)).unwrap_or(Affine::IDENTITY);
|
||||
let d = pw.inverse() * Affine::translate((dx, dy)) * pw;
|
||||
let n = doc.get_mut(id)?;
|
||||
n.transform = d * n.transform;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn rotate_world(doc: &mut Document, id: NodeId, pivot: Point, deg: f64) -> Result<()> {
|
||||
let world = doc.world_transform(id);
|
||||
let nw = figcraft_geom::rotate_about(world, pivot, deg);
|
||||
doc.set_world_transform(id, nw)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn align(s: &mut Session, p: &Value, a: Al) -> Result<Value> {
|
||||
let ids = req_targets("align", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let ids = roots(doc, &ids);
|
||||
// One layer aligns within its parent frame; several align to their combined bounds.
|
||||
let target: Rect = if ids.len() == 1 {
|
||||
let Some(id) = ids.first() else { return ok() };
|
||||
let parent = doc.get(*id)?.parent;
|
||||
match parent.filter(|pp| !doc.node(*pp).is_some_and(Node::is_page)) {
|
||||
Some(pp) => doc.world_bounds(pp).unwrap_or_default(),
|
||||
None => return ok(),
|
||||
}
|
||||
} else {
|
||||
doc.bounds_of(&ids).unwrap_or_default()
|
||||
};
|
||||
for i in ids {
|
||||
let Some(b) = doc.world_bounds(i) else { continue };
|
||||
let (dx, dy) = match a {
|
||||
Al::Left => (target.x0 - b.x0, 0.0),
|
||||
Al::HCenter => (target.center().x - b.center().x, 0.0),
|
||||
Al::Right => (target.x1 - b.x1, 0.0),
|
||||
Al::Top => (0.0, target.y0 - b.y0),
|
||||
Al::VCenter => (0.0, target.center().y - b.center().y),
|
||||
Al::Bottom => (0.0, target.y1 - b.y1),
|
||||
};
|
||||
move_world(doc, i, dx, dy)?;
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
fn distribute(s: &mut Session, p: &Value, horizontal: bool) -> Result<Value> {
|
||||
let ids = req_targets("distribute", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let ids = roots(doc, &ids);
|
||||
if ids.len() < 3 && num(p, "spacing").is_none() {
|
||||
return ok();
|
||||
}
|
||||
let mut items: Vec<(NodeId, Rect)> = ids.iter().filter_map(|i| doc.world_bounds(*i).map(|b| (*i, b))).collect();
|
||||
items.sort_by(|a, b| if horizontal { a.1.x0.total_cmp(&b.1.x0) } else { a.1.y0.total_cmp(&b.1.y0) });
|
||||
let total: f64 = items.iter().map(|(_, b)| if horizontal { b.width() } else { b.height() }).sum();
|
||||
let (Some(first), Some(last)) = (items.first(), items.last()) else { return ok() };
|
||||
let span = if horizontal { last.1.x1 - first.1.x0 } else { last.1.y1 - first.1.y0 };
|
||||
let gap = num(p, "spacing").unwrap_or((span - total) / (items.len().saturating_sub(1).max(1)) as f64);
|
||||
let mut pos = if horizontal { first.1.x0 } else { first.1.y0 };
|
||||
for (i, b) in items.clone() {
|
||||
if horizontal {
|
||||
move_world(doc, i, pos - b.x0, 0.0)?;
|
||||
pos += b.width() + gap;
|
||||
} else {
|
||||
move_world(doc, i, 0.0, pos - b.y0)?;
|
||||
pos += b.height() + gap;
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Tidy up: snap the selection into a grid of rows/columns with even spacing.
|
||||
fn tidy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("arrange.tidy", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let ids = roots(doc, &ids);
|
||||
let mut items: Vec<(NodeId, Rect)> = ids.iter().filter_map(|i| doc.world_bounds(*i).map(|b| (*i, b))).collect();
|
||||
if items.len() < 2 {
|
||||
return ok();
|
||||
}
|
||||
// Group into rows by vertical centre overlap.
|
||||
items.sort_by(|a, b| a.1.y0.total_cmp(&b.1.y0));
|
||||
let mut rows: Vec<Vec<(NodeId, Rect)>> = Vec::new();
|
||||
for it in items {
|
||||
match rows.last_mut() {
|
||||
Some(row) if row.iter().any(|(_, b)| it.1.center().y < b.y1 && it.1.center().y > b.y0) => row.push(it),
|
||||
_ => rows.push(vec![it]),
|
||||
}
|
||||
}
|
||||
let all: Vec<Rect> = rows.iter().flatten().map(|(_, b)| *b).collect();
|
||||
let (Some(origin), maxw, maxh) = (all.iter().copied().reduce(|a, b| a.union(b)), all.iter().map(Rect::width).fold(0.0, f64::max), all.iter().map(Rect::height).fold(0.0, f64::max)) else {
|
||||
return ok();
|
||||
};
|
||||
let gap = num(p, "spacing").unwrap_or_else(|| {
|
||||
// Keep the average existing horizontal gap (min 10).
|
||||
10f64.max(rows.iter().filter(|r| r.len() > 1).map(|r| (r.iter().map(|(_, b)| b.x1).fold(f64::MIN, f64::max) - r.iter().map(|(_, b)| b.x0).fold(f64::MAX, f64::min) - r.iter().map(|(_, b)| b.width()).sum::<f64>()) / (r.len() - 1) as f64).fold(0.0, f64::max))
|
||||
});
|
||||
for (ri, row) in rows.iter_mut().enumerate() {
|
||||
row.sort_by(|a, b| a.1.x0.total_cmp(&b.1.x0));
|
||||
for (ci, (id, b)) in row.iter().enumerate() {
|
||||
let x = origin.x0 + ci as f64 * (maxw + gap);
|
||||
let y = origin.y0 + ri as f64 * (maxh + gap);
|
||||
move_world(doc, *id, x - b.x0, y - b.y0)?;
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
fn pack(s: &mut Session, p: &Value, horizontal: bool) -> Result<Value> {
|
||||
let mut q = p.clone();
|
||||
if let Some(o) = q.as_object_mut() {
|
||||
o.insert("spacing".into(), json!(0.0));
|
||||
}
|
||||
distribute(s, &q, horizontal)
|
||||
}
|
||||
|
||||
fn round_px(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("arrange.roundToPixels", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
for i in ids {
|
||||
let n = doc.get_mut(i)?;
|
||||
let c = n.transform.as_coeffs();
|
||||
n.transform = Affine::new([c[0], c[1], c[2], c[3], c[4].round(), c[5].round()]);
|
||||
n.width = n.width.round().max(if n.width > 0.0 { 1.0 } else { 0.0 });
|
||||
n.height = n.height.round().max(if n.height > 0.0 { 1.0 } else { 0.0 });
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Z {
|
||||
Front,
|
||||
Forward,
|
||||
Backward,
|
||||
Back,
|
||||
}
|
||||
|
||||
fn zorder(s: &mut Session, p: &Value, z: Z) -> Result<Value> {
|
||||
let ids = req_targets("arrange", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let ids = z_sorted(doc, &roots(doc, &ids));
|
||||
let order: Vec<NodeId> = match z {
|
||||
Z::Front | Z::Forward => ids.iter().rev().copied().collect(),
|
||||
_ => ids.clone(),
|
||||
};
|
||||
for i in order {
|
||||
let Some(parent) = doc.get(i)?.parent else { continue };
|
||||
let len = doc.get(parent)?.children.len();
|
||||
let cur = doc.index_in_parent(i).unwrap_or(0);
|
||||
let siblings_selected = |k: usize, doc: &Document| doc.node(parent).and_then(|pn| pn.children.get(k)).is_some_and(|c| ids.contains(c));
|
||||
let target = match z {
|
||||
Z::Front => len.saturating_sub(1),
|
||||
Z::Back => 0,
|
||||
Z::Forward => {
|
||||
if cur + 1 < len && !siblings_selected(cur + 1, doc) {
|
||||
cur + 1
|
||||
} else {
|
||||
cur
|
||||
}
|
||||
}
|
||||
Z::Backward => {
|
||||
if cur > 0 && !siblings_selected(cur - 1, doc) {
|
||||
cur - 1
|
||||
} else {
|
||||
cur
|
||||
}
|
||||
}
|
||||
};
|
||||
doc.reorder(i, target)?;
|
||||
}
|
||||
// Front/Back of several keep their relative order.
|
||||
if matches!(z, Z::Front) {
|
||||
for i in &ids {
|
||||
let parent = doc.get(*i)?.parent;
|
||||
if let Some(pp) = parent {
|
||||
let len = doc.get(pp)?.children.len();
|
||||
doc.reorder(*i, len)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches!(z, Z::Back) {
|
||||
for (k, i) in ids.iter().enumerate() {
|
||||
doc.reorder(*i, k)?;
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
fn flip(s: &mut Session, p: &Value, horizontal: bool) -> Result<Value> {
|
||||
let ids = req_targets("flip", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let ids = roots(doc, &ids);
|
||||
let Some(b) = doc.bounds_of(&ids) else { return ok() };
|
||||
let c = b.center();
|
||||
let m = if horizontal { Affine::new([-1.0, 0.0, 0.0, 1.0, 2.0 * c.x, 0.0]) } else { Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 2.0 * c.y]) };
|
||||
for i in ids {
|
||||
let w = doc.world_transform(i);
|
||||
doc.set_world_transform(i, m * w)?;
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
fn rotate_by(s: &mut Session, p: &Value, deg: f64) -> Result<Value> {
|
||||
let ids = req_targets("rotate", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let ids = roots(doc, &ids);
|
||||
let Some(b) = doc.bounds_of(&ids) else { return ok() };
|
||||
for i in ids {
|
||||
rotate_world(doc, i, b.center(), deg)?;
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Scale nodes uniformly including strokes, effects, radii and text sizes (the Scale tool).
|
||||
pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> {
|
||||
let k = if k.is_finite() && k > 0.0 { k } else { return Ok(()) };
|
||||
let sub = doc.subtree(id);
|
||||
for (depth, i) in sub.iter().enumerate() {
|
||||
let n = doc.get_mut(*i)?;
|
||||
n.stroke.weight *= k;
|
||||
if let Some(sides) = n.stroke.sides.as_mut() {
|
||||
for v in sides.iter_mut() {
|
||||
*v *= k;
|
||||
}
|
||||
}
|
||||
for d in &mut n.stroke.dashes {
|
||||
*d *= k;
|
||||
}
|
||||
n.corners.radii = n.corners.radii.map(|r| r * k);
|
||||
for e in &mut n.effects {
|
||||
match &mut e.kind {
|
||||
EffectKind::DropShadow(sh) | EffectKind::InnerShadow(sh) => {
|
||||
sh.radius *= k;
|
||||
sh.spread *= k;
|
||||
sh.offset = (sh.offset.0 * k, sh.offset.1 * k);
|
||||
}
|
||||
EffectKind::LayerBlur { radius } | EffectKind::BackgroundBlur { radius } => *radius *= k,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(l) = n.layout.as_mut() {
|
||||
l.gap *= k;
|
||||
l.cross_gap *= k;
|
||||
l.padding = l.padding.map(|x| x * k);
|
||||
}
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
t.style.font_size *= k;
|
||||
if let LineHeight::Pixels(v) = &mut t.style.line_height {
|
||||
*v *= k;
|
||||
}
|
||||
if let LetterSpacing::Pixels(v) = &mut t.style.letter_spacing {
|
||||
*v *= k;
|
||||
}
|
||||
for r in &mut t.runs {
|
||||
r.style.font_size *= k;
|
||||
if let LineHeight::Pixels(v) = &mut r.style.line_height {
|
||||
*v *= k;
|
||||
}
|
||||
}
|
||||
t.paragraph_spacing *= k;
|
||||
}
|
||||
if let NodeKind::Vector { network } = &mut n.kind {
|
||||
network.scale(k, k);
|
||||
}
|
||||
n.width *= k;
|
||||
n.height *= k;
|
||||
if depth > 0 {
|
||||
let c = n.transform.as_coeffs();
|
||||
n.transform = Affine::new([c[0], c[1], c[2], c[3], c[4] * k, c[5] * k]);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn scale_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let k = req_num("transform.scale", p, "factor")?;
|
||||
if k <= 0.0 {
|
||||
return Err(bad("transform.scale", "`factor` must be positive"));
|
||||
}
|
||||
let ids = req_targets("transform.scale", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let ids = roots(doc, &ids);
|
||||
let b = doc.bounds_of(&ids).unwrap_or_default();
|
||||
let pivot = Point::new(num_or(p, "cx", b.x0), num_or(p, "cy", b.y0));
|
||||
for i in ids {
|
||||
let Some(nb) = doc.world_bounds(i) else { continue };
|
||||
scale_node(doc, i, k)?;
|
||||
// Keep relative position to the pivot.
|
||||
let target = Point::new(pivot.x + (nb.x0 - pivot.x) * k, pivot.y + (nb.y0 - pivot.y) * k);
|
||||
if let Some(after) = doc.world_bounds(i) {
|
||||
move_world(doc, i, target.x - after.x0, target.y - after.y0)?;
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("transform.resize", s, p)?;
|
||||
let anchor = string(p, "anchor").unwrap_or_else(|| "tl".into());
|
||||
let (ax, ay) = match anchor.as_str() {
|
||||
"t" => (0.5, 0.0),
|
||||
"tr" => (1.0, 0.0),
|
||||
"l" => (0.0, 0.5),
|
||||
"c" => (0.5, 0.5),
|
||||
"r" => (1.0, 0.5),
|
||||
"bl" => (0.0, 1.0),
|
||||
"b" => (0.5, 1.0),
|
||||
"br" => (1.0, 1.0),
|
||||
_ => (0.0, 0.0),
|
||||
};
|
||||
let doc = s.doc_mut()?;
|
||||
for i in ids {
|
||||
let n = doc.get(i)?;
|
||||
let (ow, oh) = (n.width, n.height);
|
||||
let (w, h) = (num(p, "width").unwrap_or(ow).max(0.0), num(p, "height").unwrap_or(oh).max(0.0));
|
||||
let fixed_local = Point::new(ow * ax, oh * ay);
|
||||
let before = n.transform * fixed_local;
|
||||
super::props::resize_node(doc, i, w, h)?;
|
||||
let n = doc.get_mut(i)?;
|
||||
let after = n.transform * Point::new(w * ax, h * ay);
|
||||
n.transform = Affine::translate(before - after) * n.transform;
|
||||
if num(p, "width").is_some() {
|
||||
n.child_layout.sizing_h = Sizing::Fixed;
|
||||
}
|
||||
if num(p, "height").is_some() {
|
||||
n.child_layout.sizing_v = Sizing::Fixed;
|
||||
}
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
t.auto_resize = if num(p, "height").is_some() { TextAutoResize::None } else { TextAutoResize::Height };
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
|
@ -0,0 +1,309 @@
|
|||
//! Component commands.
|
||||
|
||||
use figcraft_doc::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::edit::{roots, z_sorted};
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::components::*;
|
||||
use crate::params::*;
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!("component.create", "Create component", "{ids?} (one frame/group becomes a component; several layers are wrapped in one)", true, create),
|
||||
spec!("component.createMultiple", "Create multiple components", "{ids?}", true, |s, p| {
|
||||
let ids = req_targets("component.createMultiple", s, p)?;
|
||||
let mut out = Vec::new();
|
||||
for i in ids {
|
||||
let r = create(s, &json!({"ids": [i]}))?;
|
||||
out.push(r["id"].clone());
|
||||
}
|
||||
s.set_selection(out.iter().filter_map(id_of).collect());
|
||||
Ok(json!({"ids": out}))
|
||||
}),
|
||||
spec!("component.combineVariants", "Combine as variants", "{ids? (components)}", true, combine),
|
||||
spec!("instance.create", "Create instance", "{component, x?, y?, parent?}", true, |s, p| {
|
||||
let c = req_id("instance.create", p, "component")?;
|
||||
let page = s.page()?;
|
||||
let parent = id(p, "parent").unwrap_or(page);
|
||||
let doc = s.doc_mut()?;
|
||||
let (x, y) = match (num(p, "x"), num(p, "y")) {
|
||||
(Some(x), Some(y)) => (x, y),
|
||||
_ => doc.world_bounds(c).map(|b| (b.x1 + 40.0, b.y0)).unwrap_or((0.0, 0.0)),
|
||||
};
|
||||
let i = create_instance(doc, c, parent, x, y)?;
|
||||
s.set_selection(vec![i]);
|
||||
Ok(json!({"id": i}))
|
||||
}),
|
||||
spec!("component.detach", "Detach instance", "{ids?}", true, detach),
|
||||
spec!("component.resetInstance", "Reset all changes", "{ids?}", true, |s, p| {
|
||||
for i in req_targets("component.resetInstance", s, p)? {
|
||||
let doc = s.doc_mut()?;
|
||||
let inst = owning_instance(doc, i).unwrap_or(i);
|
||||
for d in doc.subtree(inst) {
|
||||
if let Some(n) = doc.node_mut(d) {
|
||||
n.overrides.clear();
|
||||
}
|
||||
}
|
||||
sync_instance(doc, inst);
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("component.goToMain", "Go to main component", "{id?}", false, |s, p| {
|
||||
let i = targets(s, p).first().copied().ok_or_else(|| bad("component.goToMain", "select an instance"))?;
|
||||
let doc = s.doc()?;
|
||||
let inst = owning_instance(doc, i).ok_or_else(|| bad("component.goToMain", "not an instance"))?;
|
||||
let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else { return Err(bad("component.goToMain", "not an instance")) };
|
||||
if !doc.contains(component) {
|
||||
return Err(EngineError::Other("the main component was deleted".into()));
|
||||
}
|
||||
super::edit::select_and_page(s, vec![component])?;
|
||||
let ds = s.ds_mut()?;
|
||||
if let Some(b) = ds.doc.world_bounds(component) {
|
||||
ds.view.fit(b, 2.0);
|
||||
}
|
||||
Ok(json!({"id": component}))
|
||||
}),
|
||||
spec!("component.pushChanges", "Push changes to main component", "{id?}", true, push_changes),
|
||||
spec!("component.restoreMain", "Restore main component", "{id?}", true, |_s, _p| {
|
||||
Err(EngineError::Disabled("component.restoreMain".into(), "the main component still exists".into()))
|
||||
}),
|
||||
spec!("instance.swap", "Swap instance", "{ids?, component}", true, |s, p| {
|
||||
let c = req_id("instance.swap", p, "component")?;
|
||||
for i in req_targets("instance.swap", s, p)? {
|
||||
if let NodeKind::Instance { component, .. } = &mut s.doc_mut()?.get_mut(i)?.kind {
|
||||
*component = c;
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("instance.setProperties", "Set instance properties", "{ids?, properties {name: value}} (variant properties switch variants)", true, set_props),
|
||||
spec!("component.addProperty", "Add component property", "{id? (component or set), name, type: boolean|text|instanceSwap|variant, default, layer? (node bound to it)}", true, add_prop),
|
||||
spec!("component.list", "List components", "{}", false, |s, _| {
|
||||
let doc = s.doc()?;
|
||||
Ok(Value::Array(
|
||||
doc.nodes()
|
||||
.filter(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. }))
|
||||
.map(|n| json!({"id": n.id, "name": n.name, "type": n.kind.type_name(), "page": doc.page_of(n.id), "instances": instances_of(doc, n.id).len()}))
|
||||
.collect(),
|
||||
))
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("component.create", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let ids = z_sorted(doc, &roots(doc, &ids));
|
||||
let target = if ids.len() == 1 && ids.first().and_then(|i| doc.node(*i)).is_some_and(|n| matches!(n.kind, NodeKind::Frame | NodeKind::Group)) {
|
||||
ids.first().copied().ok_or_else(|| bad("component.create", "nothing selected"))?
|
||||
} else {
|
||||
// Wrap the layers in a frame first (like Figma).
|
||||
let r = super::structure::specs()
|
||||
.into_iter()
|
||||
.find(|x| x.id == "object.frameSelection")
|
||||
.map(|x| (x.run)(s, &json!({"ids": ids})))
|
||||
.ok_or_else(|| EngineError::Other("frame selection unavailable".into()))??;
|
||||
let fid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("wrap failed".into()))?;
|
||||
let doc = s.doc_mut()?;
|
||||
let n = doc.get_mut(fid)?;
|
||||
n.fills.clear();
|
||||
fid
|
||||
};
|
||||
let doc = s.doc_mut()?;
|
||||
let n = doc.get_mut(target)?;
|
||||
let was_group = matches!(n.kind, NodeKind::Group);
|
||||
n.kind = NodeKind::Component { description: String::new(), props: Default::default() };
|
||||
if was_group {
|
||||
n.fills.clear();
|
||||
n.clip = false;
|
||||
n.blend = figcraft_color::BlendMode::PassThrough;
|
||||
}
|
||||
if ids.len() == 1 && was_group {
|
||||
// Keep the group's name; frames keep theirs too.
|
||||
}
|
||||
s.set_selection(vec![target]);
|
||||
Ok(json!({"id": target}))
|
||||
}
|
||||
|
||||
fn combine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("component.combineVariants", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
if !ids.iter().all(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))) {
|
||||
return Err(bad("component.combineVariants", "select components only"));
|
||||
}
|
||||
let ids = z_sorted(doc, &ids);
|
||||
let top = *ids.last().ok_or_else(|| bad("component.combineVariants", "nothing selected"))?;
|
||||
let parent = doc.get(top)?.parent.ok_or_else(|| bad("component.combineVariants", "page"))?;
|
||||
let bounds = doc.bounds_of(&ids).unwrap_or_default();
|
||||
let pw = doc.world_transform(parent);
|
||||
let local = figcraft_geom::transform_rect_bbox(pw.inverse(), bounds.inflate(20.0, 20.0));
|
||||
let base = ids.first().and_then(|i| doc.node(*i)).map(|n| n.name.split('/').next().unwrap_or("Component").trim().to_string()).unwrap_or_else(|| "Component".into());
|
||||
let set = doc.alloc_id();
|
||||
let mut n = Node::new(set, NodeKind::ComponentSet { description: String::new() }, base, local.width(), local.height());
|
||||
n.transform = figcraft_geom::Affine::translate((local.x0, local.y0));
|
||||
n.fills.clear();
|
||||
doc.insert(n, parent, doc.index_in_parent(top).map(|i| i + 1))?;
|
||||
for (k, i) in ids.iter().enumerate() {
|
||||
doc.reparent(*i, set, None)?;
|
||||
// Name variants "Property 1=Variant N" unless already in prop=value form.
|
||||
let node = doc.get_mut(*i)?;
|
||||
if !node.name.contains('=') {
|
||||
node.name = format!("Property 1={}", if k == 0 { "Default".to_string() } else { format!("Variant{}", k + 1) });
|
||||
}
|
||||
}
|
||||
s.set_selection(vec![set]);
|
||||
Ok(json!({"id": set}))
|
||||
}
|
||||
|
||||
fn detach(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("component.detach", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let mut out = Vec::new();
|
||||
for i in ids {
|
||||
let inst = if matches!(doc.get(i)?.kind, NodeKind::Instance { .. }) { i } else { continue };
|
||||
for d in doc.subtree(inst) {
|
||||
if let Some(n) = doc.node_mut(d) {
|
||||
n.source = None;
|
||||
n.overrides.clear();
|
||||
}
|
||||
}
|
||||
let n = doc.get_mut(inst)?;
|
||||
n.kind = NodeKind::Frame;
|
||||
out.push(inst);
|
||||
}
|
||||
s.set_selection(out.clone());
|
||||
Ok(json!({"ids": out}))
|
||||
}
|
||||
|
||||
fn push_changes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = targets(s, p).first().copied().ok_or_else(|| bad("component.pushChanges", "select an instance"))?;
|
||||
let doc = s.doc_mut()?;
|
||||
let inst = owning_instance(doc, i).ok_or_else(|| bad("component.pushChanges", "not an instance"))?;
|
||||
let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else { return ok() };
|
||||
// Copy every overridden layer's values to its source in the main.
|
||||
for d in doc.subtree(inst) {
|
||||
let Some(n) = doc.node(d).cloned() else { continue };
|
||||
let Some(src) = (if d == inst { Some(component) } else { n.source }) else { continue };
|
||||
if n.overrides.is_empty() || !doc.contains(src) {
|
||||
continue;
|
||||
}
|
||||
let Some(mut target) = doc.node(src).cloned() else { continue };
|
||||
for g in &n.overrides {
|
||||
match g.as_str() {
|
||||
"fills" => target.fills = n.fills.clone(),
|
||||
"strokes" => {
|
||||
target.strokes = n.strokes.clone();
|
||||
target.stroke = n.stroke.clone();
|
||||
}
|
||||
"effects" => target.effects = n.effects.clone(),
|
||||
"opacity" => target.opacity = n.opacity,
|
||||
"visible" => target.visible = n.visible,
|
||||
"corners" => target.corners = n.corners,
|
||||
"characters" | "textStyle" => {
|
||||
if let (NodeKind::Text(a), NodeKind::Text(b)) = (&n.kind, &mut target.kind) {
|
||||
*b = a.clone();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(slot) = doc.node_mut(src) {
|
||||
*slot = target;
|
||||
}
|
||||
if let Some(m) = doc.node_mut(d) {
|
||||
m.overrides.clear();
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("instance.setProperties", s, p)?;
|
||||
let props = get(p, "properties").and_then(Value::as_object).cloned().ok_or_else(|| bad("instance.setProperties", "`properties` object required"))?;
|
||||
let doc = s.doc_mut()?;
|
||||
for i in ids {
|
||||
let Some(NodeKind::Instance { component, .. }) = doc.node(i).map(|n| n.kind.clone()) else { continue };
|
||||
// Variant properties: switch to the matching sibling variant.
|
||||
let set = doc.node(component).and_then(|c| c.parent).filter(|pp| doc.node(*pp).is_some_and(|n| matches!(n.kind, NodeKind::ComponentSet { .. })));
|
||||
let mut wanted = doc.node(component).map(|c| variant_props(&c.name)).unwrap_or_default();
|
||||
let mut variant_change = false;
|
||||
for (k, v) in &props {
|
||||
if wanted.contains_key(k) {
|
||||
wanted.insert(k.clone(), v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()));
|
||||
variant_change = true;
|
||||
}
|
||||
}
|
||||
let n = doc.get_mut(i)?;
|
||||
if let NodeKind::Instance { component: c, props: ip } = &mut n.kind {
|
||||
for (k, v) in &props {
|
||||
ip.insert(k.clone(), v.clone());
|
||||
}
|
||||
let _ = c;
|
||||
}
|
||||
if variant_change
|
||||
&& let Some(set) = set
|
||||
&& let Some(v) = find_variant(doc, set, &wanted)
|
||||
&& let NodeKind::Instance { component: c, .. } = &mut doc.get_mut(i)?.kind
|
||||
{
|
||||
*c = v;
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
fn add_prop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.addProperty";
|
||||
let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component"))?;
|
||||
let name = req_string(cmd, p, "name")?;
|
||||
let t = req_string(cmd, p, "type")?;
|
||||
let def = match t.to_ascii_lowercase().as_str() {
|
||||
"boolean" | "bool" => PropDef::Boolean { default: boolean(p, "default").unwrap_or(true) },
|
||||
"text" => PropDef::Text { default: string(p, "default").unwrap_or_default() },
|
||||
"instanceswap" | "instance_swap" | "instance" => PropDef::InstanceSwap { default: id(p, "default").ok_or_else(|| bad(cmd, "default: component id"))? },
|
||||
"variant" => PropDef::Variant { options: vec![], default: string(p, "default").unwrap_or_default() },
|
||||
_ => return Err(bad(cmd, "type: boolean|text|instanceSwap|variant")),
|
||||
};
|
||||
let layer = id(p, "layer");
|
||||
let doc = s.doc_mut()?;
|
||||
let n = doc.get_mut(target)?;
|
||||
match &mut n.kind {
|
||||
NodeKind::Component { props, .. } => {
|
||||
props.insert(name.clone(), def.clone());
|
||||
}
|
||||
NodeKind::ComponentSet { .. } => {
|
||||
// Variant property on a set: rename every variant with the new property.
|
||||
let kids = n.children.clone();
|
||||
let dv = string(p, "default").unwrap_or_else(|| "Default".into());
|
||||
for c in kids {
|
||||
let cn = doc.get_mut(c)?;
|
||||
cn.name = if cn.name.contains('=') { format!("{}, {name}={dv}", cn.name) } else { format!("{name}={dv}") };
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
_ => return Err(bad(cmd, "select a component or component set")),
|
||||
}
|
||||
if let Some(l) = layer {
|
||||
let field = match def {
|
||||
PropDef::Boolean { .. } => "visible",
|
||||
PropDef::Text { .. } => "characters",
|
||||
PropDef::InstanceSwap { .. } => "mainComponent",
|
||||
PropDef::Variant { .. } => "variant",
|
||||
};
|
||||
doc.get_mut(l)?.prop_refs.insert(field.into(), name.clone());
|
||||
}
|
||||
// Existing instances get the default.
|
||||
for inst in instances_of(doc, target) {
|
||||
if let NodeKind::Instance { props, .. } = &mut doc.get_mut(inst)?.kind {
|
||||
let v = match &def {
|
||||
PropDef::Boolean { default } => json!(default),
|
||||
PropDef::Text { default } => json!(default),
|
||||
PropDef::InstanceSwap { default } => json!(default.0),
|
||||
PropDef::Variant { default, .. } => json!(default),
|
||||
};
|
||||
props.entry(name.clone()).or_insert(v);
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
|
@ -0,0 +1,300 @@
|
|||
//! Creating nodes.
|
||||
|
||||
use figcraft_color::{Color, ImageScaleMode, Paint};
|
||||
use figcraft_doc::*;
|
||||
use figcraft_geom::Point;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
/// Frame presets shown by the Frame tool (category, name, width, height).
|
||||
pub const FRAME_PRESETS: &[(&str, &str, f64, f64)] = &[
|
||||
("Phone", "iPhone 16", 393.0, 852.0),
|
||||
("Phone", "iPhone 16 Pro", 402.0, 874.0),
|
||||
("Phone", "iPhone 16 Pro Max", 440.0, 956.0),
|
||||
("Phone", "iPhone 16 Plus", 430.0, 932.0),
|
||||
("Phone", "iPhone SE", 320.0, 568.0),
|
||||
("Phone", "Android Compact", 412.0, 917.0),
|
||||
("Phone", "Android Medium", 700.0, 840.0),
|
||||
("Tablet", "iPad mini 8.3", 744.0, 1133.0),
|
||||
("Tablet", "iPad Pro 11\"", 834.0, 1194.0),
|
||||
("Tablet", "iPad Pro 12.9\"", 1024.0, 1366.0),
|
||||
("Tablet", "Surface Pro 8", 1440.0, 960.0),
|
||||
("Tablet", "Android Expanded", 1280.0, 800.0),
|
||||
("Desktop", "MacBook Air", 1280.0, 832.0),
|
||||
("Desktop", "MacBook Pro 14\"", 1512.0, 982.0),
|
||||
("Desktop", "MacBook Pro 16\"", 1728.0, 1117.0),
|
||||
("Desktop", "Desktop", 1440.0, 1024.0),
|
||||
("Desktop", "Wireframe", 1440.0, 1024.0),
|
||||
("Desktop", "TV", 1280.0, 720.0),
|
||||
("Presentation", "Slide 16:9", 1920.0, 1080.0),
|
||||
("Presentation", "Slide 4:3", 1024.0, 768.0),
|
||||
("Watch", "Apple Watch Series 10 42mm", 187.0, 223.0),
|
||||
("Watch", "Apple Watch 41mm", 176.0, 215.0),
|
||||
("Watch", "Apple Watch 45mm", 198.0, 242.0),
|
||||
("Paper", "A4", 595.0, 842.0),
|
||||
("Paper", "A5", 420.0, 595.0),
|
||||
("Paper", "A6", 297.0, 420.0),
|
||||
("Paper", "Letter", 612.0, 792.0),
|
||||
("Paper", "Tabloid", 792.0, 1224.0),
|
||||
("Social media", "Instagram post", 1080.0, 1350.0),
|
||||
("Social media", "Instagram story", 1080.0, 1920.0),
|
||||
("Social media", "Facebook post", 1200.0, 630.0),
|
||||
("Social media", "X post", 1200.0, 675.0),
|
||||
("Social media", "LinkedIn post", 1200.0, 1200.0),
|
||||
("Social media", "YouTube thumbnail", 1280.0, 720.0),
|
||||
("Social media", "Dribbble shot", 1600.0, 1200.0),
|
||||
];
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!(
|
||||
"node.create",
|
||||
"Create layer",
|
||||
"{type: rectangle|ellipse|polygon|star|line|arrow|vector|text|frame|section|component|slice, x?, y? (parent-relative), width?, height?, parent? (id; default: the page), index?, name?, text? (for text), path? (SVG path data for vector), props? {node.set keys}, select? (default true)}",
|
||||
true,
|
||||
create
|
||||
),
|
||||
spec!("frame.create", "Create frame", "{preset? (name from FRAME_PRESETS), x?, y?, width?, height?, parent?, name?, props?}", true, frame_create),
|
||||
spec!("shape.rectangle", "Rectangle", "{x, y, width, height, parent?, props?}", true, |s, p| create_kind(s, p, "rectangle")),
|
||||
spec!("shape.ellipse", "Ellipse", "{x, y, width, height, parent?, props?}", true, |s, p| create_kind(s, p, "ellipse")),
|
||||
spec!("shape.polygon", "Polygon", "{x, y, width, height, parent?, props? {pointCount}}", true, |s, p| create_kind(s, p, "polygon")),
|
||||
spec!("shape.star", "Star", "{x, y, width, height, parent?, props? {pointCount, innerRadius}}", true, |s, p| create_kind(s, p, "star")),
|
||||
spec!("shape.line", "Line", "{x, y, width (length), parent?, props? {rotation}}", true, |s, p| create_kind(s, p, "line")),
|
||||
spec!("shape.arrow", "Arrow", "{x, y, width (length), parent?, props?}", true, |s, p| create_kind(s, p, "arrow")),
|
||||
spec!("text.create", "Text", "{text, x, y, parent?, width? (fixed width wraps), props? (fontSize, fontFamily, …)}", true, |s, p| create_kind(s, p, "text")),
|
||||
spec!("vector.create", "Vector", "{path (SVG path data) | points [[x,y],..], closed?, x?, y?, parent?, props?}", true, |s, p| create_kind(s, p, "vector")),
|
||||
spec!("image.place", "Place image", "{data (base64) | path, x?, y?, width?, height?, parent?, mode? (fill|fit|crop|tile)}", true, place_image),
|
||||
spec!("frame.presets", "List frame presets", "{}", false, |_, _| {
|
||||
Ok(Value::Array(FRAME_PRESETS.iter().map(|(c, n, w, h)| json!({"category": c, "name": n, "width": w, "height": h})).collect()))
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
/// The top-most frame-like (or section) node on the page containing page point `pt`,
|
||||
/// excluding `exclude` and their descendants.
|
||||
pub fn frame_at(doc: &Document, page: NodeId, pt: Point, exclude: &[NodeId]) -> Option<NodeId> {
|
||||
fn rec(doc: &Document, id: NodeId, pt: Point, exclude: &[NodeId], depth: usize) -> Option<NodeId> {
|
||||
if depth > 128 || exclude.contains(&id) {
|
||||
return None;
|
||||
}
|
||||
let n = doc.node(id)?;
|
||||
if !n.visible || n.locked {
|
||||
return None;
|
||||
}
|
||||
let is_container = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. });
|
||||
let inside = doc.world_bounds(id).is_some_and(|b| b.contains(pt)) && {
|
||||
let inv = doc.world_transform(id).inverse();
|
||||
n.local_rect().contains(inv * pt)
|
||||
};
|
||||
if is_container && !inside {
|
||||
return None;
|
||||
}
|
||||
if !matches!(n.kind, NodeKind::Instance { .. }) {
|
||||
for c in n.children.iter().rev() {
|
||||
if let Some(f) = rec(doc, *c, pt, exclude, depth + 1) {
|
||||
return Some(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
(is_container && inside && !matches!(n.kind, NodeKind::Instance { .. })).then_some(id)
|
||||
}
|
||||
let p = doc.node(page)?;
|
||||
p.children.iter().rev().find_map(|c| rec(doc, *c, pt, exclude, 0))
|
||||
}
|
||||
|
||||
pub fn kind_for(t: &str) -> Option<(NodeKind, &'static str)> {
|
||||
Some(match t.to_ascii_lowercase().as_str() {
|
||||
"rectangle" | "rect" => (NodeKind::Rectangle, "Rectangle"),
|
||||
"ellipse" | "circle" | "oval" => (NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, "Ellipse"),
|
||||
"polygon" | "triangle" => (NodeKind::Polygon { count: 3 }, "Polygon"),
|
||||
"star" => (NodeKind::Star { count: 5, inner_ratio: 0.38 }, "Star"),
|
||||
"line" => (NodeKind::Line, "Line"),
|
||||
"arrow" => (NodeKind::Line, "Arrow"),
|
||||
"vector" | "path" => (NodeKind::Vector { network: VectorNetwork::default() }, "Vector"),
|
||||
"text" => (NodeKind::Text(TextData::default()), "Text"),
|
||||
"frame" => (NodeKind::Frame, "Frame"),
|
||||
"section" => (NodeKind::Section { contents_hidden: false }, "Section"),
|
||||
"component" => (NodeKind::Component { description: String::new(), props: Default::default() }, "Component"),
|
||||
"group" => (NodeKind::Group, "Group"),
|
||||
"slice" => (NodeKind::Slice, "Slice"),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn create_kind(s: &mut Session, p: &Value, t: &str) -> Result<Value> {
|
||||
let mut p = p.clone();
|
||||
if let Some(o) = p.as_object_mut() {
|
||||
o.insert("type".into(), json!(t));
|
||||
}
|
||||
create(s, &p)
|
||||
}
|
||||
|
||||
/// Parse SVG path data (`M L H V C S Q T A Z`, absolute and relative) into a bezier path.
|
||||
pub fn parse_path_data(d: &str) -> Option<figcraft_geom::BezPath> {
|
||||
figcraft_geom::BezPath::from_svg(d).ok()
|
||||
}
|
||||
|
||||
pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "node.create";
|
||||
let t = string(p, "type").ok_or_else(|| bad(cmd, "`type` is required"))?;
|
||||
let (mut kind, base) = kind_for(&t).ok_or_else(|| bad(cmd, format!("unknown type `{t}`")))?;
|
||||
let page = s.page()?;
|
||||
let parent = id(p, "parent").unwrap_or(page);
|
||||
let doc = s.doc()?;
|
||||
let pn = doc.get(parent)?;
|
||||
if !pn.kind.is_container() {
|
||||
return Err(bad(cmd, format!("{parent} can't contain layers")));
|
||||
}
|
||||
let x = num_or(p, "x", 0.0);
|
||||
let y = num_or(p, "y", 0.0);
|
||||
let (mut w, mut h) = match base {
|
||||
"Line" | "Arrow" => (num_or(p, "width", 100.0), 0.0),
|
||||
"Text" => (num_or(p, "width", 0.0), num_or(p, "height", 0.0)),
|
||||
"Frame" | "Component" => (num_or(p, "width", 100.0), num_or(p, "height", 100.0)),
|
||||
"Section" => (num_or(p, "width", 400.0), num_or(p, "height", 300.0)),
|
||||
_ => (num_or(p, "width", 100.0), num_or(p, "height", 100.0)),
|
||||
};
|
||||
// Vectors: path data or points define the shape and its box.
|
||||
let mut offset = (0.0, 0.0);
|
||||
if let NodeKind::Vector { network } = &mut kind {
|
||||
if let Some(d) = string(p, "path") {
|
||||
let mut path = parse_path_data(&d).ok_or_else(|| bad(cmd, "`path` is not valid SVG path data"))?;
|
||||
use figcraft_geom::Shape;
|
||||
let bb = path.bounding_box();
|
||||
path.apply_affine(figcraft_geom::Affine::translate((-bb.x0, -bb.y0)));
|
||||
*network = VectorNetwork::from_path(&path);
|
||||
if boolean(p, "closed") == Some(false) {
|
||||
network.regions.clear();
|
||||
}
|
||||
w = bb.width();
|
||||
h = bb.height();
|
||||
offset = (bb.x0, bb.y0);
|
||||
} else if let Some(Value::Array(pts)) = get(p, "points") {
|
||||
let pts: Vec<Point> = pts.iter().filter_map(|v| Some(Point::new(f64_of(v.get(0)?)?, f64_of(v.get(1)?)?))).collect();
|
||||
if pts.len() < 2 {
|
||||
return Err(bad(cmd, "`points` needs at least two [x, y] pairs"));
|
||||
}
|
||||
let bb = figcraft_geom::points_bounds(pts.iter().copied()).unwrap_or_default();
|
||||
let local: Vec<Point> = pts.iter().map(|q| Point::new(q.x - bb.x0, q.y - bb.y0)).collect();
|
||||
*network = VectorNetwork::from_points(&local, boolean(p, "closed").unwrap_or(false));
|
||||
w = bb.width();
|
||||
h = bb.height();
|
||||
offset = (bb.x0, bb.y0);
|
||||
}
|
||||
}
|
||||
if let NodeKind::Text(td) = &mut kind {
|
||||
td.characters = string(p, "text").or_else(|| string(p, "characters")).unwrap_or_default();
|
||||
if w > 0.0 {
|
||||
td.auto_resize = TextAutoResize::Height;
|
||||
}
|
||||
}
|
||||
let name = string(p, "name").unwrap_or_else(|| {
|
||||
if let NodeKind::Text(td) = &kind {
|
||||
let first: String = td.characters.lines().next().unwrap_or("").chars().take(64).collect();
|
||||
if !first.trim().is_empty() {
|
||||
return first;
|
||||
}
|
||||
}
|
||||
if matches!(base, "Frame" | "Section" | "Component" | "Group") { s.doc().map(|d| d.next_name(base)).unwrap_or_else(|_| base.to_string()) } else { base.to_string() }
|
||||
});
|
||||
let ds = s.ds_mut()?;
|
||||
let doc = ds.doc_mut();
|
||||
let nid = doc.alloc_id();
|
||||
let mut node = Node::new(nid, kind, name, w, h);
|
||||
node.transform = figcraft_geom::Affine::translate((x + offset.0, y + offset.1));
|
||||
if base == "Arrow" {
|
||||
node.stroke.cap_end = StrokeCap::LineArrow;
|
||||
}
|
||||
if base == "Section" {
|
||||
node.fills = vec![Paint::solid(Color::WHITE)];
|
||||
node.strokes = vec![Paint::solid(Color::from_rgb8_const(0xE6, 0xE6, 0xE6))];
|
||||
node.corners.radii = [2.0; 4];
|
||||
node.clip = false;
|
||||
}
|
||||
if let NodeKind::Text(_) = node.kind
|
||||
&& let Some((tw, th)) = figcraft_text::auto_size(&node)
|
||||
{
|
||||
node.width = tw;
|
||||
node.height = th;
|
||||
}
|
||||
let index = num(p, "index").map(|i| i.max(0.0) as usize);
|
||||
doc.insert(node, parent, index)?;
|
||||
if let Some(Value::Object(props)) = get(p, "props") {
|
||||
super::props::apply(cmd, doc, nid, props)?;
|
||||
}
|
||||
if boolean(p, "select").unwrap_or(true) {
|
||||
ds.selection = vec![nid];
|
||||
}
|
||||
Ok(json!({"id": nid}))
|
||||
}
|
||||
|
||||
fn frame_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut q = p.clone();
|
||||
if let Some(name) = string(p, "preset") {
|
||||
let (_, pname, w, h) = FRAME_PRESETS
|
||||
.iter()
|
||||
.find(|(_, n, _, _)| n.eq_ignore_ascii_case(&name))
|
||||
.ok_or_else(|| bad("frame.create", format!("unknown preset `{name}`")))?;
|
||||
if let Some(o) = q.as_object_mut() {
|
||||
o.entry("width").or_insert(json!(w));
|
||||
o.entry("height").or_insert(json!(h));
|
||||
o.entry("name").or_insert(json!(pname));
|
||||
}
|
||||
}
|
||||
if let Some(o) = q.as_object_mut() {
|
||||
o.insert("type".into(), json!("frame"));
|
||||
}
|
||||
create(s, &q)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_path(path: &str) -> Result<Vec<u8>> {
|
||||
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn read_path(path: &str) -> Result<Vec<u8>> {
|
||||
Err(EngineError::Other(format!("{path}: files can't be read in the browser")))
|
||||
}
|
||||
|
||||
/// Place an image as a rectangle with an image fill (Figma's image layer).
|
||||
pub fn place_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "image.place";
|
||||
let bytes = if let Some(b64) = string(p, "data") {
|
||||
use base64::Engine as _;
|
||||
let b64 = b64.split_once(',').map(|(_, d)| d.to_string()).unwrap_or(b64);
|
||||
base64::engine::general_purpose::STANDARD.decode(b64.trim()).map_err(|e| bad(cmd, format!("`data`: {e}")))?
|
||||
} else if let Some(path) = string(p, "path") {
|
||||
read_path(&path)?
|
||||
} else {
|
||||
return Err(bad(cmd, "pass `data` (base64) or `path`"));
|
||||
};
|
||||
let mode = match string(p, "mode").unwrap_or_default().to_ascii_lowercase().as_str() {
|
||||
"fit" => ImageScaleMode::Fit,
|
||||
"crop" => ImageScaleMode::Crop,
|
||||
"tile" => ImageScaleMode::Tile,
|
||||
_ => ImageScaleMode::Fill,
|
||||
};
|
||||
let page = s.page()?;
|
||||
let parent = id(p, "parent").unwrap_or(page);
|
||||
let ds = s.ds_mut()?;
|
||||
let doc = ds.doc_mut();
|
||||
let (hash, iw, ih) = figcraft_format::add_image(doc, bytes).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let (iw, ih) = (f64::from(iw), f64::from(ih));
|
||||
// Fit large images into 1000 px like Figma does when placing.
|
||||
let k = (1000.0 / iw.max(ih)).min(1.0);
|
||||
let w = num(p, "width").unwrap_or(iw * k);
|
||||
let h = num(p, "height").unwrap_or(if num(p, "width").is_some() { w * ih / iw.max(1.0) } else { ih * k });
|
||||
let nid = doc.alloc_id();
|
||||
let name = string(p, "name").unwrap_or_else(|| "Image".into());
|
||||
let mut node = Node::new(nid, NodeKind::Rectangle, name, w, h);
|
||||
node.transform = figcraft_geom::Affine::translate((num_or(p, "x", 0.0), num_or(p, "y", 0.0)));
|
||||
node.fills = vec![Paint::image(hash.clone(), mode)];
|
||||
doc.insert(node, parent, None)?;
|
||||
ds.selection = vec![nid];
|
||||
let _ = ok;
|
||||
Ok(json!({"id": nid, "image": hash, "width": w, "height": h}))
|
||||
}
|
||||
|
|
@ -0,0 +1,856 @@
|
|||
//! Edit menu: undo/redo, clipboard, duplicate/delete, selection commands, find, properties.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_geom::Affine;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!("edit.undo", "Undo", "{}", false, |s, _| Ok(json!({"undone": s.undo()?}))),
|
||||
spec!("edit.redo", "Redo", "{}", false, |s, _| Ok(json!({"redone": s.redo()?}))),
|
||||
spec!("edit.delete", "Delete", "{ids?}", true, delete),
|
||||
spec!("edit.duplicate", "Duplicate", "{ids?, dx?, dy? (offset; default 0 for frames-in-place... Figma offsets top-level frames to the right)}", true, duplicate),
|
||||
spec!("edit.copy", "Copy", "{ids?}", false, copy),
|
||||
spec!("edit.cut", "Cut", "{ids?}", true, cut),
|
||||
spec!("edit.paste", "Paste", "{parent?, x?, y? (page coordinates of the pasted top-left)}", true, paste),
|
||||
spec!("edit.pasteOver", "Paste over selection", "{}", true, paste_over),
|
||||
spec!("edit.pasteReplace", "Paste to replace", "{ids?}", true, paste_replace),
|
||||
spec!("edit.copyProperties", "Copy properties", "{id?}", false, copy_props),
|
||||
spec!("edit.pasteProperties", "Paste properties", "{ids?}", true, paste_props),
|
||||
spec!("edit.findReplace", "Find and replace", "{find, replace? (text layers), layerNames? (bool), select? (default true)}", true, find_replace),
|
||||
spec!("edit.setDefaultProperties", "Set default properties", "{id?}", false, |s, p| {
|
||||
let id = targets(s, p).first().copied().ok_or_else(|| bad("edit.setDefaultProperties", "select a layer"))?;
|
||||
let v = copy_props_of(s.doc()?, id);
|
||||
s.clipboard.props = Some(v);
|
||||
ok()
|
||||
}),
|
||||
spec!("select.set", "Select", "{ids: [..]} (replace the selection)", false, |s, p| {
|
||||
let ids = match get(p, "ids") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(id_of).collect(),
|
||||
_ => id(p, "id").into_iter().collect(),
|
||||
};
|
||||
select_and_page(s, ids)?;
|
||||
Ok(json!({"selection": s.selection()}))
|
||||
}),
|
||||
spec!("select.add", "Add to selection", "{ids}", false, |s, p| {
|
||||
let mut sel = s.selection();
|
||||
for i in targets_param(p) {
|
||||
if !sel.contains(&i) {
|
||||
sel.push(i);
|
||||
}
|
||||
}
|
||||
s.set_selection(sel);
|
||||
Ok(json!({"selection": s.selection()}))
|
||||
}),
|
||||
spec!("select.remove", "Remove from selection", "{ids}", false, |s, p| {
|
||||
let rm = targets_param(p);
|
||||
let sel: Vec<NodeId> = s.selection().into_iter().filter(|i| !rm.contains(i)).collect();
|
||||
s.set_selection(sel);
|
||||
Ok(json!({"selection": s.selection()}))
|
||||
}),
|
||||
spec!("select.all", "Select all", "{}", false, select_all),
|
||||
spec!("select.none", "Select none", "{}", false, |s, _| {
|
||||
s.set_selection(vec![]);
|
||||
if let Ok(ds) = s.ds_mut() {
|
||||
ds.text_edit = None;
|
||||
ds.vector_edit = None;
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("select.inverse", "Select inverse", "{}", false, select_inverse),
|
||||
spec!("select.children", "Select children", "{}", false, select_children),
|
||||
spec!("select.parent", "Select parent", "{}", false, select_parent),
|
||||
spec!("select.next", "Select next sibling", "{}", false, |s, _| select_sibling(s, 1)),
|
||||
spec!("select.previous", "Select previous sibling", "{}", false, |s, _| select_sibling(s, -1)),
|
||||
spec!("select.matching", "Select matching layers", "{}", false, select_matching),
|
||||
spec!("select.sameFill", "Select same fill", "{}", false, |s, _| select_same(s, |a, b| a.fills == b.fills && !a.fills.is_empty())),
|
||||
spec!("select.sameStroke", "Select same stroke", "{}", false, |s, _| select_same(s, |a, b| a.strokes == b.strokes && a.stroke == b.stroke && !a.strokes.is_empty())),
|
||||
spec!("select.sameEffect", "Select same effect", "{}", false, |s, _| select_same(s, |a, b| a.effects == b.effects && !a.effects.is_empty())),
|
||||
spec!("select.sameText", "Select same text properties", "{}", false, |s, _| {
|
||||
select_same(s, |a, b| matches!((&a.kind, &b.kind), (NodeKind::Text(x), NodeKind::Text(y)) if x.style == y.style))
|
||||
}),
|
||||
spec!("select.sameFont", "Select same font", "{}", false, |s, _| {
|
||||
select_same(s, |a, b| matches!((&a.kind, &b.kind), (NodeKind::Text(x), NodeKind::Text(y)) if x.style.font_family == y.style.font_family && x.style.font_style == y.style.font_style))
|
||||
}),
|
||||
spec!("select.sameInstance", "Select same instance", "{}", false, |s, _| {
|
||||
select_same(s, |a, b| matches!((&a.kind, &b.kind), (NodeKind::Instance { component: x, .. }, NodeKind::Instance { component: y, .. }) if x == y))
|
||||
}),
|
||||
spec!("select.sameProperties", "Select same properties", "{}", false, |s, _| {
|
||||
select_same(s, |a, b| a.kind.type_name() == b.kind.type_name() && a.fills == b.fills && a.strokes == b.strokes && a.effects == b.effects && a.corners == b.corners)
|
||||
}),
|
||||
spec!("select.byName", "Select layers by name", "{query, page? (default current; \"all\" for every page)}", false, |s, p| {
|
||||
let q = req_string("select.byName", p, "query")?;
|
||||
let page = if string(p, "page").as_deref() == Some("all") { None } else { Some(s.page()?) };
|
||||
let ids = s.doc()?.find_by_name(&q, page);
|
||||
select_and_page(s, ids.clone())?;
|
||||
Ok(json!({"selection": ids}))
|
||||
}),
|
||||
spec!("transform.nudge", "Nudge", "{dx, dy, big? (use the big nudge amount)}", true, nudge),
|
||||
spec!("object.rename", "Rename", "{id?, name}", true, |s, p| {
|
||||
let name = req_string("object.rename", p, "name")?;
|
||||
let ids = req_targets("object.rename", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
for i in ids {
|
||||
doc.get_mut(i)?.name = name.clone();
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("object.batchRename", "Batch rename", "{ids?, find?, replace?, name? (pattern: $& = current name, $N or $n = number asc/desc)}", true, batch_rename),
|
||||
spec!("object.toggleVisible", "Show/Hide selection", "{ids?, visible?}", true, |s, p| toggle(s, p, "visible")),
|
||||
spec!("object.toggleLock", "Lock/Unlock selection", "{ids?, locked?}", true, |s, p| toggle(s, p, "locked")),
|
||||
spec!("object.hideOthers", "Hide other layers", "{}", true, hide_others),
|
||||
spec!("object.removeFill", "Remove fill", "{ids?}", true, |s, p| {
|
||||
for i in req_targets("object.removeFill", s, p)? {
|
||||
s.doc_mut()?.get_mut(i)?.fills.clear();
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("object.removeStroke", "Remove stroke", "{ids?}", true, |s, p| {
|
||||
for i in req_targets("object.removeStroke", s, p)? {
|
||||
s.doc_mut()?.get_mut(i)?.strokes.clear();
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("object.swapFillStroke", "Swap fill and stroke", "{ids?}", true, |s, p| {
|
||||
for i in req_targets("object.swapFillStroke", s, p)? {
|
||||
let n = s.doc_mut()?.get_mut(i)?;
|
||||
std::mem::swap(&mut n.fills, &mut n.strokes);
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("layers.collapseAll", "Collapse layers", "{}", false, |s, _| {
|
||||
s.request_ui("layers.collapseAll", json!({}));
|
||||
ok()
|
||||
}),
|
||||
spec!("node.set", "Set properties", super::props::KEYS, true, set),
|
||||
spec!("node.get", "Get layer", "{id}", false, |s, p| {
|
||||
let i = req_id("node.get", p, "id")?;
|
||||
Ok(crate::inspect::node_json(s.doc()?, i, 1))
|
||||
}),
|
||||
spec!("node.reparent", "Move layer into", "{ids?, parent, index?}", true, reparent),
|
||||
spec!("node.reorder", "Reorder layer", "{id, index}", true, |s, p| {
|
||||
let i = req_id("node.reorder", p, "id")?;
|
||||
let idx = req_num("node.reorder", p, "index")?.max(0.0) as usize;
|
||||
s.doc_mut()?.reorder(i, idx)?;
|
||||
ok()
|
||||
}),
|
||||
spec!("fill.add", "Add fill", "{ids?, paint? (default #D9D9D9 or white for frames)}", true, |s, p| paint_list(s, p, true, true)),
|
||||
spec!("stroke.add", "Add stroke", "{ids?, paint? (default black)}", true, |s, p| paint_list(s, p, false, true)),
|
||||
spec!("fill.remove", "Remove fill", "{ids?, index}", true, |s, p| paint_list(s, p, true, false)),
|
||||
spec!("stroke.remove", "Remove stroke", "{ids?, index}", true, |s, p| paint_list(s, p, false, false)),
|
||||
spec!("fill.set", "Set fill", "{ids?, index?, paint | color, opacity?, visible?, blend?}", true, |s, p| paint_set(s, p, true)),
|
||||
spec!("stroke.set", "Set stroke", "{ids?, index?, paint | color, opacity?, visible?, blend?}", true, |s, p| paint_set(s, p, false)),
|
||||
spec!("effect.add", "Add effect", "{ids?, type? (dropShadow|innerShadow|layerBlur|backgroundBlur|noise|texture), effect? {..}}", true, effect_add),
|
||||
spec!("effect.remove", "Remove effect", "{ids?, index}", true, |s, p| {
|
||||
let i = req_num("effect.remove", p, "index")? as usize;
|
||||
for id in req_targets("effect.remove", s, p)? {
|
||||
let n = s.doc_mut()?.get_mut(id)?;
|
||||
if i < n.effects.len() {
|
||||
n.effects.remove(i);
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("effect.set", "Set effect", "{ids?, index, effect {..}}", true, |s, p| {
|
||||
let i = req_num("effect.set", p, "index")? as usize;
|
||||
let e: Effect = parse("effect.set", p, "effect")?.ok_or_else(|| bad("effect.set", "`effect` required"))?;
|
||||
for id in req_targets("effect.set", s, p)? {
|
||||
let n = s.doc_mut()?.get_mut(id)?;
|
||||
if let Some(slot) = n.effects.get_mut(i) {
|
||||
*slot = e.clone();
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("grid.add", "Add layout grid", "{ids?, grid? {kind: grid|columns|rows, ..}}", true, |s, p| {
|
||||
let g: LayoutGrid = parse("grid.add", p, "grid")?.unwrap_or_default();
|
||||
for id in req_targets("grid.add", s, p)? {
|
||||
s.doc_mut()?.get_mut(id)?.grids.push(g.clone());
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("grid.remove", "Remove layout grid", "{ids?, index}", true, |s, p| {
|
||||
let i = req_num("grid.remove", p, "index")? as usize;
|
||||
for id in req_targets("grid.remove", s, p)? {
|
||||
let n = s.doc_mut()?.get_mut(id)?;
|
||||
if i < n.grids.len() {
|
||||
n.grids.remove(i);
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("export.add", "Add export setting", "{ids?, setting? {size:{by:scale,value:2}, suffix, format: png|jpg|svg|pdf|webp}}", true, |s, p| {
|
||||
let e: ExportSetting = parse("export.add", p, "setting")?.unwrap_or_default();
|
||||
for id in req_targets("export.add", s, p)? {
|
||||
s.doc_mut()?.get_mut(id)?.exports.push(e.clone());
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("export.remove", "Remove export setting", "{ids?, index}", true, |s, p| {
|
||||
let i = req_num("export.remove", p, "index")? as usize;
|
||||
for id in req_targets("export.remove", s, p)? {
|
||||
let n = s.doc_mut()?.get_mut(id)?;
|
||||
if i < n.exports.len() {
|
||||
n.exports.remove(i);
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("command.batch", "Run several commands", "{label?, commands: [{command, params}]} (one undo step)", false, |s, p| {
|
||||
let label = string(p, "label").unwrap_or_else(|| "Batch".into());
|
||||
let cmds: Vec<(String, Value)> = match get(p, "commands") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(|c| Some((c.get("command")?.as_str()?.to_string(), c.get("params").cloned().unwrap_or(json!({}))))).collect(),
|
||||
_ => return Err(bad("command.batch", "`commands` must be an array")),
|
||||
};
|
||||
Ok(Value::Array(s.batch(&label, &cmds)?))
|
||||
}),
|
||||
spec!("edit.repeat", "Repeat last command", "{}", true, |s, _| {
|
||||
let Some((id, p)) = s.last_command.clone() else { return Err(EngineError::Disabled("edit.repeat".into(), "nothing to repeat".into())) };
|
||||
let spec = crate::find_command(&id).ok_or_else(|| EngineError::UnknownCommand(id.clone()))?;
|
||||
(spec.run)(s, &p)
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
fn targets_param(p: &Value) -> Vec<NodeId> {
|
||||
match get(p, "ids") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(id_of).collect(),
|
||||
_ => id(p, "id").into_iter().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Select `ids`, switching to their page if they're elsewhere.
|
||||
pub fn select_and_page(s: &mut Session, ids: Vec<NodeId>) -> Result<()> {
|
||||
let doc = s.doc()?;
|
||||
for i in &ids {
|
||||
if !doc.contains(*i) {
|
||||
return Err(EngineError::NoNode(*i));
|
||||
}
|
||||
}
|
||||
if let Some(first) = ids.first()
|
||||
&& let Some(pg) = doc.page_of(*first)
|
||||
&& pg != s.page()?
|
||||
{
|
||||
s.ds_mut()?.page = pg;
|
||||
}
|
||||
s.set_selection(ids);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Top-level selection: drop ids whose ancestor is also selected.
|
||||
pub fn roots(doc: &Document, ids: &[NodeId]) -> Vec<NodeId> {
|
||||
let set: BTreeSet<NodeId> = ids.iter().copied().collect();
|
||||
ids.iter().copied().filter(|i| !doc.ancestors(*i).iter().any(|a| set.contains(a))).collect()
|
||||
}
|
||||
|
||||
/// Ids sorted in document (z) order.
|
||||
pub fn z_sorted(doc: &Document, ids: &[NodeId]) -> Vec<NodeId> {
|
||||
let Some(page) = ids.first().and_then(|i| doc.page_of(*i)) else { return ids.to_vec() };
|
||||
let order: BTreeMap<NodeId, usize> = doc.subtree(page).into_iter().enumerate().map(|(i, id)| (id, i)).collect();
|
||||
let mut v = ids.to_vec();
|
||||
v.sort_by_key(|i| order.get(i).copied().unwrap_or(usize::MAX));
|
||||
v
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("edit.delete", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let ids = roots(doc, &ids);
|
||||
let mut parents = Vec::new();
|
||||
for i in &ids {
|
||||
if doc.node(*i).is_some_and(Node::is_page) {
|
||||
continue;
|
||||
}
|
||||
if let Some(pn) = doc.node(*i).and_then(|n| n.parent) {
|
||||
parents.push(pn);
|
||||
}
|
||||
doc.remove(*i)?;
|
||||
}
|
||||
// Select the parent frame like Figma? Figma clears the selection.
|
||||
s.set_selection(vec![]);
|
||||
Ok(json!({"deleted": ids}))
|
||||
}
|
||||
|
||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("edit.duplicate", s, p)?;
|
||||
let rename = s.prefs.rename_duplicates;
|
||||
let doc = s.doc_mut()?;
|
||||
let ids = z_sorted(doc, &roots(doc, &ids));
|
||||
let mut out = Vec::new();
|
||||
for i in ids {
|
||||
let n = doc.get(i)?;
|
||||
let parent = n.parent.ok_or_else(|| bad("edit.duplicate", "pages are duplicated with page.duplicate"))?;
|
||||
let top_frame = doc.node(parent).is_some_and(Node::is_page) && n.kind.is_frame_like();
|
||||
let idx = doc.index_in_parent(i).map(|x| x + 1);
|
||||
let w = n.width;
|
||||
let nid = doc.duplicate_into(i, parent, idx)?;
|
||||
let (dx, dy) = match (num(p, "dx"), num(p, "dy")) {
|
||||
(Some(dx), dy) => (dx, dy.unwrap_or(0.0)),
|
||||
(None, Some(dy)) => (0.0, dy),
|
||||
// Top-level frames duplicate to the right with a gap, everything else in place.
|
||||
_ if top_frame => (w + 100.0, 0.0),
|
||||
_ => (0.0, 0.0),
|
||||
};
|
||||
let nn = doc.get_mut(nid)?;
|
||||
nn.transform = Affine::translate((dx, dy)) * nn.transform;
|
||||
if rename {
|
||||
nn.name = bump_name(&nn.name);
|
||||
}
|
||||
out.push(nid);
|
||||
}
|
||||
s.set_selection(out.clone());
|
||||
Ok(json!({"ids": out}))
|
||||
}
|
||||
|
||||
fn bump_name(n: &str) -> String {
|
||||
if let Some((base, num)) = n.rsplit_once(' ')
|
||||
&& let Ok(k) = num.parse::<u64>()
|
||||
{
|
||||
return format!("{base} {}", k + 1);
|
||||
}
|
||||
format!("{n} 2")
|
||||
}
|
||||
|
||||
/// The copied form of the selection.
|
||||
pub fn copy_to_clipboard(s: &mut Session, ids: &[NodeId]) -> Result<()> {
|
||||
let ds = s.ds()?;
|
||||
let doc = &ds.doc;
|
||||
let ids = z_sorted(doc, &roots(doc, ids));
|
||||
let mut nodes = Vec::new();
|
||||
let mut images = BTreeMap::new();
|
||||
for i in &ids {
|
||||
let world = doc.world_transform(*i);
|
||||
let mut sub: Vec<Node> = doc.subtree(*i).into_iter().filter_map(|x| doc.node(x).cloned()).collect();
|
||||
// Store the root's transform in page space so pastes land where it was.
|
||||
if let Some(r) = sub.first_mut() {
|
||||
r.transform = world;
|
||||
}
|
||||
for n in &sub {
|
||||
for p in n.fills.iter().chain(n.strokes.iter()) {
|
||||
if let figcraft_color::PaintKind::Image(img) = &p.kind
|
||||
&& let Some(d) = doc.images.get(&img.image)
|
||||
{
|
||||
images.insert(img.image.clone(), d.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
nodes.push((*i, sub));
|
||||
}
|
||||
let bounds = doc.bounds_of(&ids);
|
||||
let uid = ds.uid;
|
||||
s.clipboard.nodes = nodes;
|
||||
s.clipboard.images = images;
|
||||
s.clipboard.bounds = bounds;
|
||||
s.clipboard.source_doc = uid;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("edit.copy", s, p)?;
|
||||
copy_to_clipboard(s, &ids)?;
|
||||
Ok(json!({"copied": s.clipboard.nodes.len()}))
|
||||
}
|
||||
|
||||
fn cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("edit.cut", s, p)?;
|
||||
copy_to_clipboard(s, &ids)?;
|
||||
delete(s, &json!({"ids": ids}))
|
||||
}
|
||||
|
||||
/// Paste the clipboard under `parent` with the copied top-left moved to `at` (page coords).
|
||||
pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Result<Vec<NodeId>> {
|
||||
let clip = s.clipboard.clone();
|
||||
if clip.nodes.is_empty() {
|
||||
return Err(EngineError::Disabled("edit.paste".into(), "the clipboard is empty".into()));
|
||||
}
|
||||
let doc = s.doc_mut()?;
|
||||
for (k, v) in &clip.images {
|
||||
doc.images.entry(k.clone()).or_insert_with(|| v.clone());
|
||||
}
|
||||
let parent_world = doc.world_transform(parent);
|
||||
let shift = match (at, clip.bounds) {
|
||||
(Some((x, y)), Some(b)) => Affine::translate((x - b.x0, y - b.y0)),
|
||||
_ => Affine::IDENTITY,
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for (root, nodes) in &clip.nodes {
|
||||
let mut map = BTreeMap::new();
|
||||
for n in nodes {
|
||||
map.insert(n.id, doc.alloc_id());
|
||||
}
|
||||
let mut nodes = nodes.clone();
|
||||
if let Some(r) = nodes.first_mut() {
|
||||
r.transform = parent_world.inverse() * shift * r.transform;
|
||||
}
|
||||
let nid = doc.paste_nodes(&nodes, &map, *root, parent, None)?;
|
||||
out.push(nid);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let page = s.page()?;
|
||||
// Figma pastes into the selected frame (if one is selected), at the same position when it fits,
|
||||
// else in the frame's centre; otherwise in place on the page (or the viewport centre).
|
||||
let sel = s.selection();
|
||||
let doc = s.doc()?;
|
||||
let target = id(p, "parent").or_else(|| {
|
||||
sel.first().copied().filter(|i| sel.len() == 1 && doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && !matches!(n.kind, NodeKind::Instance { .. })))
|
||||
});
|
||||
let parent = target.unwrap_or(page);
|
||||
let at = match (num(p, "x"), num(p, "y")) {
|
||||
(Some(x), Some(y)) => Some((x, y)),
|
||||
_ => {
|
||||
let b = s.clipboard.bounds;
|
||||
match (target, b) {
|
||||
(Some(t), Some(b)) => {
|
||||
let tb = doc.world_bounds(t);
|
||||
match tb {
|
||||
Some(tb) if tb.contains(b.origin()) && tb.contains(figcraft_geom::Point::new(b.x1, b.y1)) => None,
|
||||
Some(tb) => Some((tb.center().x - b.width() / 2.0, tb.center().y - b.height() / 2.0)),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
(None, Some(b)) => {
|
||||
// Off-screen originals paste at the viewport centre.
|
||||
let v = &s.ds()?.view;
|
||||
let (ox, oy) = v.origin();
|
||||
let vr = figcraft_geom::Rect::new(ox, oy, ox + v.vw / v.zoom.max(1e-9), oy + v.vh / v.zoom.max(1e-9));
|
||||
if vr.intersect(b).is_zero_area() { Some((v.cx - b.width() / 2.0, v.cy - b.height() / 2.0)) } else { None }
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
};
|
||||
let ids = paste_into(s, parent, at)?;
|
||||
s.set_selection(ids.clone());
|
||||
Ok(json!({"ids": ids}))
|
||||
}
|
||||
|
||||
fn paste_over(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let sel = s.selection();
|
||||
let page = s.page()?;
|
||||
let doc = s.doc()?;
|
||||
let (parent, at) = match sel.first() {
|
||||
Some(i) => {
|
||||
let parent = doc.node(*i).and_then(|n| n.parent).unwrap_or(page);
|
||||
(parent, doc.world_bounds(*i).map(|b| (b.x0, b.y0)))
|
||||
}
|
||||
None => (page, None),
|
||||
};
|
||||
let ids = paste_into(s, parent, at)?;
|
||||
s.set_selection(ids.clone());
|
||||
Ok(json!({"ids": ids}))
|
||||
}
|
||||
|
||||
fn paste_replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let targets = req_targets("edit.pasteReplace", s, p)?;
|
||||
let mut out = Vec::new();
|
||||
for t in targets {
|
||||
let doc = s.doc()?;
|
||||
let Some(n) = doc.node(t) else { continue };
|
||||
let parent = n.parent.ok_or_else(|| bad("edit.pasteReplace", "can't replace a page"))?;
|
||||
let idx = doc.index_in_parent(t);
|
||||
let at = doc.world_bounds(t).map(|b| (b.x0, b.y0));
|
||||
let ids = paste_into(s, parent, at)?;
|
||||
let doc = s.doc_mut()?;
|
||||
for (k, i) in ids.iter().enumerate() {
|
||||
if let Some(ix) = idx {
|
||||
let _ = doc.reorder(*i, ix + k);
|
||||
}
|
||||
}
|
||||
doc.remove(t)?;
|
||||
out.extend(ids);
|
||||
}
|
||||
s.set_selection(out.clone());
|
||||
Ok(json!({"ids": out}))
|
||||
}
|
||||
|
||||
fn copy_props_of(doc: &Document, id: NodeId) -> Value {
|
||||
let Some(n) = doc.node(id) else { return Value::Null };
|
||||
let mut v = json!({
|
||||
"fills": n.fills, "strokes": n.strokes, "strokeWeight": n.stroke.weight, "strokeAlign": n.stroke.align,
|
||||
"effects": n.effects, "opacity": n.opacity, "blend": n.blend, "cornerRadius": n.corners.radii,
|
||||
"cornerSmoothing": n.corners.smoothing,
|
||||
});
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
v["fontFamily"] = json!(t.style.font_family);
|
||||
v["fontStyle"] = json!(t.style.font_style);
|
||||
v["fontSize"] = json!(t.style.font_size);
|
||||
v["lineHeight"] = serde_json::to_value(t.style.line_height).unwrap_or(Value::Null);
|
||||
v["letterSpacing"] = serde_json::to_value(t.style.letter_spacing).unwrap_or(Value::Null);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn copy_props(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = targets(s, p).first().copied().ok_or_else(|| bad("edit.copyProperties", "select one layer"))?;
|
||||
let v = copy_props_of(s.doc()?, id);
|
||||
s.clipboard.props = Some(v.clone());
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn paste_props(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let props = s.clipboard.props.clone().ok_or_else(|| EngineError::Disabled("edit.pasteProperties".into(), "no copied properties".into()))?;
|
||||
let ids = req_targets("edit.pasteProperties", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
for i in ids {
|
||||
let mut m = props.as_object().cloned().unwrap_or_default();
|
||||
let is_text = matches!(doc.get(i)?.kind, NodeKind::Text(_));
|
||||
if !is_text {
|
||||
m.retain(|k, _| !k.starts_with("font") && k != "lineHeight" && k != "letterSpacing");
|
||||
}
|
||||
if !doc.get(i)?.kind.has_corner_radius() {
|
||||
m.remove("cornerRadius");
|
||||
m.remove("cornerSmoothing");
|
||||
}
|
||||
super::props::apply("edit.pasteProperties", doc, i, &m)?;
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
fn select_all(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let doc = s.doc()?;
|
||||
let sel = s.selection();
|
||||
// Select all siblings of the current selection, else the page's top level.
|
||||
let parent = sel.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent).unwrap_or(s.page()?);
|
||||
let ids: Vec<NodeId> = doc.get(parent)?.children.iter().copied().filter(|c| doc.node(*c).is_some_and(|n| n.visible && !n.locked)).collect();
|
||||
s.set_selection(ids.clone());
|
||||
Ok(json!({"selection": ids}))
|
||||
}
|
||||
|
||||
fn select_inverse(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let doc = s.doc()?;
|
||||
let sel = s.selection();
|
||||
let parent = sel.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent).unwrap_or(s.page()?);
|
||||
let ids: Vec<NodeId> = doc.get(parent)?.children.iter().copied().filter(|c| !sel.contains(c) && doc.node(*c).is_some_and(|n| !n.locked)).collect();
|
||||
s.set_selection(ids.clone());
|
||||
Ok(json!({"selection": ids}))
|
||||
}
|
||||
|
||||
fn select_children(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let doc = s.doc()?;
|
||||
let mut ids = Vec::new();
|
||||
for i in s.selection() {
|
||||
if let Some(n) = doc.node(i) {
|
||||
ids.extend(n.children.iter().copied());
|
||||
}
|
||||
}
|
||||
if ids.is_empty() {
|
||||
return Ok(json!({"selection": s.selection()}));
|
||||
}
|
||||
s.set_selection(ids.clone());
|
||||
Ok(json!({"selection": ids}))
|
||||
}
|
||||
|
||||
fn select_parent(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let doc = s.doc()?;
|
||||
let mut ids: Vec<NodeId> = Vec::new();
|
||||
for i in s.selection() {
|
||||
if let Some(p) = doc.node(i).and_then(|n| n.parent)
|
||||
&& !doc.node(p).is_some_and(Node::is_page)
|
||||
&& !ids.contains(&p)
|
||||
{
|
||||
ids.push(p);
|
||||
}
|
||||
}
|
||||
if ids.is_empty() {
|
||||
return Ok(json!({"selection": s.selection()}));
|
||||
}
|
||||
s.set_selection(ids.clone());
|
||||
Ok(json!({"selection": ids}))
|
||||
}
|
||||
|
||||
fn select_sibling(s: &mut Session, dir: i64) -> Result<Value> {
|
||||
let doc = s.doc()?;
|
||||
let sel = s.selection();
|
||||
let Some(cur) = sel.last().copied() else {
|
||||
let first = doc.get(s.page()?)?.children.last().copied();
|
||||
s.set_selection(first.into_iter().collect());
|
||||
return Ok(json!({"selection": s.selection()}));
|
||||
};
|
||||
let parent = doc.get(cur)?.parent.ok_or_else(|| bad("select.next", "no parent"))?;
|
||||
let kids = &doc.get(parent)?.children;
|
||||
let idx = kids.iter().position(|c| *c == cur).unwrap_or(0) as i64;
|
||||
let n = kids.len() as i64;
|
||||
// Layers panel order is reversed (top of the list = last child): Tab goes down the list.
|
||||
let next = (idx - dir).rem_euclid(n.max(1)) as usize;
|
||||
let id = kids.get(next).copied().unwrap_or(cur);
|
||||
s.set_selection(vec![id]);
|
||||
Ok(json!({"selection": [id]}))
|
||||
}
|
||||
|
||||
fn select_matching(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let doc = s.doc()?;
|
||||
let sel = s.selection();
|
||||
let Some(first) = sel.first().and_then(|i| doc.node(*i)) else { return Ok(json!({"selection": []})) };
|
||||
let (name, ty) = (first.name.clone(), first.kind.type_name());
|
||||
let page = s.page()?;
|
||||
let ids: Vec<NodeId> = doc.subtree(page).into_iter().filter(|i| doc.node(*i).is_some_and(|n| n.name == name && n.kind.type_name() == ty)).collect();
|
||||
s.set_selection(ids.clone());
|
||||
Ok(json!({"selection": ids}))
|
||||
}
|
||||
|
||||
fn select_same(s: &mut Session, same: impl Fn(&Node, &Node) -> bool) -> Result<Value> {
|
||||
let doc = s.doc()?;
|
||||
let sel = s.selection();
|
||||
let Some(first) = sel.first().and_then(|i| doc.node(*i)).cloned() else { return Ok(json!({"selection": []})) };
|
||||
let page = s.page()?;
|
||||
let ids: Vec<NodeId> = doc.subtree(page).into_iter().filter(|i| *i != page && doc.node(*i).is_some_and(|n| same(&first, n))).collect();
|
||||
s.set_selection(ids.clone());
|
||||
Ok(json!({"selection": ids}))
|
||||
}
|
||||
|
||||
fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let big = boolean(p, "big").unwrap_or(false);
|
||||
let k = if big { s.prefs.nudge_big } else { s.prefs.nudge_small };
|
||||
let dx = num_or(p, "dx", 0.0) * k;
|
||||
let dy = num_or(p, "dy", 0.0) * k;
|
||||
let ids = req_targets("transform.nudge", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
for i in roots(doc, &ids) {
|
||||
let n = doc.get_mut(i)?;
|
||||
n.transform = Affine::translate((dx, dy)) * n.transform;
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
fn toggle(s: &mut Session, p: &Value, what: &str) -> Result<Value> {
|
||||
let ids = req_targets("object.toggle", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let explicit = boolean(p, what);
|
||||
// Figma: if any is visible, hide all (and vice versa).
|
||||
let value = explicit.unwrap_or_else(|| match what {
|
||||
"visible" => !ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.visible)),
|
||||
_ => !ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.locked)),
|
||||
});
|
||||
for i in &ids {
|
||||
let n = doc.get_mut(*i)?;
|
||||
if what == "visible" {
|
||||
n.visible = value;
|
||||
} else {
|
||||
n.locked = value;
|
||||
}
|
||||
}
|
||||
if what == "locked" && value {
|
||||
s.set_selection(vec![]);
|
||||
}
|
||||
Ok(json!({what: value}))
|
||||
}
|
||||
|
||||
fn hide_others(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let sel = s.selection();
|
||||
let page = s.page()?;
|
||||
let doc = s.doc_mut()?;
|
||||
let keep: BTreeSet<NodeId> = sel.iter().flat_map(|i| {
|
||||
let mut v = doc.ancestors(*i);
|
||||
v.push(*i);
|
||||
v
|
||||
}).collect();
|
||||
let tops: Vec<NodeId> = doc.get(page)?.children.clone();
|
||||
for t in tops {
|
||||
if !keep.contains(&t) {
|
||||
doc.get_mut(t)?.visible = false;
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
fn batch_rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("object.batchRename", s, p)?;
|
||||
let find = string(p, "find");
|
||||
let replace = string(p, "replace").unwrap_or_default();
|
||||
let pattern = string(p, "name");
|
||||
let doc = s.doc_mut()?;
|
||||
let ids = z_sorted(doc, &ids);
|
||||
let n = ids.len();
|
||||
for (k, i) in ids.iter().enumerate() {
|
||||
let node = doc.get_mut(*i)?;
|
||||
let mut name = node.name.clone();
|
||||
if let Some(f) = &find
|
||||
&& !f.is_empty()
|
||||
{
|
||||
name = name.replace(f.as_str(), &replace);
|
||||
}
|
||||
if let Some(pat) = &pattern {
|
||||
name = pat.replace("$&", &name).replace("$N", &(k + 1).to_string()).replace("$n", &(n - k).to_string());
|
||||
}
|
||||
node.name = name;
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let find = req_string("edit.findReplace", p, "find")?;
|
||||
if find.is_empty() {
|
||||
return Err(bad("edit.findReplace", "`find` is empty"));
|
||||
}
|
||||
let replace = string(p, "replace");
|
||||
let names = boolean(p, "layerNames").unwrap_or(false);
|
||||
let page = s.page()?;
|
||||
let doc = s.doc_mut()?;
|
||||
let mut hits = Vec::new();
|
||||
let lower = find.to_lowercase();
|
||||
for i in doc.subtree(page) {
|
||||
let Some(n) = doc.node(i) else { continue };
|
||||
let text_hit = matches!(&n.kind, NodeKind::Text(t) if t.characters.to_lowercase().contains(&lower));
|
||||
let name_hit = names && n.name.to_lowercase().contains(&lower);
|
||||
if !(text_hit || name_hit) || n.is_page() {
|
||||
continue;
|
||||
}
|
||||
hits.push(i);
|
||||
if let Some(r) = &replace {
|
||||
let n = doc.get_mut(i)?;
|
||||
if names && name_hit {
|
||||
n.name = n.name.replace(find.as_str(), r);
|
||||
}
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
// Replace occurrence by occurrence so styles shift correctly.
|
||||
let mut guard = 0;
|
||||
while let Some(pos) = t.characters.find(find.as_str()) {
|
||||
let start = t.characters.get(..pos).map(|x| x.chars().count()).unwrap_or(0);
|
||||
let len = find.chars().count();
|
||||
t.replace_range(start, start + len, r);
|
||||
guard += 1;
|
||||
if guard > 10_000 || r.contains(find.as_str()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if boolean(p, "select").unwrap_or(true) {
|
||||
s.set_selection(hits.clone());
|
||||
}
|
||||
Ok(json!({"matches": hits}))
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("node.set", s, p)?;
|
||||
let props = match get(p, "props") {
|
||||
Some(Value::Object(m)) => m.clone(),
|
||||
_ => {
|
||||
// Allow flat params: {"id": 3, "fill": "#f00"}.
|
||||
let mut m = p.as_object().cloned().unwrap_or_default();
|
||||
m.remove("id");
|
||||
m.remove("ids");
|
||||
m
|
||||
}
|
||||
};
|
||||
if props.is_empty() {
|
||||
return Err(bad("node.set", "no properties given"));
|
||||
}
|
||||
let doc = s.doc_mut()?;
|
||||
for i in ids {
|
||||
super::props::apply("node.set", doc, i, &props)?;
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
fn reparent(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("node.reparent", s, p)?;
|
||||
let parent = req_id("node.reparent", p, "parent")?;
|
||||
let index = num(p, "index").map(|i| i.max(0.0) as usize);
|
||||
let doc = s.doc_mut()?;
|
||||
let ids = z_sorted(doc, &roots(doc, &ids));
|
||||
for (k, i) in ids.iter().enumerate() {
|
||||
doc.reparent(*i, parent, index.map(|x| x + k))?;
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
fn paint_list(s: &mut Session, p: &Value, fills: bool, add: bool) -> Result<Value> {
|
||||
let cmd = if fills { "fill" } else { "stroke" };
|
||||
let ids = req_targets(cmd, s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
for i in ids {
|
||||
let n = doc.get_mut(i)?;
|
||||
let frameish = n.kind.is_frame_like();
|
||||
let list = if fills { &mut n.fills } else { &mut n.strokes };
|
||||
if add {
|
||||
let paint = match get(p, "paint").or_else(|| get(p, "color")) {
|
||||
Some(v) => paint_of(v).ok_or_else(|| bad(cmd, "invalid paint"))?,
|
||||
None if fills && frameish => figcraft_color::Paint::solid(figcraft_color::Color::WHITE),
|
||||
None if fills => figcraft_color::Paint::solid(figcraft_color::Color::DEFAULT_SHAPE),
|
||||
None => figcraft_color::Paint::solid(figcraft_color::Color::BLACK),
|
||||
};
|
||||
list.push(paint);
|
||||
} else {
|
||||
let idx = num(p, "index").map(|x| x as usize).unwrap_or(list.len().saturating_sub(1));
|
||||
if idx < list.len() {
|
||||
list.remove(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
|
||||
let cmd = if fills { "fill.set" } else { "stroke.set" };
|
||||
let ids = req_targets(cmd, s, p)?;
|
||||
let idx = num(p, "index").map(|x| x.max(0.0) as usize);
|
||||
let new_paint = match get(p, "paint") {
|
||||
Some(v) => Some(paint_of(v).ok_or_else(|| bad(cmd, "invalid paint"))?),
|
||||
None => None,
|
||||
};
|
||||
let col = color(p, "color");
|
||||
let doc = s.doc_mut()?;
|
||||
for i in ids {
|
||||
let n = doc.get_mut(i)?;
|
||||
let list = if fills { &mut n.fills } else { &mut n.strokes };
|
||||
let at = idx.unwrap_or(list.len().saturating_sub(1));
|
||||
if list.is_empty() {
|
||||
list.push(figcraft_color::Paint::solid(if fills { figcraft_color::Color::DEFAULT_SHAPE } else { figcraft_color::Color::BLACK }));
|
||||
}
|
||||
let Some(slot) = list.get_mut(at) else { return Err(bad(cmd, format!("no paint at index {at}"))) };
|
||||
if let Some(np) = &new_paint {
|
||||
*slot = np.clone();
|
||||
}
|
||||
if let Some(c) = col {
|
||||
slot.kind = figcraft_color::PaintKind::Solid { color: c.with_alpha(1.0) };
|
||||
if c.a < 1.0 {
|
||||
slot.opacity = c.a;
|
||||
}
|
||||
}
|
||||
if let Some(o) = num(p, "opacity") {
|
||||
slot.opacity = (if o > 1.0 { o / 100.0 } else { o }).clamp(0.0, 1.0) as f32;
|
||||
}
|
||||
if let Some(v) = boolean(p, "visible") {
|
||||
slot.visible = v;
|
||||
}
|
||||
if let Some(b) = blend(p, "blend") {
|
||||
slot.blend = b;
|
||||
}
|
||||
if fills {
|
||||
n.styles.fill = None;
|
||||
} else {
|
||||
n.styles.stroke = None;
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
fn effect_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let e: Effect = match get(p, "effect") {
|
||||
Some(v) => serde_json::from_value(v.clone()).map_err(|e| bad("effect.add", e.to_string()))?,
|
||||
None => match string(p, "type").unwrap_or_else(|| "dropShadow".into()).to_ascii_lowercase().replace(['_', ' ', '-'], "").as_str() {
|
||||
"dropshadow" => Effect::drop_shadow(),
|
||||
"innershadow" => Effect::inner_shadow(),
|
||||
"layerblur" | "blur" => Effect::layer_blur(),
|
||||
"backgroundblur" => Effect::background_blur(),
|
||||
"noise" => Effect { kind: EffectKind::Noise { size: 0.5, density: 0.5, color: figcraft_color::Color::new(0.0, 0.0, 0.0, 0.25) }, visible: true },
|
||||
"texture" => Effect { kind: EffectKind::Texture { size: 0.5, radius: 4.0 }, visible: true },
|
||||
other => return Err(bad("effect.add", format!("unknown effect type `{other}`"))),
|
||||
},
|
||||
};
|
||||
for i in req_targets("effect.add", s, p)? {
|
||||
s.doc_mut()?.get_mut(i)?.effects.push(e.clone());
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
|
@ -0,0 +1,363 @@
|
|||
//! File menu: documents, save/open, import/export, versions.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use base64::Engine as _;
|
||||
use figcraft_doc::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::{DocState, EngineError, Result, Session, bad, spec};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!("file.new", "New design file", "{name?}", false, |s, p| {
|
||||
let name = string(p, "name").unwrap_or_else(|| "Untitled".into());
|
||||
s.docs.push(DocState::new(Document::new(name), None));
|
||||
s.active = s.docs.len() - 1;
|
||||
Ok(json!({"index": s.active}))
|
||||
}),
|
||||
spec!("file.open", "Open", "{path} (.figcraft, .json, .svg, .png, .jpg …)", false, open),
|
||||
spec!("file.save", "Save", "{path?} (programmatic saves need a path the first time)", false, save),
|
||||
spec!("file.saveAs", "Save as", "{path}", false, save_as),
|
||||
spec!("file.export", "Export", "{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp), scale? (default 1), path? (else returns base64 `data`)}", false, export),
|
||||
spec!("file.exportSvg", "Export selection as SVG", "{ids?, path?}", false, |s, p| {
|
||||
let mut q = p.clone();
|
||||
if let Some(o) = q.as_object_mut() {
|
||||
o.insert("format".into(), json!("svg"));
|
||||
}
|
||||
export(s, &q)
|
||||
}),
|
||||
spec!("file.import", "Import", "{path | data (base64) + name, x?, y?} SVG becomes vectors, images become image layers, .figcraft pages are appended", true, import),
|
||||
spec!("file.placeImage", "Place image", "{path | data, x?, y?}", true, super::create::place_image),
|
||||
spec!("file.close", "Close tab", "{index?, force? (discard unsaved changes)}", false, close),
|
||||
spec!("file.rename", "Rename file", "{name}", true, |s, p| {
|
||||
let name = req_string("file.rename", p, "name")?;
|
||||
s.doc_mut()?.name = name;
|
||||
ok()
|
||||
}),
|
||||
spec!("file.duplicate", "Duplicate file", "{}", false, |s, _| {
|
||||
let ds = s.ds()?;
|
||||
let mut d = (*ds.doc).clone();
|
||||
d.name = format!("{} (Copy)", d.name);
|
||||
s.docs.push(DocState::new(d, None));
|
||||
s.active = s.docs.len() - 1;
|
||||
Ok(json!({"index": s.active}))
|
||||
}),
|
||||
spec!("file.revert", "Revert to saved", "{}", false, |s, _| {
|
||||
let path = s.ds()?.path.clone().ok_or_else(|| EngineError::Disabled("file.revert".into(), "the file was never saved".into()))?;
|
||||
let (doc, _) = load(&path)?;
|
||||
let i = s.active;
|
||||
if let Some(slot) = s.docs.get_mut(i) {
|
||||
*slot = DocState::new(doc, Some(path));
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("file.saveVersion", "Save to version history", "{name?, description?}", false, |s, p| {
|
||||
let name = string(p, "name").unwrap_or_else(|| format!("Version {}", s.ds().map(|d| versions(d).len() + 1).unwrap_or(1)));
|
||||
let ds = s.ds_mut()?;
|
||||
let snap = ds.doc.clone();
|
||||
push_version(ds, name, string(p, "description").unwrap_or_default(), snap);
|
||||
ok()
|
||||
}),
|
||||
spec!("file.versionHistory", "Show version history", "{}", false, |s, _| {
|
||||
let ds = s.ds()?;
|
||||
let v: Vec<Value> = versions(ds).iter().enumerate().map(|(i, v)| json!({"index": i, "name": v.0, "description": v.1, "time": v.2})).collect();
|
||||
s.request_ui("panel.versionHistory", json!({}));
|
||||
Ok(Value::Array(v))
|
||||
}),
|
||||
spec!("version.restore", "Restore version", "{index}", true, |s, p| {
|
||||
let i = req_num("version.restore", p, "index")? as usize;
|
||||
let ds = s.ds_mut()?;
|
||||
let Some((_, _, _, doc)) = version_list().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&ds.uid).and_then(|v| v.get(i)).cloned() else {
|
||||
return Err(bad("version.restore", "no such version"));
|
||||
};
|
||||
ds.doc = doc;
|
||||
if !ds.doc.pages.contains(&ds.page) {
|
||||
ds.page = ds.doc.first_page().unwrap_or(ds.page);
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("doc.switch", "Switch document", "{index}", false, |s, p| {
|
||||
let i = req_num("doc.switch", p, "index")? as usize;
|
||||
if i >= s.docs.len() {
|
||||
return Err(bad("doc.switch", "no such document"));
|
||||
}
|
||||
s.active = i;
|
||||
ok()
|
||||
}),
|
||||
spec!("doc.list", "List documents", "{}", false, |s, _| {
|
||||
Ok(Value::Array(s.docs.iter().enumerate().map(|(i, d)| json!({"index": i, "name": d.doc.name, "path": d.path, "dirty": d.is_dirty(), "active": i == s.active})).collect()))
|
||||
}),
|
||||
spec!("doc.home", "Home", "{}", false, |s, _| {
|
||||
s.request_ui("home", json!({}));
|
||||
ok()
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
type Version = (String, String, u64, Arc<Document>);
|
||||
|
||||
fn version_list() -> &'static std::sync::Mutex<std::collections::HashMap<u64, Vec<Version>>> {
|
||||
static V: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<u64, Vec<Version>>>> = std::sync::OnceLock::new();
|
||||
V.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fn push_version(ds: &DocState, name: String, desc: String, doc: Arc<Document>) {
|
||||
version_list().lock().unwrap_or_else(std::sync::PoisonError::into_inner).entry(ds.uid).or_default().push((name, desc, now_secs(), doc));
|
||||
}
|
||||
|
||||
pub fn versions(ds: &DocState) -> Vec<(String, String, u64)> {
|
||||
version_list().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&ds.uid).map(|v| v.iter().map(|(a, b, c, _)| (a.clone(), b.clone(), *c)).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read(path: &str) -> Result<Vec<u8>> {
|
||||
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn read(path: &str) -> Result<Vec<u8>> {
|
||||
Err(EngineError::Other(format!("{path}: no file system in the browser")))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn write(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
let tmp = format!("{path}.tmp-figcraft");
|
||||
std::fs::write(&tmp, bytes).map_err(|e| EngineError::Other(format!("{tmp}: {e}")))?;
|
||||
std::fs::rename(&tmp, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn write(path: &str, _bytes: &[u8]) -> Result<()> {
|
||||
Err(EngineError::Other(format!("{path}: no file system in the browser")))
|
||||
}
|
||||
|
||||
fn ext(path: &str) -> String {
|
||||
std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn file_stem(path: &str) -> String {
|
||||
std::path::Path::new(path).file_stem().and_then(|e| e.to_str()).unwrap_or("Untitled").to_string()
|
||||
}
|
||||
|
||||
/// Load a document from any supported file.
|
||||
pub fn load(path: &str) -> Result<(Document, Vec<String>)> {
|
||||
let bytes = read(path)?;
|
||||
load_bytes(&bytes, path)
|
||||
}
|
||||
|
||||
pub fn load_bytes(bytes: &[u8], name_hint: &str) -> Result<(Document, Vec<String>)> {
|
||||
match ext(name_hint).as_str() {
|
||||
"svg" => {
|
||||
let mut doc = Document::new(file_stem(name_hint));
|
||||
let page = doc.first_page().ok_or(EngineError::NoDocument)?;
|
||||
figcraft_svg::import_svg(&mut doc, bytes, page, 0.0, 0.0, &file_stem(name_hint)).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
Ok((doc, vec![]))
|
||||
}
|
||||
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" => {
|
||||
let mut doc = Document::new(file_stem(name_hint));
|
||||
let page = doc.first_page().ok_or(EngineError::NoDocument)?;
|
||||
let (hash, w, h) = figcraft_format::add_image(&mut doc, bytes.to_vec()).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let id = doc.create(page, NodeKind::Rectangle, &file_stem(name_hint), 0.0, 0.0, f64::from(w), f64::from(h))?;
|
||||
doc.get_mut(id)?.fills = vec![figcraft_color::Paint::image(hash, figcraft_color::ImageScaleMode::Fill)];
|
||||
Ok((doc, vec![]))
|
||||
}
|
||||
_ => {
|
||||
let (mut doc, issues) = figcraft_format::load_bytes(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
if doc.name.is_empty() {
|
||||
doc.name = file_stem(name_hint);
|
||||
}
|
||||
Ok((doc, issues))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = req_string("file.open", p, "path")?;
|
||||
// Already open: switch to it.
|
||||
if let Some(i) = s.docs.iter().position(|d| d.path.as_deref() == Some(path.as_str())) {
|
||||
s.active = i;
|
||||
return Ok(json!({"index": i}));
|
||||
}
|
||||
let (doc, issues) = load(&path)?;
|
||||
let native = matches!(ext(&path).as_str(), "figcraft" | "json");
|
||||
s.docs.push(DocState::new(doc, native.then_some(path)));
|
||||
s.active = s.docs.len() - 1;
|
||||
Ok(json!({"index": s.active, "repairs": issues}))
|
||||
}
|
||||
|
||||
/// Thumbnail PNG of the first page's content (for file browsers / the home screen).
|
||||
pub fn thumbnail(doc: &Document) -> Option<Vec<u8>> {
|
||||
let page = doc.first_page()?;
|
||||
let b = doc.page_content_bounds(page)?;
|
||||
let scale = (400.0 / b.width().max(b.height()).max(1.0)).min(2.0);
|
||||
let view = figcraft_render::View { origin: (b.x0, b.y0), scale, width: (b.width() * scale).ceil().max(1.0) as u32, height: (b.height() * scale).ceil().max(1.0) as u32 };
|
||||
let pix = figcraft_render::render_view(doc, page, &view, &Default::default())?;
|
||||
figcraft_render::encode_png(&pix).ok()
|
||||
}
|
||||
|
||||
fn save_to(s: &mut Session, path: String) -> Result<Value> {
|
||||
let ds = s.ds_mut()?;
|
||||
let mut doc = (*ds.doc).clone();
|
||||
doc.current_page = Some(ds.page);
|
||||
let thumb = thumbnail(&doc);
|
||||
let bytes = figcraft_format::save_bytes(&doc, thumb.as_deref()).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
write(&path, &bytes)?;
|
||||
ds.path = Some(path.clone());
|
||||
ds.mark_saved();
|
||||
ds.revision += 1;
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}
|
||||
|
||||
fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(path) = string(p, "path") {
|
||||
return save_to(s, path);
|
||||
}
|
||||
match s.ds()?.path.clone() {
|
||||
Some(path) => save_to(s, path),
|
||||
None => {
|
||||
s.request_ui("dialog.saveAs", json!({}));
|
||||
Err(EngineError::Disabled("file.save".into(), "the file has no path yet: pass `path`".into()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn save_as(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match string(p, "path") {
|
||||
Some(mut path) => {
|
||||
if ext(&path).is_empty() {
|
||||
path.push_str(".figcraft");
|
||||
}
|
||||
save_to(s, path)
|
||||
}
|
||||
None => {
|
||||
s.request_ui("dialog.saveAs", json!({}));
|
||||
Err(EngineError::Disabled("file.saveAs".into(), "pass `path`".into()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode `ids` (or the page) in `format` at `scale`.
|
||||
pub fn export_bytes(doc: &Document, ids: &[NodeId], format: ExportFormat, scale: f64) -> Result<Vec<u8>> {
|
||||
if ids.is_empty() {
|
||||
return Err(EngineError::Disabled("file.export".into(), "nothing to export".into()));
|
||||
}
|
||||
match format {
|
||||
ExportFormat::Svg => figcraft_svg::export_svg(doc, ids, &Default::default()).map(String::into_bytes).ok_or_else(|| EngineError::Other("nothing to export".into())),
|
||||
ExportFormat::Pdf => Err(EngineError::Disabled("file.export".into(), "PDF export is not available yet".into())),
|
||||
_ => {
|
||||
let pix = if ids.len() == 1 {
|
||||
ids.first().and_then(|i| figcraft_render::render_node(doc, *i, scale))
|
||||
} else {
|
||||
let b = ids.iter().filter_map(|i| figcraft_render::export_bounds(doc, *i)).reduce(|a, b| a.union(b));
|
||||
b.and_then(|b| {
|
||||
let view = figcraft_render::View { origin: (b.x0, b.y0), scale, width: (b.width() * scale).ceil().max(1.0) as u32, height: (b.height() * scale).ceil().max(1.0) as u32 };
|
||||
let mut pix = figcraft_render::Pixmap::new(view.width.clamp(1, 16384), view.height.clamp(1, 16384))?;
|
||||
let ordered = super::edit::z_sorted(doc, ids);
|
||||
figcraft_render::render_nodes_into(doc, &ordered, &view, &mut pix);
|
||||
Some(pix)
|
||||
})
|
||||
};
|
||||
let pix = pix.ok_or_else(|| EngineError::Other("render failed (empty or too large)".into()))?;
|
||||
match format {
|
||||
ExportFormat::Jpg => figcraft_render::encode_jpeg(&pix, figcraft_color::Color::WHITE, 90),
|
||||
ExportFormat::Webp => figcraft_render::encode_webp(&pix),
|
||||
_ => figcraft_render::encode_png(&pix),
|
||||
}
|
||||
.map_err(EngineError::Other)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let doc = s.doc()?;
|
||||
let mut ids = targets(s, p);
|
||||
if ids.is_empty() {
|
||||
ids = doc.get(s.page()?)?.children.clone();
|
||||
}
|
||||
let format = string(p, "format").and_then(|f| ExportFormat::from_ext(&f)).or_else(|| string(p, "path").and_then(|pp| ExportFormat::from_ext(&ext(&pp)))).unwrap_or_default();
|
||||
let scale = num_or(p, "scale", 1.0).clamp(0.01, 64.0);
|
||||
let bytes = export_bytes(doc, &ids, format, scale)?;
|
||||
match string(p, "path") {
|
||||
Some(path) => {
|
||||
write(&path, &bytes)?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len(), "format": format.ext()}))
|
||||
}
|
||||
None => Ok(json!({"format": format.ext(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})),
|
||||
}
|
||||
}
|
||||
|
||||
fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (bytes, name) = if let Some(path) = string(p, "path") {
|
||||
(read(&path)?, path)
|
||||
} else if let Some(d) = string(p, "data") {
|
||||
let d = d.split_once(',').map(|(_, b)| b.to_string()).unwrap_or(d);
|
||||
(base64::engine::general_purpose::STANDARD.decode(d.trim()).map_err(|e| bad("file.import", e.to_string()))?, string(p, "name").unwrap_or_else(|| "import.png".into()))
|
||||
} else {
|
||||
return Err(bad("file.import", "pass `path` or `data`"));
|
||||
};
|
||||
let page = s.page()?;
|
||||
let (x, y) = (num(p, "x"), num(p, "y"));
|
||||
let center = s.ds().map(|d| (d.view.cx, d.view.cy)).unwrap_or((0.0, 0.0));
|
||||
match ext(&name).as_str() {
|
||||
"svg" => {
|
||||
let doc = s.doc_mut()?;
|
||||
let id = figcraft_svg::import_svg(doc, &bytes, page, x.unwrap_or(center.0), y.unwrap_or(center.1), &file_stem(&name)).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
s.set_selection(vec![id]);
|
||||
Ok(json!({"id": id}))
|
||||
}
|
||||
"figcraft" | "json" => {
|
||||
let (other, _) = figcraft_format::load_bytes(&bytes).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let doc = s.doc_mut()?;
|
||||
for (k, v) in &other.images {
|
||||
doc.images.entry(k.clone()).or_insert_with(|| v.clone());
|
||||
}
|
||||
let mut new_pages = Vec::new();
|
||||
for pg in &other.pages {
|
||||
let Some(pn) = other.node(*pg) else { continue };
|
||||
let np = doc.add_page(&pn.name);
|
||||
for c in pn.children.clone() {
|
||||
let nodes: Vec<Node> = other.subtree(c).into_iter().filter_map(|i| other.node(i).cloned()).collect();
|
||||
let mut map = std::collections::BTreeMap::new();
|
||||
for n in &nodes {
|
||||
map.insert(n.id, doc.alloc_id());
|
||||
}
|
||||
doc.paste_nodes(&nodes, &map, c, np, None)?;
|
||||
}
|
||||
new_pages.push(np);
|
||||
}
|
||||
Ok(json!({"pages": new_pages}))
|
||||
}
|
||||
_ => {
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
super::create::place_image(s, &json!({"data": b64, "x": x.unwrap_or(center.0), "y": y.unwrap_or(center.1), "name": file_stem(&name)}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = num(p, "index").map(|x| x as usize).unwrap_or(s.active);
|
||||
let Some(ds) = s.docs.get(i) else { return Err(bad("file.close", "no such document")) };
|
||||
if ds.is_dirty() && !boolean(p, "force").unwrap_or(false) {
|
||||
s.request_ui("dialog.unsaved", json!({"index": i}));
|
||||
return Err(EngineError::Disabled("file.close".into(), "the document has unsaved changes (pass force: true to discard)".into()));
|
||||
}
|
||||
s.docs.remove(i);
|
||||
if s.active >= s.docs.len() {
|
||||
s.active = s.docs.len().saturating_sub(1);
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
//! The command registry. Each area module exposes `specs()`; [`all_commands`] collects them once.
|
||||
|
||||
pub mod arrange;
|
||||
pub mod component;
|
||||
pub mod create;
|
||||
pub mod edit;
|
||||
pub mod file;
|
||||
pub mod page;
|
||||
pub mod props;
|
||||
pub mod proto;
|
||||
pub mod structure;
|
||||
pub mod styles;
|
||||
pub mod text;
|
||||
pub mod view;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub type RunFn = fn(&mut Session, &Value) -> Result<Value>;
|
||||
|
||||
/// A registered command.
|
||||
pub struct CommandSpec {
|
||||
pub id: &'static str,
|
||||
pub label: &'static str,
|
||||
/// Parameter documentation for agents (`list_commands`).
|
||||
pub params: &'static str,
|
||||
/// Whether the command edits the document (an undo step).
|
||||
pub history: bool,
|
||||
pub run: RunFn,
|
||||
}
|
||||
|
||||
impl CommandSpec {
|
||||
pub fn menu(&self) -> Option<&'static str> {
|
||||
crate::catalog::MENU.iter().find(|m| m.id == self.id).map(|m| m.menu)
|
||||
}
|
||||
pub fn shortcut(&self) -> Option<&'static str> {
|
||||
crate::catalog::shortcut(self.id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Define a command spec: `spec!(id, label, params_doc, history, run)`.
|
||||
#[macro_export]
|
||||
macro_rules! spec {
|
||||
($id:expr, $label:expr, $params:expr, $hist:expr, $run:expr) => {
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, params: $params, history: $hist, run: $run }
|
||||
};
|
||||
}
|
||||
|
||||
struct Registry {
|
||||
list: Vec<CommandSpec>,
|
||||
index: HashMap<&'static str, usize>,
|
||||
}
|
||||
|
||||
fn registry() -> &'static Registry {
|
||||
static R: OnceLock<Registry> = OnceLock::new();
|
||||
R.get_or_init(|| {
|
||||
let mut list = Vec::new();
|
||||
list.extend(create::specs());
|
||||
list.extend(edit::specs());
|
||||
list.extend(arrange::specs());
|
||||
list.extend(structure::specs());
|
||||
list.extend(file::specs());
|
||||
list.extend(page::specs());
|
||||
list.extend(view::specs());
|
||||
list.extend(text::specs());
|
||||
list.extend(styles::specs());
|
||||
list.extend(component::specs());
|
||||
list.extend(proto::specs());
|
||||
let mut index = HashMap::new();
|
||||
for (i, s) in list.iter().enumerate() {
|
||||
if index.insert(s.id, i).is_some() {
|
||||
log::warn!("duplicate command id {}", s.id);
|
||||
}
|
||||
}
|
||||
Registry { list, index }
|
||||
})
|
||||
}
|
||||
|
||||
pub fn all_commands() -> &'static [CommandSpec] {
|
||||
®istry().list
|
||||
}
|
||||
|
||||
pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
|
||||
let r = registry();
|
||||
r.index.get(id).and_then(|i| r.list.get(*i))
|
||||
}
|
||||
|
||||
/// `{"ok": true}` shorthand.
|
||||
pub fn ok() -> Result<Value> {
|
||||
Ok(serde_json::json!({"ok": true}))
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
//! Pages.
|
||||
|
||||
use figcraft_doc::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::{bad, spec};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!("page.add", "Add page", "{name?}", true, |s, p| {
|
||||
let doc = s.doc_mut()?;
|
||||
let name = string(p, "name").unwrap_or_else(|| format!("Page {}", doc.pages.len() + 1));
|
||||
let id = doc.add_page(&name);
|
||||
let ds = s.ds_mut()?;
|
||||
ds.page = id;
|
||||
ds.selection.clear();
|
||||
ds.zoom_to_fit();
|
||||
Ok(json!({"id": id}))
|
||||
}),
|
||||
spec!("page.select", "Go to page", "{id | index}", false, |s, p| {
|
||||
let doc = s.doc()?;
|
||||
let id = match id(p, "id") {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
let k = req_num("page.select", p, "index")? as usize;
|
||||
*doc.pages.get(k).ok_or_else(|| bad("page.select", "no such page"))?
|
||||
}
|
||||
};
|
||||
if !doc.pages.contains(&id) {
|
||||
return Err(bad("page.select", format!("{id} is not a page")));
|
||||
}
|
||||
let ds = s.ds_mut()?;
|
||||
ds.page = id;
|
||||
ds.selection.clear();
|
||||
ds.text_edit = None;
|
||||
ds.zoom_to_fit();
|
||||
ok()
|
||||
}),
|
||||
spec!("page.rename", "Rename page", "{id?, name}", true, |s, p| {
|
||||
let name = req_string("page.rename", p, "name")?;
|
||||
let id = id(p, "id").unwrap_or(s.page()?);
|
||||
s.doc_mut()?.get_mut(id)?.name = name;
|
||||
ok()
|
||||
}),
|
||||
spec!("page.delete", "Delete page", "{id?}", true, |s, p| {
|
||||
let id = id(p, "id").unwrap_or(s.page()?);
|
||||
let doc = s.doc_mut()?;
|
||||
doc.remove(id)?;
|
||||
let first = doc.first_page();
|
||||
let ds = s.ds_mut()?;
|
||||
if ds.page == id
|
||||
&& let Some(f) = first
|
||||
{
|
||||
ds.page = f;
|
||||
ds.selection.clear();
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("page.duplicate", "Duplicate page", "{id?}", true, |s, p| {
|
||||
let src = id(p, "id").unwrap_or(s.page()?);
|
||||
let doc = s.doc_mut()?;
|
||||
let name = format!("{} (Copy)", doc.get(src)?.name);
|
||||
let np = doc.add_page(&name);
|
||||
if let NodeKind::Page { background, .. } = doc.get(src)?.kind.clone()
|
||||
&& let NodeKind::Page { background: b, .. } = &mut doc.get_mut(np)?.kind
|
||||
{
|
||||
*b = background;
|
||||
}
|
||||
for c in doc.get(src)?.children.clone() {
|
||||
doc.duplicate_into(c, np, None)?;
|
||||
}
|
||||
// Keep the new page right after the source.
|
||||
if let Some(si) = doc.pages.iter().position(|x| *x == src) {
|
||||
doc.pages.retain(|x| *x != np);
|
||||
doc.pages.insert(si + 1, np);
|
||||
}
|
||||
s.ds_mut()?.page = np;
|
||||
Ok(json!({"id": np}))
|
||||
}),
|
||||
spec!("page.move", "Move page", "{id?, index}", true, |s, p| {
|
||||
let id = id(p, "id").unwrap_or(s.page()?);
|
||||
let k = req_num("page.move", p, "index")?.max(0.0) as usize;
|
||||
let doc = s.doc_mut()?;
|
||||
doc.pages.retain(|x| *x != id);
|
||||
let k = k.min(doc.pages.len());
|
||||
doc.pages.insert(k, id);
|
||||
ok()
|
||||
}),
|
||||
spec!("page.setBackground", "Page background", "{id?, color}", true, |s, p| {
|
||||
let c = color(p, "color").ok_or_else(|| bad("page.setBackground", "`color` required"))?;
|
||||
let id = id(p, "id").unwrap_or(s.page()?);
|
||||
if let NodeKind::Page { background, .. } = &mut s.doc_mut()?.get_mut(id)?.kind {
|
||||
*background = c;
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("page.list", "List pages", "{}", false, |s, _| {
|
||||
let doc = s.doc()?;
|
||||
let cur = s.page()?;
|
||||
Ok(Value::Array(doc.pages.iter().filter_map(|p| doc.node(*p)).map(|n| json!({"id": n.id, "name": n.name, "current": n.id == cur, "children": n.children.len()})).collect()))
|
||||
}),
|
||||
spec!("guide.add", "Add guide", "{vertical (bool), offset, page?}", true, |s, p| {
|
||||
let id = id(p, "page").unwrap_or(s.page()?);
|
||||
let g = Guide { vertical: boolean(p, "vertical").unwrap_or(true), offset: req_num("guide.add", p, "offset")? };
|
||||
if let NodeKind::Page { guides, .. } = &mut s.doc_mut()?.get_mut(id)?.kind {
|
||||
guides.push(g);
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("guide.remove", "Remove guide", "{index, page?}", true, |s, p| {
|
||||
let id = id(p, "page").unwrap_or(s.page()?);
|
||||
let k = req_num("guide.remove", p, "index")? as usize;
|
||||
if let NodeKind::Page { guides, .. } = &mut s.doc_mut()?.get_mut(id)?.kind
|
||||
&& k < guides.len()
|
||||
{
|
||||
guides.remove(k);
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,598 @@
|
|||
//! Property editing: `node.set` and the shorthand keys every surface uses.
|
||||
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::*;
|
||||
use figcraft_geom::{Affine, Point};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::params::*;
|
||||
use crate::{Result, bad};
|
||||
|
||||
/// Keys `node.set` understands (documented for agents).
|
||||
pub const KEYS: &str = "name, visible, locked, opacity (0-1), blend, x, y (parent-relative), width, height, rotation (degrees), \
|
||||
fills (paints or colours), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], \
|
||||
strokeCap / startCap / endCap, strokeJoin, dashes [..], cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \
|
||||
clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \
|
||||
mainAlign (min|center|max|spaceBetween), crossAlign (min|center|max|baseline), wrap, gridColumns, gridRows, sizingH / sizingV (fixed|hug|fill), \
|
||||
absolute, minWidth, maxWidth, minHeight, maxHeight, constraints {horizontal, vertical}, grids, exports, characters, fontFamily, fontStyle, \
|
||||
fontSize, lineHeight (auto|px|\"150%\"), letterSpacing (px|\"2%\"), textAlign (left|center|right|justified), textAlignVertical, textAutoResize \
|
||||
(widthAndHeight|height|none|truncate), textDecoration, textCase, paragraphSpacing, listStyle, pointCount, innerRadius, arcStart, arcSweep, \
|
||||
arcInner, constrainProportions, description, interactions, overflow, pluginData {k: v}";
|
||||
|
||||
fn enum_of<T: serde::de::DeserializeOwned>(v: &Value) -> Option<T> {
|
||||
let s = v.as_str()?;
|
||||
// Accept camelCase, snake/upper-case and spaced variants.
|
||||
let norm: String = {
|
||||
let mut out = String::new();
|
||||
let mut up = false;
|
||||
for (i, ch) in s.trim().chars().enumerate() {
|
||||
if ch == '_' || ch == '-' || ch == ' ' {
|
||||
up = true;
|
||||
continue;
|
||||
}
|
||||
if i == 0 {
|
||||
out.extend(ch.to_lowercase());
|
||||
} else if up {
|
||||
out.extend(ch.to_uppercase());
|
||||
up = false;
|
||||
} else if s.chars().all(|c| !c.is_lowercase() || !c.is_alphabetic()) {
|
||||
out.extend(ch.to_lowercase());
|
||||
} else {
|
||||
out.push(ch);
|
||||
}
|
||||
}
|
||||
out
|
||||
};
|
||||
serde_json::from_value(Value::String(norm)).ok()
|
||||
}
|
||||
|
||||
fn sizing(v: &Value) -> Option<Sizing> {
|
||||
match v.as_str()?.to_ascii_lowercase().as_str() {
|
||||
"fixed" => Some(Sizing::Fixed),
|
||||
"hug" | "auto" | "hug contents" => Some(Sizing::Hug),
|
||||
"fill" | "fill container" | "stretch" => Some(Sizing::Fill),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn line_height(v: &Value) -> Option<LineHeight> {
|
||||
match v {
|
||||
Value::String(s) if s.eq_ignore_ascii_case("auto") => Some(LineHeight::Auto),
|
||||
Value::String(s) if s.trim().ends_with('%') => s.trim().trim_end_matches('%').trim().parse().ok().map(LineHeight::Percent),
|
||||
Value::Object(_) => serde_json::from_value(v.clone()).ok(),
|
||||
other => f64_of(other).map(LineHeight::Pixels),
|
||||
}
|
||||
}
|
||||
|
||||
fn letter_spacing(v: &Value) -> Option<LetterSpacing> {
|
||||
match v {
|
||||
Value::String(s) if s.trim().ends_with('%') => s.trim().trim_end_matches('%').trim().parse().ok().map(LetterSpacing::Percent),
|
||||
Value::Object(_) => serde_json::from_value(v.clone()).ok(),
|
||||
other => f64_of(other).map(LetterSpacing::Pixels),
|
||||
}
|
||||
}
|
||||
|
||||
fn four(v: &Value) -> Option<[f64; 4]> {
|
||||
match v {
|
||||
Value::Array(a) => {
|
||||
let f: Vec<f64> = a.iter().filter_map(f64_of).collect();
|
||||
match f.as_slice() {
|
||||
[x] => Some([*x; 4]),
|
||||
[a, b] => Some([*a, *b, *a, *b]),
|
||||
[a, b, c, d] => Some([*a, *b, *c, *d]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
other => f64_of(other).map(|x| [x; 4]),
|
||||
}
|
||||
}
|
||||
|
||||
fn text_mut<'a>(cmd: &str, n: &'a mut Node) -> Result<&'a mut TextData> {
|
||||
let id = n.id;
|
||||
n.kind.text_mut().ok_or_else(|| bad(cmd, format!("node {id} is not text")))
|
||||
}
|
||||
|
||||
fn ensure_layout(n: &mut Node) -> &mut AutoLayout {
|
||||
n.layout.get_or_insert_with(|| AutoLayout { padding: [0.0; 4], gap: 0.0, ..Default::default() })
|
||||
}
|
||||
|
||||
/// Apply a property map to one node. `x`/`y` are relative to the parent.
|
||||
pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Value>) -> Result<()> {
|
||||
let mut unknown = Vec::new();
|
||||
// Geometry keys first so rotation applies to the final size.
|
||||
let order = |k: &str| match k {
|
||||
"width" | "height" | "w" | "h" => 0,
|
||||
"rotation" => 2,
|
||||
"x" | "y" => 3,
|
||||
_ => 1,
|
||||
};
|
||||
let mut keys: Vec<(&String, &Value)> = props.iter().collect();
|
||||
keys.sort_by_key(|(k, _)| order(k));
|
||||
for (k, v) in keys {
|
||||
let k = k.as_str();
|
||||
match k {
|
||||
"x" | "y" => {
|
||||
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?;
|
||||
let n = doc.get_mut(id)?;
|
||||
let b = figcraft_geom::transform_rect_bbox(n.transform, n.local_rect());
|
||||
let c = n.transform.as_coeffs();
|
||||
// Figma's X/Y are the node's own origin (its rotated top-left) for rotated nodes.
|
||||
let (mut tx, mut ty) = (c[4], c[5]);
|
||||
let _ = b;
|
||||
if k == "x" {
|
||||
tx = val;
|
||||
} else {
|
||||
ty = val;
|
||||
}
|
||||
n.transform = Affine::new([c[0], c[1], c[2], c[3], tx, ty]);
|
||||
}
|
||||
"width" | "height" | "w" | "h" => {
|
||||
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0);
|
||||
let (ow, oh, keep) = {
|
||||
let n = doc.get(id)?;
|
||||
(n.width, n.height, n.constrain_proportions && !props.contains_key("width") || !props.contains_key("height"))
|
||||
};
|
||||
let _ = keep;
|
||||
let (nw, nh) = if k.starts_with('w') { (val, oh) } else { (ow, val) };
|
||||
resize_node(doc, id, nw, nh)?;
|
||||
// Explicit size makes text fixed in that dimension.
|
||||
if let Some(t) = doc.get_mut(id)?.kind.text_mut() {
|
||||
t.auto_resize = if k.starts_with('w') {
|
||||
if t.auto_resize == TextAutoResize::WidthAndHeight { TextAutoResize::Height } else { t.auto_resize }
|
||||
} else {
|
||||
TextAutoResize::None
|
||||
};
|
||||
}
|
||||
// Explicit width/height makes hugging/filling fixed (like typing into W/H).
|
||||
let n = doc.get_mut(id)?;
|
||||
if k.starts_with('w') {
|
||||
n.child_layout.sizing_h = Sizing::Fixed;
|
||||
} else {
|
||||
n.child_layout.sizing_v = Sizing::Fixed;
|
||||
}
|
||||
}
|
||||
"rotation" => {
|
||||
let deg = f64_of(v).ok_or_else(|| bad(cmd, "`rotation` must be degrees"))?;
|
||||
let n = doc.get_mut(id)?;
|
||||
n.transform = figcraft_geom::set_rotation_about_center(n.transform, n.width, n.height, deg);
|
||||
}
|
||||
"name" => doc.get_mut(id)?.name = string_val(v).ok_or_else(|| bad(cmd, "`name` must be a string"))?,
|
||||
"visible" => doc.get_mut(id)?.visible = v.as_bool().ok_or_else(|| bad(cmd, "`visible` must be a boolean"))?,
|
||||
"locked" => doc.get_mut(id)?.locked = v.as_bool().ok_or_else(|| bad(cmd, "`locked` must be a boolean"))?,
|
||||
"opacity" => {
|
||||
let o = f64_of(v).ok_or_else(|| bad(cmd, "`opacity` must be a number"))?;
|
||||
let o = if o > 1.0 { o / 100.0 } else { o };
|
||||
doc.get_mut(id)?.opacity = o.clamp(0.0, 1.0) as f32;
|
||||
}
|
||||
"blend" | "blendMode" => {
|
||||
let b = v.as_str().and_then(figcraft_color::BlendMode::from_name).ok_or_else(|| bad(cmd, "unknown blend mode"))?;
|
||||
doc.get_mut(id)?.blend = b;
|
||||
}
|
||||
"fills" => doc.get_mut(id)?.fills = paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?,
|
||||
"fill" => {
|
||||
let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour"))?;
|
||||
doc.get_mut(id)?.fills = vec![Paint::solid(c)];
|
||||
}
|
||||
"strokes" => doc.get_mut(id)?.strokes = paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?,
|
||||
"stroke" => {
|
||||
let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour"))?;
|
||||
doc.get_mut(id)?.strokes = vec![Paint::solid(c)];
|
||||
}
|
||||
"strokeWeight" => doc.get_mut(id)?.stroke.weight = f64_of(v).ok_or_else(|| bad(cmd, "`strokeWeight` must be a number"))?.max(0.0),
|
||||
"strokeAlign" => doc.get_mut(id)?.stroke.align = enum_of(v).ok_or_else(|| bad(cmd, "strokeAlign: inside|center|outside"))?,
|
||||
"strokeSides" => doc.get_mut(id)?.stroke.sides = if v.is_null() { None } else { Some(four(v).ok_or_else(|| bad(cmd, "strokeSides: [t,r,b,l]"))?) },
|
||||
"strokeCap" => {
|
||||
let c: StrokeCap = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?;
|
||||
let n = doc.get_mut(id)?;
|
||||
n.stroke.cap_start = c;
|
||||
n.stroke.cap_end = c;
|
||||
}
|
||||
"startCap" => doc.get_mut(id)?.stroke.cap_start = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?,
|
||||
"endCap" => doc.get_mut(id)?.stroke.cap_end = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?,
|
||||
"strokeJoin" => doc.get_mut(id)?.stroke.join = enum_of(v).ok_or_else(|| bad(cmd, "strokeJoin: miter|bevel|round"))?,
|
||||
"miterLimit" => doc.get_mut(id)?.stroke.miter_limit = f64_of(v).unwrap_or(4.0).clamp(1.0, 100.0),
|
||||
"dashes" => {
|
||||
let d: Vec<f64> = v.as_array().map(|a| a.iter().filter_map(f64_of).map(|x| x.max(0.0)).collect()).unwrap_or_default();
|
||||
doc.get_mut(id)?.stroke.dashes = d;
|
||||
}
|
||||
"cornerRadius" => {
|
||||
let r = four(v).ok_or_else(|| bad(cmd, "cornerRadius: n or [tl,tr,br,bl]"))?;
|
||||
doc.get_mut(id)?.corners.radii = r.map(|x| x.max(0.0));
|
||||
}
|
||||
"topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => {
|
||||
let i = ["topLeftRadius", "topRightRadius", "bottomRightRadius", "bottomLeftRadius"].iter().position(|x| *x == k).unwrap_or(0);
|
||||
let r = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0);
|
||||
if let Some(slot) = doc.get_mut(id)?.corners.radii.get_mut(i) {
|
||||
*slot = r;
|
||||
}
|
||||
}
|
||||
"cornerSmoothing" => doc.get_mut(id)?.corners.smoothing = f64_of(v).unwrap_or(0.0).clamp(0.0, 1.0),
|
||||
"effects" => doc.get_mut(id)?.effects = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("effects: {e}")))?,
|
||||
"clip" | "clipsContent" => doc.get_mut(id)?.clip = v.as_bool().ok_or_else(|| bad(cmd, "`clip` must be a boolean"))?,
|
||||
"isMask" => doc.get_mut(id)?.is_mask = v.as_bool().unwrap_or(false),
|
||||
"layout" | "layoutMode" => {
|
||||
let n = doc.get_mut(id)?;
|
||||
if !n.kind.is_frame_like() {
|
||||
return Err(bad(cmd, "auto layout needs a frame"));
|
||||
}
|
||||
match v {
|
||||
Value::Null => n.layout = None,
|
||||
Value::String(s) if s.eq_ignore_ascii_case("none") => n.layout = None,
|
||||
Value::String(s) => {
|
||||
let mode = match s.to_ascii_lowercase().as_str() {
|
||||
"horizontal" | "row" => LayoutMode::Horizontal,
|
||||
"vertical" | "column" => LayoutMode::Vertical,
|
||||
"grid" => LayoutMode::Grid,
|
||||
_ => return Err(bad(cmd, "layout: none|horizontal|vertical|grid")),
|
||||
};
|
||||
ensure_layout(n).mode = mode;
|
||||
}
|
||||
Value::Object(_) => n.layout = Some(serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("layout: {e}")))?),
|
||||
_ => return Err(bad(cmd, "layout: none|horizontal|vertical|grid|{...}")),
|
||||
}
|
||||
}
|
||||
"gap" | "itemSpacing" => ensure_layout(doc.get_mut(id)?).gap = f64_of(v).ok_or_else(|| bad(cmd, "`gap` must be a number"))?,
|
||||
"crossGap" | "counterAxisSpacing" => ensure_layout(doc.get_mut(id)?).cross_gap = f64_of(v).ok_or_else(|| bad(cmd, "`crossGap` must be a number"))?,
|
||||
"padding" => {
|
||||
let p = four(v).ok_or_else(|| bad(cmd, "padding: n or [t,r,b,l]"))?;
|
||||
ensure_layout(doc.get_mut(id)?).padding = p.map(|x| x.max(0.0));
|
||||
}
|
||||
"paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" => {
|
||||
let i = ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"].iter().position(|x| *x == k).unwrap_or(0);
|
||||
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0);
|
||||
if let Some(slot) = ensure_layout(doc.get_mut(id)?).padding.get_mut(i) {
|
||||
*slot = val;
|
||||
}
|
||||
}
|
||||
"paddingHorizontal" => {
|
||||
let val = f64_of(v).unwrap_or(0.0).max(0.0);
|
||||
let l = ensure_layout(doc.get_mut(id)?);
|
||||
l.padding[1] = val;
|
||||
l.padding[3] = val;
|
||||
}
|
||||
"paddingVertical" => {
|
||||
let val = f64_of(v).unwrap_or(0.0).max(0.0);
|
||||
let l = ensure_layout(doc.get_mut(id)?);
|
||||
l.padding[0] = val;
|
||||
l.padding[2] = val;
|
||||
}
|
||||
"mainAlign" | "primaryAxisAlignItems" => ensure_layout(doc.get_mut(id)?).main_align = enum_of(v).ok_or_else(|| bad(cmd, "mainAlign: min|center|max|spaceBetween"))?,
|
||||
"crossAlign" | "counterAxisAlignItems" => ensure_layout(doc.get_mut(id)?).cross_align = enum_of(v).ok_or_else(|| bad(cmd, "crossAlign: min|center|max|baseline"))?,
|
||||
"wrap" => ensure_layout(doc.get_mut(id)?).wrap = v.as_bool().unwrap_or(false),
|
||||
"gridColumns" => ensure_layout(doc.get_mut(id)?).grid_columns = f64_of(v).unwrap_or(2.0).clamp(1.0, 1000.0) as u32,
|
||||
"gridRows" => ensure_layout(doc.get_mut(id)?).grid_rows = f64_of(v).unwrap_or(2.0).clamp(1.0, 1000.0) as u32,
|
||||
"strokesIncluded" => ensure_layout(doc.get_mut(id)?).strokes_included = v.as_bool().unwrap_or(false),
|
||||
"reverseZ" => ensure_layout(doc.get_mut(id)?).reverse_z = v.as_bool().unwrap_or(false),
|
||||
"sizingH" | "layoutSizingHorizontal" => {
|
||||
let s = sizing(v).ok_or_else(|| bad(cmd, "sizing: fixed|hug|fill"))?;
|
||||
set_sizing(cmd, doc, id, s, true)?;
|
||||
}
|
||||
"sizingV" | "layoutSizingVertical" => {
|
||||
let s = sizing(v).ok_or_else(|| bad(cmd, "sizing: fixed|hug|fill"))?;
|
||||
set_sizing(cmd, doc, id, s, false)?;
|
||||
}
|
||||
"absolute" => doc.get_mut(id)?.child_layout.absolute = v.as_bool().unwrap_or(false),
|
||||
"minWidth" => doc.get_mut(id)?.child_layout.min_w = f64_of(v),
|
||||
"maxWidth" => doc.get_mut(id)?.child_layout.max_w = f64_of(v),
|
||||
"minHeight" => doc.get_mut(id)?.child_layout.min_h = f64_of(v),
|
||||
"maxHeight" => doc.get_mut(id)?.child_layout.max_h = f64_of(v),
|
||||
"constraints" => {
|
||||
let n = doc.get_mut(id)?;
|
||||
if let Some(h) = v.get("horizontal") {
|
||||
n.constraints.horizontal = enum_of(h).ok_or_else(|| bad(cmd, "horizontal: left|right|leftRight|center|scale"))?;
|
||||
}
|
||||
if let Some(vv) = v.get("vertical") {
|
||||
n.constraints.vertical = enum_of(vv).ok_or_else(|| bad(cmd, "vertical: top|bottom|topBottom|center|scale"))?;
|
||||
}
|
||||
}
|
||||
"grids" | "layoutGrids" => doc.get_mut(id)?.grids = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("grids: {e}")))?,
|
||||
"exports" | "exportSettings" => doc.get_mut(id)?.exports = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("exports: {e}")))?,
|
||||
"interactions" => doc.get_mut(id)?.interactions = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("interactions: {e}")))?,
|
||||
"overflow" => doc.get_mut(id)?.overflow = enum_of(v).ok_or_else(|| bad(cmd, "overflow: none|horizontal|vertical|both"))?,
|
||||
"scrollPosition" => doc.get_mut(id)?.scroll_position = enum_of(v).ok_or_else(|| bad(cmd, "scrollPosition: scrolls|fixed|sticky"))?,
|
||||
"constrainProportions" => doc.get_mut(id)?.constrain_proportions = v.as_bool().unwrap_or(false),
|
||||
"characters" | "text" => {
|
||||
let s = string_val(v).ok_or_else(|| bad(cmd, "`characters` must be a string"))?;
|
||||
let n = doc.get_mut(id)?;
|
||||
let t = text_mut(cmd, n)?;
|
||||
let len = t.char_count();
|
||||
t.replace_range(0, len, &s);
|
||||
n.overrides.insert("characters".into());
|
||||
}
|
||||
"fontFamily" | "fontStyle" | "fontWeight" | "fontSize" | "lineHeight" | "letterSpacing" | "textDecoration" | "textCase" | "hyperlink" | "textFills" => {
|
||||
let n = doc.get_mut(id)?;
|
||||
let t = text_mut(cmd, n)?;
|
||||
let (start, end) = range_of(props, t.char_count());
|
||||
apply_text_style(cmd, t, start, end, k, v)?;
|
||||
}
|
||||
"textAlign" | "textAlignHorizontal" => text_mut(cmd, doc.get_mut(id)?)?.align_h = enum_of(v).ok_or_else(|| bad(cmd, "textAlign: left|center|right|justified"))?,
|
||||
"textAlignVertical" => text_mut(cmd, doc.get_mut(id)?)?.align_v = enum_of(v).ok_or_else(|| bad(cmd, "textAlignVertical: top|middle|bottom"))?,
|
||||
"textAutoResize" => text_mut(cmd, doc.get_mut(id)?)?.auto_resize = enum_of(v).ok_or_else(|| bad(cmd, "textAutoResize: widthAndHeight|height|none|truncate"))?,
|
||||
"paragraphSpacing" => text_mut(cmd, doc.get_mut(id)?)?.paragraph_spacing = f64_of(v).unwrap_or(0.0).max(0.0),
|
||||
"paragraphIndent" => text_mut(cmd, doc.get_mut(id)?)?.paragraph_indent = f64_of(v).unwrap_or(0.0).max(0.0),
|
||||
"listStyle" => text_mut(cmd, doc.get_mut(id)?)?.list = enum_of(v).ok_or_else(|| bad(cmd, "listStyle: none|bulleted|numbered"))?,
|
||||
"maxLines" => text_mut(cmd, doc.get_mut(id)?)?.max_lines = f64_of(v).unwrap_or(0.0).clamp(0.0, 10_000.0) as u32,
|
||||
"start" | "end" => {}
|
||||
"pointCount" => {
|
||||
let c = f64_of(v).unwrap_or(5.0).clamp(3.0, 120.0) as u32;
|
||||
match &mut doc.get_mut(id)?.kind {
|
||||
NodeKind::Polygon { count } | NodeKind::Star { count, .. } => *count = c,
|
||||
_ => return Err(bad(cmd, "pointCount applies to polygons and stars")),
|
||||
}
|
||||
}
|
||||
"innerRadius" | "ratio" => {
|
||||
let r = f64_of(v).unwrap_or(0.38);
|
||||
let r = if r > 1.0 { r / 100.0 } else { r };
|
||||
match &mut doc.get_mut(id)?.kind {
|
||||
NodeKind::Star { inner_ratio, .. } => *inner_ratio = r.clamp(0.0, 1.0),
|
||||
_ => return Err(bad(cmd, "innerRadius applies to stars")),
|
||||
}
|
||||
}
|
||||
"arcStart" | "arcSweep" | "arcInner" => {
|
||||
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?;
|
||||
match &mut doc.get_mut(id)?.kind {
|
||||
NodeKind::Ellipse { start, sweep, inner } => match k {
|
||||
"arcStart" => *start = val.to_radians(),
|
||||
"arcSweep" => *sweep = val.clamp(-360.0, 360.0).to_radians(),
|
||||
_ => *inner = (if val > 1.0 { val / 100.0 } else { val }).clamp(0.0, 0.99),
|
||||
},
|
||||
_ => return Err(bad(cmd, "arc applies to ellipses")),
|
||||
}
|
||||
}
|
||||
"description" => match &mut doc.get_mut(id)?.kind {
|
||||
NodeKind::Component { description, .. } | NodeKind::ComponentSet { description } => *description = string_val(v).unwrap_or_default(),
|
||||
_ => return Err(bad(cmd, "description applies to components")),
|
||||
},
|
||||
"pluginData" => {
|
||||
let n = doc.get_mut(id)?;
|
||||
if let Some(o) = v.as_object() {
|
||||
for (pk, pv) in o {
|
||||
if pv.is_null() {
|
||||
n.plugin_data.remove(pk);
|
||||
} else {
|
||||
n.plugin_data.insert(pk.clone(), string_val(pv).unwrap_or_else(|| pv.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => {
|
||||
let sid = string_val(v);
|
||||
crate::cmd::styles::apply_style_ref(doc, id, k, sid.as_deref())?;
|
||||
}
|
||||
"boundVariables" => {
|
||||
let n = doc.get_mut(id)?;
|
||||
if let Some(o) = v.as_object() {
|
||||
for (field, var) in o {
|
||||
match var.as_str() {
|
||||
Some(vid) => {
|
||||
n.bound.insert(field.clone(), vid.to_string());
|
||||
}
|
||||
None => {
|
||||
n.bound.remove(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"variableModes" => {
|
||||
let n = doc.get_mut(id)?;
|
||||
if let Some(o) = v.as_object() {
|
||||
for (coll, mode) in o {
|
||||
match mode.as_str() {
|
||||
Some(m) => {
|
||||
n.var_modes.insert(coll.clone(), m.to_string());
|
||||
}
|
||||
None => {
|
||||
n.var_modes.remove(coll);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"booleanOperation" => match &mut doc.get_mut(id)?.kind {
|
||||
NodeKind::BooleanOp { op } => *op = enum_of(v).ok_or_else(|| bad(cmd, "booleanOperation: union|subtract|intersect|exclude"))?,
|
||||
_ => return Err(bad(cmd, "not a boolean group")),
|
||||
},
|
||||
"sectionContentsHidden" => {
|
||||
if let NodeKind::Section { contents_hidden } = &mut doc.get_mut(id)?.kind {
|
||||
*contents_hidden = v.as_bool().unwrap_or(false);
|
||||
}
|
||||
}
|
||||
"background" => {
|
||||
let c = color_of(v).ok_or_else(|| bad(cmd, "`background` must be a colour"))?;
|
||||
match &mut doc.get_mut(id)?.kind {
|
||||
NodeKind::Page { background, .. } => *background = c,
|
||||
_ => return Err(bad(cmd, "background applies to pages")),
|
||||
}
|
||||
}
|
||||
"componentProperties" => {
|
||||
if let NodeKind::Instance { props: p, .. } = &mut doc.get_mut(id)?.kind
|
||||
&& let Some(o) = v.as_object()
|
||||
{
|
||||
for (pk, pv) in o {
|
||||
p.insert(pk.clone(), pv.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
other => unknown.push(other.to_string()),
|
||||
}
|
||||
}
|
||||
if !unknown.is_empty() {
|
||||
return Err(bad(cmd, format!("unknown properties: {} (known: {KEYS})", unknown.join(", "))));
|
||||
}
|
||||
// Inside an instance, record which property groups were overridden.
|
||||
let n = doc.get_mut(id)?;
|
||||
if n.source.is_some() {
|
||||
for k in props.keys() {
|
||||
n.overrides.insert(override_group(k).to_string());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The override group a property key belongs to.
|
||||
pub fn override_group(k: &str) -> &'static str {
|
||||
match k {
|
||||
"fills" | "fill" => "fills",
|
||||
"strokes" | "stroke" | "strokeWeight" | "strokeAlign" | "dashes" | "strokeCap" | "startCap" | "endCap" | "strokeJoin" | "strokeSides" => "strokes",
|
||||
"effects" => "effects",
|
||||
"opacity" | "blend" | "blendMode" => "opacity",
|
||||
"visible" => "visible",
|
||||
"name" => "name",
|
||||
"characters" | "text" => "characters",
|
||||
"cornerRadius" | "cornerSmoothing" | "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => "corners",
|
||||
"width" | "height" | "w" | "h" | "sizingH" | "sizingV" => "size",
|
||||
k if k.starts_with("font") || k.starts_with("text") || k.starts_with("letter") || k.starts_with("line") || k.starts_with("paragraph") => "textStyle",
|
||||
_ => "other",
|
||||
}
|
||||
}
|
||||
|
||||
fn string_val(v: &Value) -> Option<String> {
|
||||
match v {
|
||||
Value::String(s) => Some(s.clone()),
|
||||
Value::Number(n) => Some(n.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn range_of(props: &Map<String, Value>, len: usize) -> (usize, usize) {
|
||||
let s = props.get("start").and_then(f64_of).map(|v| v.max(0.0) as usize).unwrap_or(0);
|
||||
let e = props.get("end").and_then(f64_of).map(|v| v.max(0.0) as usize).unwrap_or(len);
|
||||
(s.min(len), e.min(len))
|
||||
}
|
||||
|
||||
/// Set one text style property on chars `start..end`.
|
||||
pub fn apply_text_style(cmd: &str, t: &mut TextData, start: usize, end: usize, k: &str, v: &Value) -> Result<()> {
|
||||
match k {
|
||||
"fontFamily" => {
|
||||
let fam = string_val(v).ok_or_else(|| bad(cmd, "`fontFamily` must be a string"))?;
|
||||
// Keep the style if the new family has it, else the nearest.
|
||||
let styles = figcraft_text::family_styles(&fam);
|
||||
t.restyle(start, end, |s| {
|
||||
s.font_family = fam.clone();
|
||||
if !styles.is_empty() && !styles.contains(&s.font_style) {
|
||||
let want = s.weight();
|
||||
if let Some(best) = styles.iter().min_by_key(|st| (i32::from(style_weight(st)) - i32::from(want)).abs()) {
|
||||
s.font_style = best.clone();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
"fontStyle" => {
|
||||
let st = string_val(v).ok_or_else(|| bad(cmd, "`fontStyle` must be a string"))?;
|
||||
t.restyle(start, end, |s| s.font_style = st.clone());
|
||||
}
|
||||
"fontWeight" => {
|
||||
let w = f64_of(v).ok_or_else(|| bad(cmd, "`fontWeight` must be a number"))?;
|
||||
let name = weight_name(w as u16);
|
||||
t.restyle(start, end, |s| {
|
||||
s.font_style = if s.italic() { format!("{name} Italic").replace("Regular Italic", "Italic") } else { name.to_string() }
|
||||
});
|
||||
}
|
||||
"fontSize" => {
|
||||
let sz = f64_of(v).ok_or_else(|| bad(cmd, "`fontSize` must be a number"))?.clamp(1.0, 5000.0);
|
||||
t.restyle(start, end, |s| s.font_size = sz);
|
||||
}
|
||||
"lineHeight" => {
|
||||
let lh = line_height(v).ok_or_else(|| bad(cmd, "lineHeight: auto | px | \"150%\""))?;
|
||||
t.restyle(start, end, |s| s.line_height = lh);
|
||||
}
|
||||
"letterSpacing" => {
|
||||
let ls = letter_spacing(v).ok_or_else(|| bad(cmd, "letterSpacing: px | \"2%\""))?;
|
||||
t.restyle(start, end, |s| s.letter_spacing = ls);
|
||||
}
|
||||
"textDecoration" => {
|
||||
let d: TextDecoration = enum_of(v).ok_or_else(|| bad(cmd, "textDecoration: none|underline|strikethrough"))?;
|
||||
t.restyle(start, end, |s| s.decoration = d);
|
||||
}
|
||||
"textCase" => {
|
||||
let c: TextCase = enum_of(v).ok_or_else(|| bad(cmd, "textCase: original|upper|lower|title"))?;
|
||||
t.restyle(start, end, |s| s.case = c);
|
||||
}
|
||||
"hyperlink" => {
|
||||
let l = string_val(v);
|
||||
t.restyle(start, end, |s| s.link = l.clone());
|
||||
}
|
||||
"textFills" => {
|
||||
let p = if v.is_null() { None } else { Some(paints_of(v).ok_or_else(|| bad(cmd, "textFills: paints"))?) };
|
||||
t.restyle(start, end, |s| s.fills = p.clone());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn weight_name(w: u16) -> &'static str {
|
||||
match w {
|
||||
0..=149 => "Thin",
|
||||
150..=249 => "Extra Light",
|
||||
250..=349 => "Light",
|
||||
350..=449 => "Regular",
|
||||
450..=549 => "Medium",
|
||||
550..=649 => "Semi Bold",
|
||||
650..=749 => "Bold",
|
||||
750..=849 => "Extra Bold",
|
||||
_ => "Black",
|
||||
}
|
||||
}
|
||||
|
||||
fn set_sizing(cmd: &str, doc: &mut Document, id: NodeId, s: Sizing, horizontal: bool) -> Result<()> {
|
||||
let parent_al = doc.get(id)?.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some());
|
||||
let n = doc.get_mut(id)?;
|
||||
match s {
|
||||
Sizing::Fill if !parent_al => return Err(bad(cmd, "Fill needs an auto layout parent")),
|
||||
Sizing::Hug if n.layout.is_none() && !matches!(n.kind, NodeKind::Text(_)) => return Err(bad(cmd, "Hug needs auto layout or text")),
|
||||
_ => {}
|
||||
}
|
||||
if horizontal {
|
||||
n.child_layout.sizing_h = s;
|
||||
} else {
|
||||
n.child_layout.sizing_v = s;
|
||||
}
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
t.auto_resize = match (n.child_layout.sizing_h, n.child_layout.sizing_v) {
|
||||
(Sizing::Hug, _) => TextAutoResize::WidthAndHeight,
|
||||
(_, Sizing::Hug) => TextAutoResize::Height,
|
||||
_ => TextAutoResize::None,
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resize a node's box from its top-left, applying constraints / group scaling to children.
|
||||
pub fn resize_node(doc: &mut Document, id: NodeId, w: f64, h: f64) -> Result<()> {
|
||||
let n = doc.get(id)?;
|
||||
let (ow, oh) = (n.width, n.height);
|
||||
let group = n.kind.bounds_follow_children();
|
||||
figcraft_layout::set_size(doc, id, w, h);
|
||||
if group {
|
||||
let kx = if ow > 1e-9 { w / ow } else { 1.0 };
|
||||
let ky = if oh > 1e-9 { h / oh } else { 1.0 };
|
||||
figcraft_layout::scale_group_children(doc, id, kx, ky);
|
||||
} else if doc.get(id)?.layout.is_none() {
|
||||
figcraft_layout::apply_constraints(doc, id, ow, oh);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The design-panel view of a node (used by the UI and inspect).
|
||||
pub fn describe(doc: &Document, id: NodeId) -> Value {
|
||||
let Some(n) = doc.node(id) else { return Value::Null };
|
||||
let c = n.transform.as_coeffs();
|
||||
let mut v = json!({
|
||||
"id": n.id,
|
||||
"name": n.name,
|
||||
"type": n.kind.type_name(),
|
||||
"x": c[4], "y": c[5],
|
||||
"width": n.width, "height": n.height,
|
||||
"rotation": figcraft_geom::round_to(figcraft_geom::rotation_degrees(n.transform), 3),
|
||||
"visible": n.visible, "locked": n.locked,
|
||||
"opacity": n.opacity, "blend": n.blend,
|
||||
});
|
||||
if let Some(b) = doc.world_bounds(id) {
|
||||
v["absoluteBounds"] = json!({"x": b.x0, "y": b.y0, "width": b.width(), "height": b.height()});
|
||||
}
|
||||
let _ = Point::ZERO;
|
||||
let _ = Color::BLACK;
|
||||
v
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
//! Prototype commands: interactions, flows, settings, presenting.
|
||||
|
||||
use figcraft_doc::proto::*;
|
||||
use figcraft_doc::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::{Result, Session, bad, spec};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!(
|
||||
"proto.interaction.add",
|
||||
"Add interaction",
|
||||
"{id?, trigger? {type: onClick|onHover|onPress|onDrag|afterDelay|mouseEnter|mouseLeave|keyPress,..}, action {type: navigate|back|scrollTo|openOverlay|swapOverlay|closeOverlay|openUrl|changeTo|setVariable, dest?, url?}, transition? {type: instant|dissolve|smartAnimate|moveIn|push|slideIn,..}, durationMs?, easing?} — or {id, dest} for a click-navigate",
|
||||
true,
|
||||
add
|
||||
),
|
||||
spec!("proto.interaction.remove", "Remove interaction", "{id?, index}", true, |s, p| {
|
||||
let k = req_num("proto.interaction.remove", p, "index")? as usize;
|
||||
for i in req_targets("proto.interaction.remove", s, p)? {
|
||||
let n = s.doc_mut()?.get_mut(i)?;
|
||||
if k < n.interactions.len() {
|
||||
n.interactions.remove(k);
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("proto.interaction.set", "Edit interaction", "{id?, index, interaction {..}}", true, |s, p| {
|
||||
let k = req_num("proto.interaction.set", p, "index")? as usize;
|
||||
let it: Interaction = parse("proto.interaction.set", p, "interaction")?.ok_or_else(|| bad("proto.interaction.set", "`interaction` required"))?;
|
||||
for i in req_targets("proto.interaction.set", s, p)? {
|
||||
let n = s.doc_mut()?.get_mut(i)?;
|
||||
if let Some(slot) = n.interactions.get_mut(k) {
|
||||
*slot = it.clone();
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("proto.flow.add", "Add flow starting point", "{start? (frame id; default selection), name?}", true, |s, p| {
|
||||
let start = id(p, "start").or_else(|| s.selection().first().copied()).ok_or_else(|| bad("proto.flow.add", "select a top-level frame"))?;
|
||||
let page = s.page()?;
|
||||
let doc = s.doc_mut()?;
|
||||
let count = match &doc.get(page)?.kind {
|
||||
NodeKind::Page { flows, .. } => flows.len(),
|
||||
_ => 0,
|
||||
};
|
||||
let name = string(p, "name").unwrap_or_else(|| format!("Flow {}", count + 1));
|
||||
if let NodeKind::Page { flows, .. } = &mut doc.get_mut(page)?.kind {
|
||||
flows.retain(|f| f.start != start);
|
||||
flows.push(Flow { name, start, description: String::new() });
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("proto.flow.remove", "Remove flow", "{start}", true, |s, p| {
|
||||
let start = req_id("proto.flow.remove", p, "start")?;
|
||||
let page = s.page()?;
|
||||
if let NodeKind::Page { flows, .. } = &mut s.doc_mut()?.get_mut(page)?.kind {
|
||||
flows.retain(|f| f.start != start);
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("proto.settings", "Prototype settings", "{device?, background?}", true, |s, p| {
|
||||
let page = s.page()?;
|
||||
let bg = color(p, "background");
|
||||
let dev = string(p, "device");
|
||||
if let NodeKind::Page { prototype, .. } = &mut s.doc_mut()?.get_mut(page)?.kind {
|
||||
if let Some(c) = bg {
|
||||
prototype.background = c;
|
||||
}
|
||||
if let Some(d) = dev {
|
||||
prototype.device = d;
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("proto.present", "Present", "{start? (frame id)}", false, |s, p| {
|
||||
let start = id(p, "start").or_else(|| {
|
||||
let ds = s.ds().ok()?;
|
||||
ds.selection.first().and_then(|i| ds.doc.top_level_frame(*i)).or_else(|| match &ds.doc.node(ds.page)?.kind {
|
||||
NodeKind::Page { flows, .. } => flows.first().map(|f| f.start),
|
||||
_ => None,
|
||||
}).or_else(|| ds.doc.top_frames(ds.page).first().copied())
|
||||
});
|
||||
let start = start.ok_or_else(|| bad("proto.present", "no frame to present"))?;
|
||||
s.request_ui("present", json!({"start": start}));
|
||||
Ok(json!({"start": start}))
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "proto.interaction.add";
|
||||
let it = if let Some(d) = id(p, "dest") {
|
||||
Interaction { trigger: Trigger::OnClick, actions: vec![Action::Navigate { dest: d }], transition: Transition::Instant, duration_ms: 300, easing: Easing::EaseOut }
|
||||
} else {
|
||||
let trigger: Trigger = parse(cmd, p, "trigger")?.unwrap_or_default();
|
||||
let action: Action = parse(cmd, p, "action")?.ok_or_else(|| bad(cmd, "`action` required"))?;
|
||||
let transition: Transition = parse(cmd, p, "transition")?.unwrap_or_default();
|
||||
let easing: Easing = parse(cmd, p, "easing")?.unwrap_or_default();
|
||||
Interaction { trigger, actions: vec![action], transition, duration_ms: num_or(p, "durationMs", 300.0).clamp(0.0, 60_000.0) as u32, easing }
|
||||
};
|
||||
for i in req_targets(cmd, s, p)? {
|
||||
s.doc_mut()?.get_mut(i)?.interactions.push(it.clone());
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
|
@ -0,0 +1,465 @@
|
|||
//! Structure: groups, frames, masks, boolean groups, flatten, outline stroke, auto layout, pages.
|
||||
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::*;
|
||||
use figcraft_geom::{Affine, BezPath, Rect, Shape};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::edit::{roots, z_sorted};
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!("object.group", "Group selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Group)),
|
||||
spec!("object.ungroup", "Ungroup selection", "{ids?}", true, ungroup),
|
||||
spec!("object.frameSelection", "Frame selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Frame)),
|
||||
spec!("object.removeFrame", "Remove frame", "{ids?}", true, ungroup),
|
||||
spec!("object.mask", "Use as mask", "{ids?}", true, mask),
|
||||
spec!("object.releaseMask", "Release mask", "{ids?}", true, |s, p| {
|
||||
for i in req_targets("object.releaseMask", s, p)? {
|
||||
s.doc_mut()?.get_mut(i)?.is_mask = false;
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("bool.union", "Union selection", "{ids?}", true, |s, p| wrap(s, p, Wrap::Bool(BoolOp::Union))),
|
||||
spec!("bool.subtract", "Subtract selection", "{ids?}", true, |s, p| wrap(s, p, Wrap::Bool(BoolOp::Subtract))),
|
||||
spec!("bool.intersect", "Intersect selection", "{ids?}", true, |s, p| wrap(s, p, Wrap::Bool(BoolOp::Intersect))),
|
||||
spec!("bool.exclude", "Exclude selection", "{ids?}", true, |s, p| wrap(s, p, Wrap::Bool(BoolOp::Exclude))),
|
||||
spec!("bool.flatten", "Flatten boolean", "{ids?}", true, flatten),
|
||||
spec!("vector.flatten", "Flatten", "{ids?}", true, flatten),
|
||||
spec!("vector.outlineStroke", "Outline stroke", "{ids?}", true, outline_stroke),
|
||||
spec!("layout.add", "Add auto layout", "{ids?, direction? (horizontal|vertical|grid)}", true, layout_add),
|
||||
spec!("layout.remove", "Remove auto layout", "{ids?}", true, |s, p| {
|
||||
for i in req_targets("layout.remove", s, p)? {
|
||||
let n = s.doc_mut()?.get_mut(i)?;
|
||||
n.layout = None;
|
||||
n.child_layout.sizing_h = Sizing::Fixed;
|
||||
n.child_layout.sizing_v = Sizing::Fixed;
|
||||
}
|
||||
// Children that filled become fixed.
|
||||
ok()
|
||||
}),
|
||||
spec!("layout.suggest", "Suggest auto layout", "{ids?}", true, layout_add),
|
||||
spec!("object.moveToPage", "Move to page", "{ids?, page}", true, move_to_page),
|
||||
spec!("object.setThumbnail", "Set as thumbnail", "{id?}", true, |s, p| {
|
||||
let id = targets(s, p).first().copied().ok_or_else(|| bad("object.setThumbnail", "select a frame"))?;
|
||||
let doc = s.doc_mut()?;
|
||||
for n in doc.ids().collect::<Vec<_>>() {
|
||||
if let Some(node) = doc.node(n)
|
||||
&& node.plugin_data.contains_key("figcraft.thumbnail")
|
||||
{
|
||||
doc.get_mut(n)?.plugin_data.remove("figcraft.thumbnail");
|
||||
}
|
||||
}
|
||||
doc.get_mut(id)?.plugin_data.insert("figcraft.thumbnail".into(), "true".into());
|
||||
ok()
|
||||
}),
|
||||
spec!("object.rasterize", "Rasterize selection", "{ids?, scale? (default 2)}", true, rasterize),
|
||||
spec!("vector.reverse", "Reverse path direction", "{ids?}", true, |s, p| {
|
||||
for i in req_targets("vector.reverse", s, p)? {
|
||||
if let NodeKind::Vector { network } = &mut s.doc_mut()?.get_mut(i)?.kind {
|
||||
for seg in &mut network.segments {
|
||||
std::mem::swap(&mut seg.a, &mut seg.b);
|
||||
std::mem::swap(&mut seg.ta, &mut seg.tb);
|
||||
}
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("vector.simplify", "Simplify vector", "{ids?, tolerance?}", true, simplify),
|
||||
spec!("vector.offset", "Offset path", "{ids?, offset}", true, offset_path),
|
||||
spec!("vector.join", "Join selection", "{ids?}", true, join),
|
||||
spec!("vector.smoothJoin", "Smooth join selection", "{ids?}", true, join),
|
||||
]
|
||||
}
|
||||
|
||||
enum Wrap {
|
||||
Group,
|
||||
Frame,
|
||||
Bool(BoolOp),
|
||||
}
|
||||
|
||||
/// Wrap the selection in a new container at the topmost selected layer's place.
|
||||
fn wrap(s: &mut Session, p: &Value, w: Wrap) -> Result<Value> {
|
||||
let ids = req_targets("object.group", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let ids = z_sorted(doc, &roots(doc, &ids));
|
||||
let top = *ids.last().ok_or_else(|| bad("object.group", "nothing selected"))?;
|
||||
let parent = doc.get(top)?.parent.ok_or_else(|| bad("object.group", "can't group a page"))?;
|
||||
let index = doc.index_in_parent(top).map(|i| i + 1);
|
||||
let bounds = doc.bounds_of(&ids).unwrap_or_default();
|
||||
let pw = doc.world_transform(parent);
|
||||
let local = figcraft_geom::transform_rect_bbox(pw.inverse(), bounds);
|
||||
let (kind, base) = match &w {
|
||||
Wrap::Group => (NodeKind::Group, "Group"),
|
||||
Wrap::Frame => (NodeKind::Frame, "Frame"),
|
||||
Wrap::Bool(op) => (NodeKind::BooleanOp { op: *op }, op.label()),
|
||||
};
|
||||
let name = string(p, "name").unwrap_or_else(|| if matches!(w, Wrap::Bool(_)) { base.to_string() } else { doc.next_name(base) });
|
||||
let gid = doc.alloc_id();
|
||||
let mut g = Node::new(gid, kind, name, local.width(), local.height());
|
||||
g.transform = Affine::translate((local.x0, local.y0));
|
||||
if let Wrap::Bool(_) = w {
|
||||
// A boolean group takes its look from the bottom-most shape.
|
||||
if let Some(first) = ids.first().and_then(|i| doc.node(*i)) {
|
||||
g.fills = if first.fills.is_empty() { vec![Paint::solid(Color::DEFAULT_SHAPE)] } else { first.fills.clone() };
|
||||
g.strokes = first.strokes.clone();
|
||||
g.stroke = first.stroke.clone();
|
||||
g.effects = first.effects.clone();
|
||||
}
|
||||
g.blend = figcraft_color::BlendMode::Normal;
|
||||
}
|
||||
if let Wrap::Frame = w {
|
||||
g.clip = true;
|
||||
}
|
||||
// Insert the container where the topmost layer is.
|
||||
doc.insert(g, parent, index)?;
|
||||
for i in &ids {
|
||||
doc.reparent(*i, gid, None)?;
|
||||
}
|
||||
if !matches!(w, Wrap::Frame) {
|
||||
let _ = doc.fit_to_children(gid);
|
||||
}
|
||||
s.set_selection(vec![gid]);
|
||||
Ok(json!({"id": gid}))
|
||||
}
|
||||
|
||||
fn ungroup(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("object.ungroup", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let mut out = Vec::new();
|
||||
for g in ids {
|
||||
let n = doc.get(g)?;
|
||||
if !(matches!(n.kind, NodeKind::Group | NodeKind::BooleanOp { .. } | NodeKind::Frame | NodeKind::Section { .. })) {
|
||||
continue;
|
||||
}
|
||||
let Some(parent) = n.parent else { continue };
|
||||
let idx = doc.index_in_parent(g).unwrap_or(0);
|
||||
let kids = n.children.clone();
|
||||
// Ungrouping a group keeps its opacity on the children (multiplied).
|
||||
let op = n.opacity;
|
||||
for (k, c) in kids.iter().enumerate() {
|
||||
doc.reparent(*c, parent, Some(idx + k))?;
|
||||
if op < 1.0 {
|
||||
let cn = doc.get_mut(*c)?;
|
||||
cn.opacity *= op;
|
||||
}
|
||||
out.push(*c);
|
||||
}
|
||||
doc.remove(g)?;
|
||||
}
|
||||
s.set_selection(out.clone());
|
||||
Ok(json!({"ids": out}))
|
||||
}
|
||||
|
||||
fn mask(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("object.mask", s, p)?;
|
||||
if ids.len() == 1 {
|
||||
let doc = s.doc_mut()?;
|
||||
let id = ids.first().copied().unwrap_or(NodeId(0));
|
||||
let n = doc.get_mut(id)?;
|
||||
n.is_mask = !n.is_mask;
|
||||
return ok();
|
||||
}
|
||||
// Group, then the bottom-most child is the mask.
|
||||
let r = wrap(s, &json!({"ids": ids, "name": "Mask group"}), Wrap::Group)?;
|
||||
let gid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("group failed".into()))?;
|
||||
let doc = s.doc_mut()?;
|
||||
if let Some(first) = doc.get(gid)?.children.first().copied() {
|
||||
doc.get_mut(first)?.is_mask = true;
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// The outline of a node in its parent's space (shapes, text, booleans, groups).
|
||||
pub fn outline_in_parent(doc: &Document, id: NodeId) -> Option<BezPath> {
|
||||
let n = doc.node(id)?;
|
||||
let (mut p, _) = figcraft_render::subtree_outline(doc, n, 0)?;
|
||||
p.apply_affine(n.transform);
|
||||
Some(p)
|
||||
}
|
||||
|
||||
fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("vector.flatten", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let ids = z_sorted(doc, &roots(doc, &ids));
|
||||
let top = *ids.last().ok_or_else(|| bad("vector.flatten", "nothing selected"))?;
|
||||
let parent = doc.get(top)?.parent.ok_or_else(|| bad("vector.flatten", "can't flatten a page"))?;
|
||||
let index = doc.index_in_parent(top).map(|i| i + 1);
|
||||
let pw = doc.world_transform(parent);
|
||||
let mut path = BezPath::new();
|
||||
let mut even = false;
|
||||
for i in &ids {
|
||||
let Some(n) = doc.node(*i) else { continue };
|
||||
// Booleans flatten to their result; strokes don't get outlined.
|
||||
let Some((mut op, rule)) = (match &n.kind {
|
||||
NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. } => figcraft_render::subtree_outline(doc, n, 0),
|
||||
_ => figcraft_render::node_outline(doc, n),
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
even |= rule == figcraft_render::FillRule::EvenOdd;
|
||||
// Into the common parent's space.
|
||||
let w = doc.world_transform(*i);
|
||||
op.apply_affine(pw.inverse() * w);
|
||||
path.extend(op.elements().iter().copied());
|
||||
}
|
||||
if path.elements().is_empty() {
|
||||
return Err(EngineError::Disabled("vector.flatten".into(), "nothing to flatten".into()));
|
||||
}
|
||||
let first = ids.first().and_then(|i| doc.node(*i)).cloned();
|
||||
let bb = path.bounding_box();
|
||||
path.apply_affine(Affine::translate((-bb.x0, -bb.y0)));
|
||||
let mut net = VectorNetwork::from_path(&path);
|
||||
for r in &mut net.regions {
|
||||
r.winding = if even { WindingRule::EvenOdd } else { WindingRule::NonZero };
|
||||
}
|
||||
let vid = doc.alloc_id();
|
||||
let name = first.as_ref().map(|f| f.name.clone()).unwrap_or_else(|| "Vector".into());
|
||||
let mut v = Node::new(vid, NodeKind::Vector { network: net }, name, bb.width(), bb.height());
|
||||
v.transform = Affine::translate((bb.x0, bb.y0));
|
||||
if let Some(f) = first {
|
||||
v.fills = if matches!(f.kind, NodeKind::Group | NodeKind::Line) && f.fills.is_empty() { vec![Paint::solid(Color::DEFAULT_SHAPE)] } else { f.fills.clone() };
|
||||
v.strokes = f.strokes.clone();
|
||||
v.stroke = StrokeProps { align: StrokeAlign::Center, ..f.stroke.clone() };
|
||||
v.effects = f.effects.clone();
|
||||
v.opacity = f.opacity;
|
||||
v.blend = if matches!(f.blend, figcraft_color::BlendMode::PassThrough) { figcraft_color::BlendMode::Normal } else { f.blend };
|
||||
}
|
||||
doc.insert(v, parent, index)?;
|
||||
for i in &ids {
|
||||
doc.remove(*i)?;
|
||||
}
|
||||
s.set_selection(vec![vid]);
|
||||
Ok(json!({"id": vid}))
|
||||
}
|
||||
|
||||
fn outline_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("vector.outlineStroke", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let mut out = Vec::new();
|
||||
for i in ids {
|
||||
let n = doc.get(i)?.clone();
|
||||
if !n.has_visible_stroke() {
|
||||
continue;
|
||||
}
|
||||
let closed = figcraft_doc::geometry::is_closed(&n);
|
||||
let Some(geom) = (match &n.kind {
|
||||
NodeKind::Text(_) | NodeKind::BooleanOp { .. } => figcraft_render::node_outline(doc, &n).map(|(p, _)| p),
|
||||
_ => figcraft_doc::geometry::stroke_geometry(&n),
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
let area = figcraft_render::draw::stroke_area(&n, &geom, closed);
|
||||
let mut path = (*area).clone();
|
||||
// Union the pieces so the outline is one clean shape.
|
||||
if let Ok(u) = figcraft_pathops::simplify(&path) {
|
||||
path = u;
|
||||
}
|
||||
let bb = path.bounding_box();
|
||||
path.apply_affine(Affine::translate((-bb.x0, -bb.y0)));
|
||||
let mut net = VectorNetwork::from_path(&path);
|
||||
for r in &mut net.regions {
|
||||
r.winding = WindingRule::EvenOdd;
|
||||
}
|
||||
let parent = n.parent.ok_or_else(|| bad("vector.outlineStroke", "page"))?;
|
||||
let idx = doc.index_in_parent(i);
|
||||
let vid = doc.alloc_id();
|
||||
let mut v = Node::new(vid, NodeKind::Vector { network: net }, n.name.clone(), bb.width(), bb.height());
|
||||
v.transform = n.transform * Affine::translate((bb.x0, bb.y0));
|
||||
v.fills = n.strokes.clone();
|
||||
v.strokes.clear();
|
||||
if n.fills.iter().any(|f| f.visible) && closed {
|
||||
// Keep the fill as its own layer under the outline, grouped.
|
||||
doc.insert(v, parent, idx.map(|x| x + 1))?;
|
||||
let fill_id = i;
|
||||
{
|
||||
let f = doc.get_mut(fill_id)?;
|
||||
f.strokes.clear();
|
||||
}
|
||||
let r = wrap(s, &json!({"ids": [fill_id, vid], "name": n.name}), Wrap::Group)?;
|
||||
if let Some(g) = id_of(&r["id"]) {
|
||||
out.push(g);
|
||||
}
|
||||
return Ok(json!({"ids": out}));
|
||||
}
|
||||
doc.insert(v, parent, idx.map(|x| x + 1))?;
|
||||
doc.remove(i)?;
|
||||
out.push(vid);
|
||||
}
|
||||
s.set_selection(out.clone());
|
||||
Ok(json!({"ids": out}))
|
||||
}
|
||||
|
||||
/// Infer direction, gap and padding from where children are (Figma's "Add auto layout").
|
||||
fn infer_layout(doc: &Document, frame: NodeId) -> AutoLayout {
|
||||
let Some(f) = doc.node(frame) else { return AutoLayout::default() };
|
||||
let boxes: Vec<Rect> = f.children.iter().filter_map(|c| doc.node(*c)).map(|c| figcraft_geom::transform_rect_bbox(c.transform, c.local_rect())).collect();
|
||||
if boxes.is_empty() {
|
||||
return AutoLayout { padding: [10.0; 4], gap: 10.0, ..Default::default() };
|
||||
}
|
||||
let span_x = boxes.iter().map(|b| b.x1).fold(f64::MIN, f64::max) - boxes.iter().map(|b| b.x0).fold(f64::MAX, f64::min);
|
||||
let span_y = boxes.iter().map(|b| b.y1).fold(f64::MIN, f64::max) - boxes.iter().map(|b| b.y0).fold(f64::MAX, f64::min);
|
||||
let vertical = span_y > span_x;
|
||||
let mut sorted = boxes.clone();
|
||||
sorted.sort_by(|a, b| if vertical { a.y0.total_cmp(&b.y0) } else { a.x0.total_cmp(&b.x0) });
|
||||
let gaps: Vec<f64> = sorted.windows(2).filter_map(|w| Some(if vertical { w.get(1)?.y0 - w.first()?.y1 } else { w.get(1)?.x0 - w.first()?.x1 })).collect();
|
||||
let gap = if gaps.is_empty() { 10.0 } else { (gaps.iter().sum::<f64>() / gaps.len() as f64).max(0.0).round() };
|
||||
let all = boxes.iter().copied().reduce(|a, b| a.union(b)).unwrap_or_default();
|
||||
let pad = [all.y0.max(0.0), (f.width - all.x1).max(0.0), (f.height - all.y1).max(0.0), all.x0.max(0.0)].map(f64::round);
|
||||
AutoLayout { mode: if vertical { LayoutMode::Vertical } else { LayoutMode::Horizontal }, gap, padding: pad, ..Default::default() }
|
||||
}
|
||||
|
||||
fn layout_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("layout.add", s, p)?;
|
||||
let dir = string(p, "direction");
|
||||
let doc = s.doc()?;
|
||||
let all_frames = ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none()));
|
||||
let targets = if all_frames {
|
||||
ids
|
||||
} else {
|
||||
// Wrap non-frames in a new frame without a fill, then add auto layout to it.
|
||||
let r = wrap(s, &json!({"ids": ids}), Wrap::Frame)?;
|
||||
let fid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("wrap failed".into()))?;
|
||||
let f = s.doc_mut()?.get_mut(fid)?;
|
||||
f.fills.clear();
|
||||
f.clip = false;
|
||||
vec![fid]
|
||||
};
|
||||
let doc = s.doc_mut()?;
|
||||
for f in &targets {
|
||||
let mut al = infer_layout(doc, *f);
|
||||
if let Some(d) = &dir {
|
||||
al.mode = match d.to_ascii_lowercase().as_str() {
|
||||
"vertical" | "column" => LayoutMode::Vertical,
|
||||
"grid" => LayoutMode::Grid,
|
||||
_ => LayoutMode::Horizontal,
|
||||
};
|
||||
}
|
||||
// Children in reading order along the axis.
|
||||
let mut kids = doc.get(*f)?.children.clone();
|
||||
let vertical = al.mode == LayoutMode::Vertical;
|
||||
kids.sort_by(|a, b| {
|
||||
let pa = doc.node(*a).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default();
|
||||
let pb = doc.node(*b).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default();
|
||||
if vertical { pa.y0.total_cmp(&pb.y0) } else { pa.x0.total_cmp(&pb.x0) }
|
||||
});
|
||||
let n = doc.get_mut(*f)?;
|
||||
n.children = kids;
|
||||
n.layout = Some(al);
|
||||
n.child_layout.sizing_h = Sizing::Hug;
|
||||
n.child_layout.sizing_v = Sizing::Hug;
|
||||
}
|
||||
s.set_selection(targets.clone());
|
||||
Ok(json!({"ids": targets}))
|
||||
}
|
||||
|
||||
fn move_to_page(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("object.moveToPage", s, p)?;
|
||||
let page = req_id("object.moveToPage", p, "page")?;
|
||||
let doc = s.doc_mut()?;
|
||||
if !doc.node(page).is_some_and(Node::is_page) {
|
||||
return Err(bad("object.moveToPage", format!("{page} is not a page")));
|
||||
}
|
||||
for i in z_sorted(doc, &roots(doc, &ids)) {
|
||||
doc.reparent(i, page, None)?;
|
||||
}
|
||||
s.set_selection(vec![]);
|
||||
ok()
|
||||
}
|
||||
|
||||
fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("object.rasterize", s, p)?;
|
||||
let scale = num_or(p, "scale", 2.0);
|
||||
let mut out = Vec::new();
|
||||
for i in ids {
|
||||
let doc = s.doc()?;
|
||||
let Some(pix) = figcraft_render::render_node(doc, i, scale) else { continue };
|
||||
let png = figcraft_render::encode_png(&pix).map_err(EngineError::Other)?;
|
||||
let b = figcraft_render::export_bounds(doc, i).unwrap_or_default();
|
||||
let parent = doc.get(i)?.parent.ok_or_else(|| bad("object.rasterize", "page"))?;
|
||||
let idx = doc.index_in_parent(i);
|
||||
let pw = doc.world_transform(parent);
|
||||
let name = doc.get(i)?.name.clone();
|
||||
let docm = s.doc_mut()?;
|
||||
let (hash, _, _) = figcraft_format::add_image(docm, png).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let rid = docm.alloc_id();
|
||||
let mut r = Node::new(rid, NodeKind::Rectangle, name, b.width(), b.height());
|
||||
r.transform = pw.inverse() * Affine::translate((b.x0, b.y0));
|
||||
r.fills = vec![Paint::image(hash, figcraft_color::ImageScaleMode::Fill)];
|
||||
docm.insert(r, parent, idx.map(|x| x + 1))?;
|
||||
docm.remove(i)?;
|
||||
out.push(rid);
|
||||
}
|
||||
s.set_selection(out.clone());
|
||||
Ok(json!({"ids": out}))
|
||||
}
|
||||
|
||||
fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let tol = num_or(p, "tolerance", 0.5).max(0.01);
|
||||
for i in req_targets("vector.simplify", s, p)? {
|
||||
let doc = s.doc_mut()?;
|
||||
let n = doc.get_mut(i)?;
|
||||
if let NodeKind::Vector { network } = &mut n.kind {
|
||||
let fill = network.fill_path();
|
||||
let src = if fill.elements().is_empty() { network.stroke_path() } else { fill };
|
||||
let simplified = figcraft_geom::kurbo::simplify::simplify_bezpath(src.iter(), tol, &Default::default());
|
||||
let regions = !network.regions.is_empty();
|
||||
let mut net = VectorNetwork::from_path(&simplified);
|
||||
if !regions {
|
||||
net.regions.clear();
|
||||
}
|
||||
*network = net;
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = req_num("vector.offset", p, "offset")?;
|
||||
for i in req_targets("vector.offset", s, p)? {
|
||||
let doc = s.doc_mut()?;
|
||||
let n = doc.get(i)?.clone();
|
||||
let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue };
|
||||
let props = StrokeProps { weight: d.abs() * 2.0, join: StrokeJoin::Round, align: StrokeAlign::Center, ..Default::default() };
|
||||
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
|
||||
let res = if d >= 0.0 { figcraft_pathops::boolean(&[path, ring], BoolOp::Union) } else { figcraft_pathops::boolean(&[path, ring], BoolOp::Subtract) };
|
||||
let Ok(mut res) = res else { continue };
|
||||
let bb = res.bounding_box();
|
||||
res.apply_affine(Affine::translate((-bb.x0, -bb.y0)));
|
||||
let nm = doc.get_mut(i)?;
|
||||
let mut net = VectorNetwork::from_path(&res);
|
||||
for r in &mut net.regions {
|
||||
r.winding = WindingRule::EvenOdd;
|
||||
}
|
||||
nm.kind = NodeKind::Vector { network: net };
|
||||
nm.transform = nm.transform * Affine::translate((bb.x0, bb.y0));
|
||||
nm.width = bb.width();
|
||||
nm.height = bb.height();
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Join open vectors whose end points touch into one vector.
|
||||
fn join(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("vector.join", s, p)?;
|
||||
if ids.len() < 2 {
|
||||
// Close a single open path.
|
||||
if let Some(i) = ids.first() {
|
||||
let n = s.doc_mut()?.get_mut(*i)?;
|
||||
if let NodeKind::Vector { network } = &mut n.kind
|
||||
&& network.regions.is_empty()
|
||||
&& network.vertices.len() > 2
|
||||
{
|
||||
let last = network.vertices.len() - 1;
|
||||
network.segments.push(Segment::line(last, 0));
|
||||
let all: Vec<usize> = (0..network.segments.len()).collect();
|
||||
network.regions.push(Region { loops: vec![all], winding: WindingRule::NonZero });
|
||||
}
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
flatten(s, &json!({"ids": ids}))
|
||||
}
|
||||
|
|
@ -0,0 +1,406 @@
|
|||
//! Shared styles (paint, text, effect, grid) and variables (collections, modes, bindings).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use figcraft_color::Color;
|
||||
use figcraft_doc::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!("style.create", "Create style", "{type: paint|text|effect|grid, name, from? (node id; default first selected), description?, value? (paints/effects/grids/text style JSON)}", true, create),
|
||||
spec!("style.apply", "Apply style", "{ids?, style (id or name), target? (fill|stroke; paint styles)}", true, apply),
|
||||
spec!("style.detach", "Detach style", "{ids?, which: fill|stroke|effect|grid|text}", true, |s, p| {
|
||||
let which = req_string("style.detach", p, "which")?;
|
||||
for i in req_targets("style.detach", s, p)? {
|
||||
let n = s.doc_mut()?.get_mut(i)?;
|
||||
match which.as_str() {
|
||||
"fill" => n.styles.fill = None,
|
||||
"stroke" => n.styles.stroke = None,
|
||||
"effect" => n.styles.effect = None,
|
||||
"grid" => n.styles.grid = None,
|
||||
"text" => n.styles.text = None,
|
||||
_ => return Err(bad("style.detach", "which: fill|stroke|effect|grid|text")),
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("style.update", "Edit style", "{style, name?, description?, value?, from? (node id: redefine from it)}", true, update),
|
||||
spec!("style.delete", "Delete style", "{style}", true, |s, p| {
|
||||
let id = req_string("style.delete", p, "style")?;
|
||||
let doc = s.doc_mut()?;
|
||||
let sid = doc.style(&id).map(|st| st.id.clone()).ok_or_else(|| bad("style.delete", "no such style"))?;
|
||||
doc.styles.retain(|st| st.id != sid);
|
||||
// Nodes keep their values but lose the link.
|
||||
for nid in doc.ids().collect::<Vec<_>>() {
|
||||
if let Some(n) = doc.node(nid)
|
||||
&& [&n.styles.fill, &n.styles.stroke, &n.styles.effect, &n.styles.grid, &n.styles.text].iter().any(|r| r.as_deref() == Some(sid.as_str()))
|
||||
{
|
||||
let n = doc.get_mut(nid)?;
|
||||
for r in [&mut n.styles.fill, &mut n.styles.stroke, &mut n.styles.effect, &mut n.styles.grid, &mut n.styles.text] {
|
||||
if r.as_deref() == Some(sid.as_str()) {
|
||||
*r = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("style.list", "List styles", "{type?}", false, |s, p| {
|
||||
let t = string(p, "type");
|
||||
Ok(Value::Array(s.doc()?.styles.iter().filter(|st| t.as_deref().is_none_or(|t| st.kind.type_name() == t)).map(|st| serde_json::to_value(st).unwrap_or(Value::Null)).collect()))
|
||||
}),
|
||||
spec!("variable.createCollection", "Create variable collection", "{name, modes? [names]}", true, |s, p| {
|
||||
let name = req_string("variable.createCollection", p, "name")?;
|
||||
let doc = s.doc_mut()?;
|
||||
let id = format!("VariableCollectionId:{}", doc.variables.collections.len() + 1);
|
||||
let modes: Vec<String> = match get(p, "modes") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(|m| m.as_str().map(str::to_string)).collect(),
|
||||
_ => vec!["Mode 1".into()],
|
||||
};
|
||||
let modes = modes.into_iter().enumerate().map(|(i, m)| Mode { id: format!("{id}/mode{}", i + 1), name: m }).collect();
|
||||
doc.variables.collections.push(Collection { id: id.clone(), name, modes });
|
||||
Ok(json!({"id": id}))
|
||||
}),
|
||||
spec!("variable.addMode", "Add mode", "{collection, name}", true, |s, p| {
|
||||
let c = req_string("variable.addMode", p, "collection")?;
|
||||
let name = req_string("variable.addMode", p, "name")?;
|
||||
let doc = s.doc_mut()?;
|
||||
let coll = doc.variables.collections.iter_mut().find(|x| x.id == c || x.name == c).ok_or_else(|| bad("variable.addMode", "no such collection"))?;
|
||||
let mid = format!("{}/mode{}", coll.id, coll.modes.len() + 1);
|
||||
let first = coll.modes.first().map(|m| m.id.clone());
|
||||
coll.modes.push(Mode { id: mid.clone(), name });
|
||||
let cid = coll.id.clone();
|
||||
// New mode starts with the default mode's values.
|
||||
for v in doc.variables.variables.iter_mut().filter(|v| v.collection == cid) {
|
||||
if let Some(val) = first.as_ref().and_then(|f| v.values.get(f)).cloned() {
|
||||
v.values.insert(mid.clone(), val);
|
||||
}
|
||||
}
|
||||
Ok(json!({"id": mid}))
|
||||
}),
|
||||
spec!("variable.create", "Create variable", "{collection, name, type: color|number|string|boolean, value (default mode) | values {modeName: value}}", true, var_create),
|
||||
spec!("variable.set", "Set variable value", "{variable, mode? (name or id; default first), value}", true, var_set),
|
||||
spec!("variable.delete", "Delete variable", "{variable}", true, |s, p| {
|
||||
let v = req_string("variable.delete", p, "variable")?;
|
||||
let doc = s.doc_mut()?;
|
||||
let id = doc.variables.get(&v).map(|x| x.id.clone()).ok_or_else(|| bad("variable.delete", "no such variable"))?;
|
||||
doc.variables.variables.retain(|x| x.id != id);
|
||||
ok()
|
||||
}),
|
||||
spec!("variable.bind", "Bind variable", "{ids?, field (fill|stroke|width|height|opacity|cornerRadius|gap|padding|characters|visible|fontSize…), variable (null unbinds)}", true, |s, p| {
|
||||
let field = req_string("variable.bind", p, "field")?;
|
||||
let var = string(p, "variable");
|
||||
let ids = req_targets("variable.bind", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
if let Some(v) = &var
|
||||
&& doc.variables.get(v).is_none()
|
||||
{
|
||||
return Err(bad("variable.bind", "no such variable"));
|
||||
}
|
||||
let vid = var.and_then(|v| doc.variables.get(&v).map(|x| x.id.clone()));
|
||||
for i in ids {
|
||||
let n = doc.get_mut(i)?;
|
||||
match &vid {
|
||||
Some(v) => {
|
||||
n.bound.insert(field.clone(), v.clone());
|
||||
}
|
||||
None => {
|
||||
n.bound.remove(&field);
|
||||
}
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("variable.setMode", "Set variable mode", "{ids? (frames/page), collection, mode}", true, |s, p| {
|
||||
let c = req_string("variable.setMode", p, "collection")?;
|
||||
let m = req_string("variable.setMode", p, "mode")?;
|
||||
let doc = s.doc()?;
|
||||
let coll = doc.variables.collection(&c).ok_or_else(|| bad("variable.setMode", "no such collection"))?;
|
||||
let mode = coll.modes.iter().find(|x| x.id == m || x.name == m).map(|x| x.id.clone()).ok_or_else(|| bad("variable.setMode", "no such mode"))?;
|
||||
let cid = coll.id.clone();
|
||||
let mut ids = targets(s, p);
|
||||
if ids.is_empty() {
|
||||
ids = vec![s.page()?];
|
||||
}
|
||||
let doc = s.doc_mut()?;
|
||||
for i in ids {
|
||||
doc.get_mut(i)?.var_modes.insert(cid.clone(), mode.clone());
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("variable.list", "List variables", "{}", false, |s, _| Ok(serde_json::to_value(&s.doc()?.variables).unwrap_or(Value::Null))),
|
||||
]
|
||||
}
|
||||
|
||||
fn next_style_id(doc: &Document) -> String {
|
||||
let mut k = doc.styles.len() + 1;
|
||||
loop {
|
||||
let id = format!("S:{k}");
|
||||
if doc.style(&id).is_none() {
|
||||
return id;
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_from_node(t: &str, n: &Node) -> Option<StyleKind> {
|
||||
Some(match t {
|
||||
"paint" | "color" | "fill" => StyleKind::Paint { paints: n.fills.clone() },
|
||||
"stroke" => StyleKind::Paint { paints: n.strokes.clone() },
|
||||
"text" => StyleKind::Text { style: n.kind.text()?.style.clone() },
|
||||
"effect" => StyleKind::Effect { effects: n.effects.clone() },
|
||||
"grid" => StyleKind::Grid { grids: n.grids.clone() },
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "style.create";
|
||||
let t = req_string(cmd, p, "type")?.to_ascii_lowercase();
|
||||
let name = req_string(cmd, p, "name")?;
|
||||
let from = id(p, "from").or_else(|| s.selection().first().copied());
|
||||
let doc = s.doc_mut()?;
|
||||
let kind = if let Some(v) = get(p, "value") {
|
||||
match t.as_str() {
|
||||
"paint" | "color" | "fill" => StyleKind::Paint { paints: paints_of(v).ok_or_else(|| bad(cmd, "value: paints"))? },
|
||||
"text" => StyleKind::Text { style: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
|
||||
"effect" => StyleKind::Effect { effects: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
|
||||
"grid" => StyleKind::Grid { grids: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
|
||||
_ => return Err(bad(cmd, "type: paint|text|effect|grid")),
|
||||
}
|
||||
} else {
|
||||
let n = from.and_then(|f| doc.node(f)).ok_or_else(|| bad(cmd, "pass `value` or select a layer to take it from"))?;
|
||||
kind_from_node(&t, n).ok_or_else(|| bad(cmd, "that layer has no such property"))?
|
||||
};
|
||||
let sid = next_style_id(doc);
|
||||
doc.styles.push(Style { id: sid.clone(), name, description: string(p, "description").unwrap_or_default(), kind });
|
||||
// The source layer becomes linked to the new style.
|
||||
if let Some(f) = from
|
||||
&& get(p, "value").is_none()
|
||||
{
|
||||
let field = match t.as_str() {
|
||||
"stroke" => "strokeStyle",
|
||||
"text" => "textStyle",
|
||||
"effect" => "effectStyle",
|
||||
"grid" => "gridStyle",
|
||||
_ => "fillStyle",
|
||||
};
|
||||
apply_style_ref(doc, f, field, Some(&sid))?;
|
||||
}
|
||||
Ok(json!({"id": sid}))
|
||||
}
|
||||
|
||||
/// Link node `id` to style `sid` for `field` (fillStyle, strokeStyle, …) and copy its value in.
|
||||
pub fn apply_style_ref(doc: &mut Document, id: NodeId, field: &str, sid: Option<&str>) -> Result<()> {
|
||||
let style = match sid {
|
||||
Some(s) => Some(doc.style(s).cloned().ok_or_else(|| EngineError::Other(format!("no such style `{s}`")))?),
|
||||
None => None,
|
||||
};
|
||||
let n = doc.get_mut(id)?;
|
||||
match (field, &style) {
|
||||
("fillStyle", Some(Style { kind: StyleKind::Paint { paints }, id, .. })) => {
|
||||
n.fills = paints.clone();
|
||||
n.styles.fill = Some(id.clone());
|
||||
}
|
||||
("strokeStyle", Some(Style { kind: StyleKind::Paint { paints }, id, .. })) => {
|
||||
n.strokes = paints.clone();
|
||||
n.styles.stroke = Some(id.clone());
|
||||
}
|
||||
("effectStyle", Some(Style { kind: StyleKind::Effect { effects }, id, .. })) => {
|
||||
n.effects = effects.clone();
|
||||
n.styles.effect = Some(id.clone());
|
||||
}
|
||||
("gridStyle", Some(Style { kind: StyleKind::Grid { grids }, id, .. })) => {
|
||||
n.grids = grids.clone();
|
||||
n.styles.grid = Some(id.clone());
|
||||
}
|
||||
("textStyle", Some(Style { kind: StyleKind::Text { style }, id, .. })) => {
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
let keep_fills = t.style.fills.clone();
|
||||
t.style = TextStyle { fills: keep_fills, ..style.clone() };
|
||||
t.runs.clear();
|
||||
}
|
||||
n.styles.text = Some(id.clone());
|
||||
}
|
||||
("fillStyle", None) => n.styles.fill = None,
|
||||
("strokeStyle", None) => n.styles.stroke = None,
|
||||
("effectStyle", None) => n.styles.effect = None,
|
||||
("gridStyle", None) => n.styles.grid = None,
|
||||
("textStyle", None) => n.styles.text = None,
|
||||
_ => return Err(EngineError::Other(format!("style doesn't fit `{field}`"))),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "style.apply";
|
||||
let st = req_string(cmd, p, "style")?;
|
||||
let ids = req_targets(cmd, s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let style = doc.style(&st).cloned().ok_or_else(|| bad(cmd, format!("no style `{st}`")))?;
|
||||
let field = match (&style.kind, string(p, "target").as_deref()) {
|
||||
(StyleKind::Paint { .. }, Some("stroke")) => "strokeStyle",
|
||||
(StyleKind::Paint { .. }, _) => "fillStyle",
|
||||
(StyleKind::Text { .. }, _) => "textStyle",
|
||||
(StyleKind::Effect { .. }, _) => "effectStyle",
|
||||
(StyleKind::Grid { .. }, _) => "gridStyle",
|
||||
};
|
||||
for i in ids {
|
||||
apply_style_ref(doc, i, field, Some(&style.id))?;
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
fn update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "style.update";
|
||||
let st = req_string(cmd, p, "style")?;
|
||||
let doc = s.doc_mut()?;
|
||||
let idx = doc.styles.iter().position(|x| x.id == st || x.name == st).ok_or_else(|| bad(cmd, "no such style"))?;
|
||||
let from = id(p, "from");
|
||||
let new_kind = if let Some(v) = get(p, "value") {
|
||||
let cur = doc.styles.get(idx).map(|x| x.kind.type_name()).unwrap_or("paint");
|
||||
Some(match cur {
|
||||
"paint" => StyleKind::Paint { paints: paints_of(v).ok_or_else(|| bad(cmd, "value: paints"))? },
|
||||
"text" => StyleKind::Text { style: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
|
||||
"effect" => StyleKind::Effect { effects: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
|
||||
_ => StyleKind::Grid { grids: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
|
||||
})
|
||||
} else if let Some(f) = from {
|
||||
let t = doc.styles.get(idx).map(|x| x.kind.type_name()).unwrap_or("paint");
|
||||
doc.node(f).and_then(|n| kind_from_node(t, n))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let Some(style) = doc.styles.get_mut(idx) else { return Err(bad(cmd, "no such style")) };
|
||||
if let Some(n) = string(p, "name") {
|
||||
style.name = n;
|
||||
}
|
||||
if let Some(d) = string(p, "description") {
|
||||
style.description = d;
|
||||
}
|
||||
if let Some(k) = new_kind {
|
||||
style.kind = k;
|
||||
}
|
||||
let sid = style.id.clone();
|
||||
propagate_style(doc, &sid);
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Re-apply style `sid` to every node linked to it.
|
||||
pub fn propagate_style(doc: &mut Document, sid: &str) {
|
||||
let ids: Vec<NodeId> = doc.ids().collect();
|
||||
for i in ids {
|
||||
let Some(n) = doc.node(i) else { continue };
|
||||
let refs = n.styles.clone();
|
||||
for (field, r) in [("fillStyle", &refs.fill), ("strokeStyle", &refs.stroke), ("effectStyle", &refs.effect), ("gridStyle", &refs.grid), ("textStyle", &refs.text)] {
|
||||
if r.as_deref() == Some(sid) {
|
||||
let _ = apply_style_ref(doc, i, field, Some(sid));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn var_value(kind: VariableType, v: &Value) -> Option<VariableValue> {
|
||||
if let Some(a) = v.get("alias").and_then(Value::as_str) {
|
||||
return Some(VariableValue::Alias(a.to_string()));
|
||||
}
|
||||
Some(match kind {
|
||||
VariableType::Color => VariableValue::Color(color_of(v)?),
|
||||
VariableType::Number => VariableValue::Number(f64_of(v)?),
|
||||
VariableType::String => VariableValue::String(v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())),
|
||||
VariableType::Boolean => VariableValue::Boolean(v.as_bool()?),
|
||||
})
|
||||
}
|
||||
|
||||
fn var_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "variable.create";
|
||||
let c = req_string(cmd, p, "collection")?;
|
||||
let name = req_string(cmd, p, "name")?;
|
||||
let kind = match req_string(cmd, p, "type")?.to_ascii_lowercase().as_str() {
|
||||
"color" => VariableType::Color,
|
||||
"number" | "float" => VariableType::Number,
|
||||
"string" => VariableType::String,
|
||||
"boolean" | "bool" => VariableType::Boolean,
|
||||
_ => return Err(bad(cmd, "type: color|number|string|boolean")),
|
||||
};
|
||||
let doc = s.doc_mut()?;
|
||||
let coll = doc.variables.collection(&c).cloned().ok_or_else(|| bad(cmd, "no such collection"))?;
|
||||
let mut values = BTreeMap::new();
|
||||
if let Some(Value::Object(m)) = get(p, "values") {
|
||||
for (mode, v) in m {
|
||||
let mid = coll.modes.iter().find(|x| x.id == *mode || x.name == *mode).map(|x| x.id.clone()).ok_or_else(|| bad(cmd, format!("no mode `{mode}`")))?;
|
||||
values.insert(mid, var_value(kind, v).ok_or_else(|| bad(cmd, "invalid value"))?);
|
||||
}
|
||||
}
|
||||
if let Some(v) = get(p, "value") {
|
||||
let val = var_value(kind, v).ok_or_else(|| bad(cmd, "invalid value"))?;
|
||||
for m in &coll.modes {
|
||||
values.entry(m.id.clone()).or_insert_with(|| val.clone());
|
||||
}
|
||||
}
|
||||
let default = match kind {
|
||||
VariableType::Color => VariableValue::Color(Color::BLACK),
|
||||
VariableType::Number => VariableValue::Number(0.0),
|
||||
VariableType::String => VariableValue::String(String::new()),
|
||||
VariableType::Boolean => VariableValue::Boolean(true),
|
||||
};
|
||||
for m in &coll.modes {
|
||||
values.entry(m.id.clone()).or_insert_with(|| default.clone());
|
||||
}
|
||||
let id = format!("VariableID:{}", doc.variables.variables.len() + 1);
|
||||
doc.variables.variables.push(Variable { id: id.clone(), name, collection: coll.id, kind, values, description: string(p, "description").unwrap_or_default() });
|
||||
Ok(json!({"id": id}))
|
||||
}
|
||||
|
||||
fn var_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "variable.set";
|
||||
let v = req_string(cmd, p, "variable")?;
|
||||
let doc = s.doc_mut()?;
|
||||
let var = doc.variables.get(&v).cloned().ok_or_else(|| bad(cmd, "no such variable"))?;
|
||||
let coll = doc.variables.collection(&var.collection).cloned().ok_or_else(|| bad(cmd, "variable has no collection"))?;
|
||||
let mode = match string(p, "mode") {
|
||||
Some(m) => coll.modes.iter().find(|x| x.id == m || x.name == m).map(|x| x.id.clone()).ok_or_else(|| bad(cmd, "no such mode"))?,
|
||||
None => coll.default_mode().cloned().ok_or_else(|| bad(cmd, "collection has no modes"))?,
|
||||
};
|
||||
let val = get(p, "value").and_then(|x| var_value(var.kind, x)).ok_or_else(|| bad(cmd, "invalid value"))?;
|
||||
if let Some(slot) = doc.variables.variables.iter_mut().find(|x| x.id == var.id) {
|
||||
slot.values.insert(mode, val);
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Write bound variable values into nodes (called during normalisation).
|
||||
pub fn apply_bindings(doc: &mut Document) {
|
||||
if doc.variables.variables.is_empty() {
|
||||
return;
|
||||
}
|
||||
let bound: Vec<NodeId> = doc.nodes().filter(|n| !n.bound.is_empty()).map(|n| n.id).collect();
|
||||
for id in bound {
|
||||
let modes = doc.modes_for(id);
|
||||
let Some(n) = doc.node(id) else { continue };
|
||||
let binds = n.bound.clone();
|
||||
let mut sets = serde_json::Map::new();
|
||||
for (field, var) in binds {
|
||||
let Some(val) = doc.variables.resolve(&var, &modes) else { continue };
|
||||
let jv = match &val {
|
||||
VariableValue::Color(c) => json!(c.to_css_hex()),
|
||||
VariableValue::Number(x) => json!(x),
|
||||
VariableValue::String(x) => json!(x),
|
||||
VariableValue::Boolean(b) => json!(b),
|
||||
VariableValue::Alias(_) => continue,
|
||||
};
|
||||
let key = match field.as_str() {
|
||||
"fill" | "fills" | "fills/0" | "fills/0/color" => "fill",
|
||||
"stroke" | "strokes/0/color" => "stroke",
|
||||
other => other,
|
||||
};
|
||||
sets.insert(key.to_string(), jv);
|
||||
}
|
||||
let _ = super::props::apply("variables", doc, id, &sets);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
//! Text menu and text editing commands.
|
||||
|
||||
use figcraft_doc::*;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::{Result, Session, bad, spec, textedit};
|
||||
|
||||
/// Apply a style change to the edited range, or to whole text layers in the selection.
|
||||
fn style(s: &mut Session, p: &Value, f: impl Fn(&mut TextStyle)) -> Result<Value> {
|
||||
if let Some((id, a, b)) = textedit::selected_range(s) {
|
||||
let ds = s.ds_mut()?;
|
||||
let before = ds.doc.clone();
|
||||
let doc = ds.doc_mut();
|
||||
if let NodeKind::Text(t) = &mut doc.get_mut(id)?.kind {
|
||||
if a == b {
|
||||
t.restyle(0, t.char_count(), &f);
|
||||
} else {
|
||||
t.restyle(a, b, &f);
|
||||
}
|
||||
}
|
||||
crate::normalize(doc, &before);
|
||||
return ok();
|
||||
}
|
||||
let ids = req_targets("text", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
for i in ids {
|
||||
for d in doc.subtree(i) {
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(d).map(|n| &mut n.kind) {
|
||||
let n = t.char_count();
|
||||
t.restyle(0, n, &f);
|
||||
}
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
fn para(s: &mut Session, p: &Value, f: impl Fn(&mut TextData)) -> Result<Value> {
|
||||
let ids = match textedit::selected_range(s) {
|
||||
Some((id, _, _)) => vec![id],
|
||||
None => req_targets("text", s, p)?,
|
||||
};
|
||||
let ds = s.ds_mut()?;
|
||||
let before = ds.doc.clone();
|
||||
let doc = ds.doc_mut();
|
||||
for i in ids {
|
||||
for d in doc.subtree(i) {
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(d).map(|n| &mut n.kind) {
|
||||
f(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::normalize(doc, &before);
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Whether every targeted char has `pred` (to decide toggle direction).
|
||||
fn all_have(s: &Session, p: &Value, pred: impl Fn(&TextStyle) -> bool) -> bool {
|
||||
let Ok(doc) = s.doc() else { return false };
|
||||
if let Some((id, a, b)) = textedit::selected_range(s) {
|
||||
if let Some(NodeKind::Text(t)) = doc.node(id).map(|n| &n.kind) {
|
||||
let (a, b) = if a == b { (0, t.char_count()) } else { (a, b) };
|
||||
return (a..b.max(a + 1)).all(|i| pred(t.style_at(i)));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
targets(s, p).iter().all(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Text(t)) if t.spans().iter().all(|(_, _, st)| pred(st))))
|
||||
}
|
||||
|
||||
fn toggle_bold(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let bold = all_have(s, p, |st| st.weight() >= 700);
|
||||
style(s, p, |st| {
|
||||
let italic = st.italic();
|
||||
let base = if bold { "Regular" } else { "Bold" };
|
||||
st.font_style = if italic { if base == "Regular" { "Italic".into() } else { format!("{base} Italic") } } else { base.into() };
|
||||
})
|
||||
}
|
||||
|
||||
fn toggle_italic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let italic = all_have(s, p, TextStyle::italic);
|
||||
style(s, p, |st| {
|
||||
let w = super::props::weight_name(st.weight());
|
||||
st.font_style = if italic { w.to_string() } else if w == "Regular" { "Italic".into() } else { format!("{w} Italic") };
|
||||
})
|
||||
}
|
||||
|
||||
fn step_weight(s: &mut Session, p: &Value, up: bool) -> Result<Value> {
|
||||
style(s, p, |st| {
|
||||
let w = st.weight() as i32 + if up { 100 } else { -100 };
|
||||
let w = w.clamp(100, 900) as u16;
|
||||
let name = super::props::weight_name(w);
|
||||
st.font_style = if st.italic() { if name == "Regular" { "Italic".into() } else { format!("{name} Italic") } } else { name.into() };
|
||||
})
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!("text.bold", "Bold", "{ids?}", true, toggle_bold),
|
||||
spec!("text.italic", "Italic", "{ids?}", true, toggle_italic),
|
||||
spec!("text.underline", "Underline", "{ids?}", true, |s, p| {
|
||||
let on = all_have(s, p, |st| st.decoration == TextDecoration::Underline);
|
||||
style(s, p, |st| st.decoration = if on { TextDecoration::None } else { TextDecoration::Underline })
|
||||
}),
|
||||
spec!("text.strikethrough", "Strikethrough", "{ids?}", true, |s, p| {
|
||||
let on = all_have(s, p, |st| st.decoration == TextDecoration::Strikethrough);
|
||||
style(s, p, |st| st.decoration = if on { TextDecoration::None } else { TextDecoration::Strikethrough })
|
||||
}),
|
||||
spec!("text.link", "Create link", "{url, ids?}", true, |s, p| {
|
||||
let url = req_string("text.link", p, "url")?;
|
||||
style(s, p, |st| st.link = Some(url.clone()))
|
||||
}),
|
||||
spec!("text.bulletList", "Bulleted list", "{ids?}", true, |s, p| para(s, p, |t| t.list = if t.list == ListStyle::Bulleted { ListStyle::None } else { ListStyle::Bulleted })),
|
||||
spec!("text.numberList", "Numbered list", "{ids?}", true, |s, p| para(s, p, |t| t.list = if t.list == ListStyle::Numbered { ListStyle::None } else { ListStyle::Numbered })),
|
||||
spec!("text.alignLeft", "Align left", "{ids?}", true, |s, p| para(s, p, |t| t.align_h = TextAlignH::Left)),
|
||||
spec!("text.alignCenter", "Align center", "{ids?}", true, |s, p| para(s, p, |t| t.align_h = TextAlignH::Center)),
|
||||
spec!("text.alignRight", "Align right", "{ids?}", true, |s, p| para(s, p, |t| t.align_h = TextAlignH::Right)),
|
||||
spec!("text.alignJustify", "Justify", "{ids?}", true, |s, p| para(s, p, |t| t.align_h = TextAlignH::Justified)),
|
||||
spec!("text.sizeUp", "Increase font size", "{ids?}", true, |s, p| style(s, p, |st| st.font_size = (st.font_size + 1.0).min(5000.0))),
|
||||
spec!("text.sizeDown", "Decrease font size", "{ids?}", true, |s, p| style(s, p, |st| st.font_size = (st.font_size - 1.0).max(1.0))),
|
||||
spec!("text.weightUp", "Increase font weight", "{ids?}", true, |s, p| step_weight(s, p, true)),
|
||||
spec!("text.weightDown", "Decrease font weight", "{ids?}", true, |s, p| step_weight(s, p, false)),
|
||||
spec!("text.lineHeightUp", "Increase line height", "{ids?}", true, |s, p| {
|
||||
style(s, p, |st| st.line_height = LineHeight::Pixels(st.line_height.resolve(st.font_size, 1.21).round() + 1.0))
|
||||
}),
|
||||
spec!("text.lineHeightDown", "Decrease line height", "{ids?}", true, |s, p| {
|
||||
style(s, p, |st| st.line_height = LineHeight::Pixels((st.line_height.resolve(st.font_size, 1.21).round() - 1.0).max(0.0)))
|
||||
}),
|
||||
spec!("text.trackingUp", "Increase letter spacing", "{ids?}", true, |s, p| {
|
||||
style(s, p, |st| st.letter_spacing = LetterSpacing::Percent(match st.letter_spacing {
|
||||
LetterSpacing::Percent(v) => v + 1.0,
|
||||
LetterSpacing::Pixels(v) => v / st.font_size.max(1.0) * 100.0 + 1.0,
|
||||
}))
|
||||
}),
|
||||
spec!("text.trackingDown", "Decrease letter spacing", "{ids?}", true, |s, p| {
|
||||
style(s, p, |st| st.letter_spacing = LetterSpacing::Percent(match st.letter_spacing {
|
||||
LetterSpacing::Percent(v) => v - 1.0,
|
||||
LetterSpacing::Pixels(v) => v / st.font_size.max(1.0) * 100.0 - 1.0,
|
||||
}))
|
||||
}),
|
||||
spec!("text.caseOriginal", "Original case", "{ids?}", true, |s, p| style(s, p, |st| st.case = TextCase::Original)),
|
||||
spec!("text.caseUpper", "Uppercase", "{ids?}", true, |s, p| style(s, p, |st| st.case = TextCase::Upper)),
|
||||
spec!("text.caseLower", "Lowercase", "{ids?}", true, |s, p| style(s, p, |st| st.case = TextCase::Lower)),
|
||||
spec!("text.caseTitle", "Title case", "{ids?}", true, |s, p| style(s, p, |st| st.case = TextCase::Title)),
|
||||
spec!("text.set", "Set text", "{id?, characters}", true, |s, p| {
|
||||
let c = string(p, "characters").or_else(|| string(p, "text")).ok_or_else(|| bad("text.set", "`characters` required"))?;
|
||||
let ids = req_targets("text.set", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
for i in ids {
|
||||
let mut m = Map::new();
|
||||
m.insert("characters".into(), json!(c));
|
||||
super::props::apply("text.set", doc, i, &m)?;
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("text.style", "Style text range", "{id?, start?, end?, fontFamily?, fontStyle?, fontWeight?, fontSize?, lineHeight?, letterSpacing?, textDecoration?, textCase?, hyperlink?, textFills?}", true, |s, p| {
|
||||
let ids = req_targets("text.style", s, p)?;
|
||||
let m = p.as_object().cloned().unwrap_or_default();
|
||||
let doc = s.doc_mut()?;
|
||||
for i in ids {
|
||||
let n = doc.get_mut(i)?;
|
||||
let NodeKind::Text(t) = &mut n.kind else { return Err(bad("text.style", format!("{i} is not text"))) };
|
||||
let len = t.char_count();
|
||||
let a = num(p, "start").map(|v| v.max(0.0) as usize).unwrap_or(0).min(len);
|
||||
let b = num(p, "end").map(|v| v.max(0.0) as usize).unwrap_or(len).min(len);
|
||||
for (k, v) in &m {
|
||||
if matches!(k.as_str(), "id" | "ids" | "start" | "end") {
|
||||
continue;
|
||||
}
|
||||
super::props::apply_text_style("text.style", t, a, b, k, v)?;
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("text.edit.begin", "Edit text", "{id, caret? (char index; default select all)}", false, |s, p| {
|
||||
let id = req_id("text.edit.begin", p, "id")?;
|
||||
let caret = num(p, "caret").map(|c| c.max(0.0) as usize);
|
||||
textedit::begin(s, id, caret, false)?;
|
||||
ok()
|
||||
}),
|
||||
spec!("text.edit.insert", "Type text", "{text}", true, |s, p| {
|
||||
let t = string(p, "text").unwrap_or_default();
|
||||
textedit::insert(s, &t)?;
|
||||
ok()
|
||||
}),
|
||||
spec!("text.edit.key", "Text key", "{key: Backspace|Delete|Left|Right|Up|Down|Home|End|Enter|Escape|SelectAll, shift?, alt?, cmd?}", true, |s, p| {
|
||||
let k = req_string("text.edit.key", p, "key")?;
|
||||
textedit::key(s, &k, boolean(p, "shift").unwrap_or(false), boolean(p, "alt").unwrap_or(false), boolean(p, "cmd").unwrap_or(false))?;
|
||||
ok()
|
||||
}),
|
||||
spec!("text.edit.caret", "Set caret", "{index, extend?}", false, |s, p| {
|
||||
let i = req_num("text.edit.caret", p, "index")?.max(0.0) as usize;
|
||||
textedit::set_caret(s, i, boolean(p, "extend").unwrap_or(false))?;
|
||||
ok()
|
||||
}),
|
||||
spec!("text.edit.end", "Stop editing text", "{}", false, |s, _| {
|
||||
textedit::end(s)?;
|
||||
ok()
|
||||
}),
|
||||
spec!("text.fonts", "List fonts", "{family? (list its styles)}", false, |_, p| {
|
||||
Ok(match string(p, "family") {
|
||||
Some(f) => json!(figcraft_text::family_styles(&f)),
|
||||
None => json!(figcraft_text::family_names()),
|
||||
})
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
//! View menu, preferences and tools. View state is per document and drivable like everything else.
|
||||
|
||||
use figcraft_doc::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::tools::ToolId;
|
||||
use crate::{Result, Session, ViewState, bad, spec};
|
||||
|
||||
fn view(s: &mut Session) -> Result<&mut ViewState> {
|
||||
Ok(&mut s.ds_mut()?.view)
|
||||
}
|
||||
|
||||
fn flag(s: &mut Session, p: &Value, f: fn(&mut ViewState) -> &mut bool) -> Result<Value> {
|
||||
let v = view(s)?;
|
||||
let slot = f(v);
|
||||
*slot = boolean(p, "on").unwrap_or(!*slot);
|
||||
Ok(json!({"on": *slot}))
|
||||
}
|
||||
|
||||
fn zoom_to(s: &mut Session, z: f64) -> Result<Value> {
|
||||
let v = view(s)?;
|
||||
let c = (v.cx, v.cy);
|
||||
v.set_zoom_at(z, c);
|
||||
Ok(json!({"zoom": v.zoom}))
|
||||
}
|
||||
|
||||
/// Figma's zoom steps.
|
||||
const STEPS: &[f64] = &[0.02, 0.03, 0.06, 0.13, 0.25, 0.5, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 128.0, 256.0];
|
||||
|
||||
fn step(z: f64, up: bool) -> f64 {
|
||||
if up { STEPS.iter().copied().find(|s| *s > z * 1.001).unwrap_or(256.0) } else { STEPS.iter().rev().copied().find(|s| *s < z * 0.999).unwrap_or(0.02) }
|
||||
}
|
||||
|
||||
fn zoom_frame(s: &mut Session, dir: i64) -> Result<Value> {
|
||||
let ds = s.ds()?;
|
||||
let frames = ds.doc.top_frames(ds.page);
|
||||
if frames.is_empty() {
|
||||
return ok();
|
||||
}
|
||||
let cur = ds.selection.first().and_then(|i| ds.doc.top_level_frame(*i)).and_then(|f| frames.iter().position(|x| *x == f));
|
||||
let n = frames.len() as i64;
|
||||
let next = match cur {
|
||||
Some(c) => (c as i64 + dir).rem_euclid(n) as usize,
|
||||
None => 0,
|
||||
};
|
||||
let Some(f) = frames.get(next).copied() else { return ok() };
|
||||
let b = ds.doc.world_bounds(f).unwrap_or_default();
|
||||
let ds = s.ds_mut()?;
|
||||
ds.selection = vec![f];
|
||||
ds.view.fit(b, 256.0);
|
||||
Ok(json!({"frame": f}))
|
||||
}
|
||||
|
||||
fn page_step(s: &mut Session, dir: i64) -> Result<Value> {
|
||||
let ds = s.ds()?;
|
||||
let pages = &ds.doc.pages;
|
||||
let i = pages.iter().position(|p| *p == ds.page).unwrap_or(0) as i64;
|
||||
let n = pages.len() as i64;
|
||||
let next = (i + dir).clamp(0, n - 1) as usize;
|
||||
let Some(pg) = pages.get(next).copied() else { return ok() };
|
||||
let ds = s.ds_mut()?;
|
||||
ds.page = pg;
|
||||
ds.selection.clear();
|
||||
ds.zoom_to_fit();
|
||||
Ok(json!({"page": pg}))
|
||||
}
|
||||
|
||||
fn ui(s: &mut Session, kind: &str) -> Result<Value> {
|
||||
s.request_ui(kind, json!({}));
|
||||
ok()
|
||||
}
|
||||
|
||||
fn pref(s: &mut Session, p: &Value, f: fn(&mut crate::Prefs) -> &mut bool) -> Result<Value> {
|
||||
let slot = f(&mut s.prefs);
|
||||
*slot = boolean(p, "on").unwrap_or(!*slot);
|
||||
Ok(json!({"on": *slot}))
|
||||
}
|
||||
|
||||
fn tool(s: &mut Session, t: ToolId) -> Result<Value> {
|
||||
s.set_tool(t);
|
||||
Ok(json!({"tool": t.id()}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!("view.zoomIn", "Zoom in", "{}", false, |s, _| {
|
||||
let z = view(s)?.zoom;
|
||||
zoom_to(s, step(z, true))
|
||||
}),
|
||||
spec!("view.zoomOut", "Zoom out", "{}", false, |s, _| {
|
||||
let z = view(s)?.zoom;
|
||||
zoom_to(s, step(z, false))
|
||||
}),
|
||||
spec!("view.zoom100", "Zoom to 100%", "{}", false, |s, _| zoom_to(s, 1.0)),
|
||||
spec!("view.zoom50", "Zoom to 50%", "{}", false, |s, _| zoom_to(s, 0.5)),
|
||||
spec!("view.zoom200", "Zoom to 200%", "{}", false, |s, _| zoom_to(s, 2.0)),
|
||||
spec!("view.zoomFit", "Zoom to fit", "{}", false, |s, _| {
|
||||
let ds = s.ds_mut()?;
|
||||
if let Some(b) = ds.doc.page_content_bounds(ds.page) {
|
||||
ds.view.fit(b, 256.0);
|
||||
}
|
||||
Ok(json!({"zoom": ds.view.zoom}))
|
||||
}),
|
||||
spec!("view.zoomSelection", "Zoom to selection", "{}", false, |s, _| {
|
||||
let ds = s.ds_mut()?;
|
||||
if let Some(b) = ds.doc.bounds_of(&ds.selection) {
|
||||
ds.view.fit(b, 256.0);
|
||||
}
|
||||
Ok(json!({"zoom": ds.view.zoom}))
|
||||
}),
|
||||
spec!("view.set", "Set view", "{zoom?, cx?, cy? (page coordinate at the centre), vw?, vh? (viewport size)}", false, |s, p| {
|
||||
let v = view(s)?;
|
||||
if let Some(z) = num(p, "zoom") {
|
||||
v.zoom = z.clamp(ViewState::MIN_ZOOM, ViewState::MAX_ZOOM);
|
||||
}
|
||||
v.cx = num_or(p, "cx", v.cx);
|
||||
v.cy = num_or(p, "cy", v.cy);
|
||||
if let Some(w) = num(p, "vw") {
|
||||
v.vw = w.clamp(10.0, 20000.0);
|
||||
}
|
||||
if let Some(h) = num(p, "vh") {
|
||||
v.vh = h.clamp(10.0, 20000.0);
|
||||
}
|
||||
Ok(serde_json::to_value(&*v).unwrap_or(Value::Null))
|
||||
}),
|
||||
spec!("view.get", "Get view", "{}", false, |s, _| Ok(serde_json::to_value(&s.ds()?.view).unwrap_or(Value::Null))),
|
||||
spec!("view.prevFrame", "Zoom to previous frame", "{}", false, |s, _| zoom_frame(s, -1)),
|
||||
spec!("view.nextFrame", "Zoom to next frame", "{}", false, |s, _| zoom_frame(s, 1)),
|
||||
spec!("view.prevPage", "Previous page", "{}", false, |s, _| page_step(s, -1)),
|
||||
spec!("view.nextPage", "Next page", "{}", false, |s, _| page_step(s, 1)),
|
||||
spec!("view.pixelGrid", "Pixel grid", "{on?}", false, |s, p| flag(s, p, |v| &mut v.pixel_grid)),
|
||||
spec!("view.layoutGrids", "Layout grids", "{on?}", false, |s, p| flag(s, p, |v| &mut v.layout_grids)),
|
||||
spec!("view.rulers", "Rulers", "{on?}", false, |s, p| flag(s, p, |v| &mut v.rulers)),
|
||||
spec!("view.slices", "Slices", "{on?}", false, |s, p| flag(s, p, |v| &mut v.show_slices)),
|
||||
spec!("view.comments", "Comments", "{on?}", false, |s, p| flag(s, p, |v| &mut v.comments)),
|
||||
spec!("view.outlines", "Outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.outlines)),
|
||||
spec!("view.pixelPreview", "Pixel preview", "{on?}", false, |s, p| flag(s, p, |v| &mut v.pixel_preview)),
|
||||
spec!("view.frameOutlines", "Frame outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.frame_outlines)),
|
||||
spec!("view.snapGeometry", "Snap to geometry", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_geometry)),
|
||||
spec!("view.snapObjects", "Snap to objects", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_objects)),
|
||||
spec!("view.snapPixel", "Snap to pixel grid", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_pixel)),
|
||||
spec!("view.toggleUi", "Show/Hide UI", "{on?}", false, |s, p| flag(s, p, |v| &mut v.ui_hidden)),
|
||||
spec!("view.layers", "Layers panel", "{}", false, |s, _| ui(s, "panel.layers")),
|
||||
spec!("view.assets", "Assets panel", "{}", false, |s, _| ui(s, "panel.assets")),
|
||||
spec!("view.design", "Design panel", "{}", false, |s, _| ui(s, "panel.design")),
|
||||
spec!("view.prototype", "Prototype panel", "{}", false, |s, _| ui(s, "panel.prototype")),
|
||||
spec!("view.themeLight", "Light theme", "{}", false, |s, _| {
|
||||
s.prefs.theme = "light".into();
|
||||
ok()
|
||||
}),
|
||||
spec!("view.themeDark", "Dark theme", "{}", false, |s, _| {
|
||||
s.prefs.theme = "dark".into();
|
||||
ok()
|
||||
}),
|
||||
spec!("view.themeSystem", "Follow system theme", "{}", false, |s, _| {
|
||||
s.prefs.theme = "system".into();
|
||||
ok()
|
||||
}),
|
||||
spec!("pref.keepTool", "Keep tool selected after use", "{on?}", false, |s, p| pref(s, p, |x| &mut x.keep_tool)),
|
||||
spec!("pref.highlightHover", "Highlight layers on hover", "{on?}", false, |s, p| pref(s, p, |x| &mut x.highlight_on_hover)),
|
||||
spec!("pref.renameDuplicates", "Rename duplicated layers", "{on?}", false, |s, p| pref(s, p, |x| &mut x.rename_duplicates)),
|
||||
spec!("pref.showDimensions", "Show dimensions on objects", "{on?}", false, |s, p| pref(s, p, |x| &mut x.show_dimensions)),
|
||||
spec!("pref.numberKeysOpacity", "Use number keys for opacity", "{on?}", false, |s, p| pref(s, p, |x| &mut x.number_keys_opacity)),
|
||||
spec!("pref.nudge", "Nudge amount", "{small?, big?}", false, |s, p| {
|
||||
s.prefs.nudge_small = num(p, "small").unwrap_or(s.prefs.nudge_small).clamp(0.01, 1000.0);
|
||||
s.prefs.nudge_big = num(p, "big").unwrap_or(s.prefs.nudge_big).clamp(0.01, 10000.0);
|
||||
Ok(json!({"small": s.prefs.nudge_small, "big": s.prefs.nudge_big}))
|
||||
}),
|
||||
spec!("app.preferences", "Preferences", "{}", false, |s, _| ui(s, "dialog.preferences")),
|
||||
spec!("app.actions", "Actions", "{}", false, |s, _| ui(s, "palette.actions")),
|
||||
spec!("help.shortcuts", "Keyboard shortcuts", "{}", false, |s, _| ui(s, "panel.shortcuts")),
|
||||
spec!("help.about", "About FigCraft", "{}", false, |s, _| ui(s, "dialog.about")),
|
||||
spec!("help.page", "Help page", "{}", false, |s, _| ui(s, "open.help")),
|
||||
spec!("libraries.open", "Libraries", "{}", false, |s, _| ui(s, "dialog.libraries")),
|
||||
spec!("tool.set", "Choose tool", "{tool: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment}", false, |s, p| {
|
||||
let t = req_string("tool.set", p, "tool")?;
|
||||
let t = ToolId::from_id(&t).ok_or_else(|| bad("tool.set", format!("unknown tool `{t}`")))?;
|
||||
tool(s, t)
|
||||
}),
|
||||
spec!("tool.move", "Move", "{}", false, |s, _| tool(s, ToolId::Move)),
|
||||
spec!("tool.scale", "Scale", "{}", false, |s, _| tool(s, ToolId::Scale)),
|
||||
spec!("tool.hand", "Hand", "{}", false, |s, _| tool(s, ToolId::Hand)),
|
||||
spec!("tool.frame", "Frame", "{}", false, |s, _| tool(s, ToolId::Frame)),
|
||||
spec!("tool.section", "Section", "{}", false, |s, _| tool(s, ToolId::Section)),
|
||||
spec!("tool.slice", "Slice", "{}", false, |s, _| tool(s, ToolId::Slice)),
|
||||
spec!("tool.rectangle", "Rectangle", "{}", false, |s, _| tool(s, ToolId::Rectangle)),
|
||||
spec!("tool.line", "Line", "{}", false, |s, _| tool(s, ToolId::Line)),
|
||||
spec!("tool.arrow", "Arrow", "{}", false, |s, _| tool(s, ToolId::Arrow)),
|
||||
spec!("tool.ellipse", "Ellipse", "{}", false, |s, _| tool(s, ToolId::Ellipse)),
|
||||
spec!("tool.polygon", "Polygon", "{}", false, |s, _| tool(s, ToolId::Polygon)),
|
||||
spec!("tool.star", "Star", "{}", false, |s, _| tool(s, ToolId::Star)),
|
||||
spec!("tool.pen", "Pen", "{}", false, |s, _| tool(s, ToolId::Pen)),
|
||||
spec!("tool.pencil", "Pencil", "{}", false, |s, _| tool(s, ToolId::Pencil)),
|
||||
spec!("tool.text", "Text", "{}", false, |s, _| tool(s, ToolId::Text)),
|
||||
spec!("tool.comment", "Comment", "{}", false, |s, _| tool(s, ToolId::Comment)),
|
||||
spec!("edit.pickColor", "Pick color", "{}", false, |s, _| tool(s, ToolId::Eyedropper)),
|
||||
]
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn unused(_: NodeId) {}
|
||||
|
|
@ -0,0 +1,453 @@
|
|||
//! Components and instances.
|
||||
//!
|
||||
//! An instance's layers are real nodes copied from its main component; each copy records the
|
||||
//! main-component node it mirrors (`source`) and which property groups were changed locally
|
||||
//! (`overrides`). When a main component changes, every instance is rebuilt from it, keeping layer
|
||||
//! ids and re-applying overrides. Variants are components inside a component set, named
|
||||
//! `Property=Value, …`; component properties (boolean, text, instance swap, variant) are applied
|
||||
//! through `prop_refs` on the layers they control.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_geom::Affine;
|
||||
use serde_json::Value;
|
||||
|
||||
/// The component a node belongs to (itself, or the nearest component ancestor).
|
||||
pub fn owning_component(doc: &Document, id: NodeId) -> Option<NodeId> {
|
||||
let mut chain = vec![id];
|
||||
chain.extend(doc.ancestors(id));
|
||||
chain.into_iter().find(|c| doc.node(*c).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. })))
|
||||
}
|
||||
|
||||
/// The instance root containing `id` (excluding `id` itself unless it is an instance).
|
||||
pub fn owning_instance(doc: &Document, id: NodeId) -> Option<NodeId> {
|
||||
let mut chain = vec![id];
|
||||
chain.extend(doc.ancestors(id));
|
||||
chain.into_iter().find(|c| doc.node(*c).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. })))
|
||||
}
|
||||
|
||||
/// All instances of `component` in the document.
|
||||
pub fn instances_of(doc: &Document, component: NodeId) -> Vec<NodeId> {
|
||||
doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { component: c, .. } if c == component)).map(|n| n.id).collect()
|
||||
}
|
||||
|
||||
/// Create an instance of `component` under `parent` at `(x, y)` (parent space).
|
||||
pub fn create_instance(doc: &mut Document, component: NodeId, parent: NodeId, x: f64, y: f64) -> Result<NodeId, DocError> {
|
||||
let comp = doc.get(component)?.clone();
|
||||
if !matches!(comp.kind, NodeKind::Component { .. }) {
|
||||
return Err(DocError::Invalid(format!("{component} is not a component")));
|
||||
}
|
||||
let id = doc.alloc_id();
|
||||
let mut root = comp.clone();
|
||||
root.id = id;
|
||||
root.kind = NodeKind::Instance { component, props: default_props(&comp) };
|
||||
root.children.clear();
|
||||
root.parent = None;
|
||||
root.source = Some(component);
|
||||
root.overrides.clear();
|
||||
root.transform = Affine::translate((x, y));
|
||||
root.prop_refs.clear();
|
||||
doc.insert(root, parent, None)?;
|
||||
sync_instance(doc, id);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn default_props(comp: &Node) -> BTreeMap<String, Value> {
|
||||
let mut out = BTreeMap::new();
|
||||
if let NodeKind::Component { props, .. } = &comp.kind {
|
||||
for (k, d) in props {
|
||||
let v = match d {
|
||||
PropDef::Boolean { default } => Value::Bool(*default),
|
||||
PropDef::Text { default } => Value::String(default.clone()),
|
||||
PropDef::InstanceSwap { default } => Value::from(default.0),
|
||||
PropDef::Variant { default, .. } => Value::String(default.clone()),
|
||||
};
|
||||
out.insert(k.clone(), v);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Copy the overridden property groups of `from` onto `to`.
|
||||
fn carry_overrides(from: &Node, to: &mut Node) {
|
||||
for g in &from.overrides {
|
||||
match g.as_str() {
|
||||
"fills" => to.fills = from.fills.clone(),
|
||||
"strokes" => {
|
||||
to.strokes = from.strokes.clone();
|
||||
to.stroke = from.stroke.clone();
|
||||
}
|
||||
"effects" => to.effects = from.effects.clone(),
|
||||
"opacity" => {
|
||||
to.opacity = from.opacity;
|
||||
to.blend = from.blend;
|
||||
}
|
||||
"visible" => to.visible = from.visible,
|
||||
"name" => to.name = from.name.clone(),
|
||||
"corners" => to.corners = from.corners,
|
||||
"characters" => {
|
||||
if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) {
|
||||
b.characters = a.characters.clone();
|
||||
b.runs = a.runs.clone();
|
||||
}
|
||||
}
|
||||
"textStyle" => {
|
||||
if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) {
|
||||
b.style = a.style.clone();
|
||||
b.runs = a.runs.clone();
|
||||
b.align_h = a.align_h;
|
||||
b.align_v = a.align_v;
|
||||
}
|
||||
}
|
||||
"size" => {
|
||||
to.width = from.width;
|
||||
to.height = from.height;
|
||||
to.child_layout = from.child_layout.clone();
|
||||
}
|
||||
"component" => {
|
||||
if let (NodeKind::Instance { component: a, props: pa }, NodeKind::Instance { component: b, props: pb }) = (&from.kind, &mut to.kind) {
|
||||
*b = *a;
|
||||
*pb = pa.clone();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
to.overrides = from.overrides.clone();
|
||||
}
|
||||
|
||||
/// Name path from the instance root (for matching layers across variants).
|
||||
fn name_path(doc: &Document, root: NodeId, id: NodeId) -> String {
|
||||
let mut parts = Vec::new();
|
||||
let mut cur = Some(id);
|
||||
while let Some(c) = cur {
|
||||
if c == root {
|
||||
break;
|
||||
}
|
||||
let Some(n) = doc.node(c) else { break };
|
||||
parts.push(n.name.clone());
|
||||
cur = n.parent;
|
||||
if parts.len() > 256 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
parts.reverse();
|
||||
parts.join("/")
|
||||
}
|
||||
|
||||
/// Rebuild instance `inst` from its main component, keeping ids and overrides.
|
||||
pub fn sync_instance(doc: &mut Document, inst: NodeId) {
|
||||
sync_instance_depth(doc, inst, 0);
|
||||
}
|
||||
|
||||
fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize) {
|
||||
if depth > 16 {
|
||||
return;
|
||||
}
|
||||
let Some(root) = doc.node(inst).cloned() else { return };
|
||||
let NodeKind::Instance { component, props } = &root.kind else { return };
|
||||
let (component, props) = (*component, props.clone());
|
||||
let Some(comp) = doc.node(component).cloned() else { return };
|
||||
if doc.is_ancestor(inst, component) {
|
||||
// An instance inside its own main component would recurse forever.
|
||||
return;
|
||||
}
|
||||
// Old layers by source and by name path.
|
||||
let old_ids: Vec<NodeId> = doc.subtree(inst).into_iter().filter(|i| *i != inst).collect();
|
||||
let mut by_source: BTreeMap<NodeId, Node> = BTreeMap::new();
|
||||
let mut by_path: BTreeMap<String, Node> = BTreeMap::new();
|
||||
for i in &old_ids {
|
||||
if let Some(n) = doc.node(*i) {
|
||||
if let Some(s) = n.source {
|
||||
by_source.insert(s, n.clone());
|
||||
}
|
||||
by_path.insert(name_path(doc, inst, *i), n.clone());
|
||||
}
|
||||
}
|
||||
// Remove the old layers.
|
||||
let kids = doc.node(inst).map(|n| n.children.clone()).unwrap_or_default();
|
||||
for c in kids {
|
||||
let _ = doc.remove(c);
|
||||
}
|
||||
// Copy the component's layers.
|
||||
let src: Vec<NodeId> = doc.subtree(component).into_iter().filter(|i| *i != component).collect();
|
||||
let mut map: BTreeMap<NodeId, NodeId> = BTreeMap::new();
|
||||
let mut used: BTreeSet<NodeId> = BTreeSet::new();
|
||||
for s in &src {
|
||||
let path = name_path(doc, component, *s);
|
||||
let old = by_source.get(s).or_else(|| by_path.get(&path));
|
||||
let id = match old {
|
||||
Some(o) if !doc.contains(o.id) && used.insert(o.id) => o.id,
|
||||
_ => doc.alloc_id(),
|
||||
};
|
||||
map.insert(*s, id);
|
||||
}
|
||||
map.insert(component, inst);
|
||||
let mut new_nodes = Vec::new();
|
||||
for s in &src {
|
||||
let Some(orig) = doc.node(*s).cloned() else { continue };
|
||||
let Some(&nid) = map.get(s) else { continue };
|
||||
let mut n = orig.clone();
|
||||
n.id = nid;
|
||||
n.parent = orig.parent.and_then(|p| map.get(&p).copied());
|
||||
n.children = orig.children.iter().filter_map(|c| map.get(c).copied()).collect();
|
||||
// Nested instance copies keep their own main; layers mirror the main's layer.
|
||||
n.source = Some(orig.source.filter(|_| false).unwrap_or(*s));
|
||||
n.overrides.clear();
|
||||
let path = name_path(doc, component, *s);
|
||||
if let Some(old) = by_source.get(s).or_else(|| by_path.get(&path)) {
|
||||
carry_overrides(old, &mut n);
|
||||
}
|
||||
// Component properties.
|
||||
for (field, prop) in &orig.prop_refs {
|
||||
let Some(val) = props.get(prop) else { continue };
|
||||
match field.as_str() {
|
||||
"visible" => {
|
||||
if let Some(b) = val.as_bool() {
|
||||
n.visible = b;
|
||||
}
|
||||
}
|
||||
"characters" => {
|
||||
if let (NodeKind::Text(t), Some(sv)) = (&mut n.kind, val.as_str())
|
||||
&& !n.overrides.contains("characters")
|
||||
{
|
||||
let len = t.char_count();
|
||||
t.replace_range(0, len, sv);
|
||||
}
|
||||
}
|
||||
"mainComponent" => {
|
||||
if let (NodeKind::Instance { component: c, .. }, Some(v)) = (&mut n.kind, crate::params::id_of(val)) {
|
||||
*c = v;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
new_nodes.push(n);
|
||||
}
|
||||
for n in new_nodes {
|
||||
let id = n.id;
|
||||
let parent = n.parent;
|
||||
let _ = doc_insert_raw(doc, n);
|
||||
if parent == Some(inst)
|
||||
&& let Some(r) = doc.node_mut(inst)
|
||||
&& !r.children.contains(&id)
|
||||
{
|
||||
r.children.push(id);
|
||||
}
|
||||
}
|
||||
// Keep the main's child order.
|
||||
let order: Vec<NodeId> = comp.children.iter().filter_map(|c| map.get(c).copied()).collect();
|
||||
if let Some(r) = doc.node_mut(inst) {
|
||||
r.children = order;
|
||||
}
|
||||
// Root properties follow the main unless overridden.
|
||||
if let Some(r) = doc.node_mut(inst) {
|
||||
let mut fresh = comp.clone();
|
||||
fresh.id = inst;
|
||||
fresh.parent = r.parent;
|
||||
fresh.children = r.children.clone();
|
||||
fresh.transform = r.transform;
|
||||
fresh.kind = r.kind.clone();
|
||||
fresh.source = Some(component);
|
||||
fresh.constraints = r.constraints;
|
||||
fresh.child_layout = r.child_layout.clone();
|
||||
fresh.interactions = r.interactions.clone();
|
||||
fresh.exports = r.exports.clone();
|
||||
fresh.prop_refs.clear();
|
||||
fresh.locked = r.locked;
|
||||
fresh.visible = r.visible;
|
||||
fresh.name = r.name.clone();
|
||||
fresh.var_modes = r.var_modes.clone();
|
||||
// Instances keep their own size (resizing an instance is allowed).
|
||||
fresh.width = r.width;
|
||||
fresh.height = r.height;
|
||||
carry_overrides(&r.clone(), &mut fresh);
|
||||
*r = fresh;
|
||||
}
|
||||
// Instance size differs from main: children follow constraints.
|
||||
let (cw, ch) = (comp.width, comp.height);
|
||||
let (iw, ih) = doc.node(inst).map(|n| (n.width, n.height)).unwrap_or((cw, ch));
|
||||
if ((iw - cw).abs() > 1e-9 || (ih - ch).abs() > 1e-9) && doc.node(inst).is_some_and(|n| n.layout.is_none()) {
|
||||
figcraft_layout::apply_constraints(doc, inst, cw, ch);
|
||||
}
|
||||
// Nested instances inside the copy.
|
||||
let nested: Vec<NodeId> = doc.subtree(inst).into_iter().filter(|i| *i != inst && doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. }))).collect();
|
||||
for n in nested {
|
||||
// Only re-sync nested instances whose main was swapped by a property or override.
|
||||
let needs = doc.node(n).is_some_and(|nn| nn.overrides.contains("component") || !nn.prop_refs.is_empty());
|
||||
if needs {
|
||||
sync_instance_depth(doc, n, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a node whose parent/children links are already set (used when rebuilding instances).
|
||||
fn doc_insert_raw(doc: &mut Document, n: Node) -> Result<(), DocError> {
|
||||
let parent = n.parent.ok_or_else(|| DocError::Invalid("no parent".into()))?;
|
||||
let id = n.id;
|
||||
let mut n = n;
|
||||
let kids = std::mem::take(&mut n.children);
|
||||
doc.insert(n, parent, None)?;
|
||||
// `insert` appended us to the parent; fix sibling order later. Restore children list.
|
||||
if let Some(m) = doc.node_mut(id) {
|
||||
m.children = kids;
|
||||
}
|
||||
// Parent may have listed us already (inserted twice): dedupe.
|
||||
if let Some(p) = doc.node_mut(parent) {
|
||||
let mut seen = BTreeSet::new();
|
||||
p.children.retain(|c| seen.insert(*c));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Property groups that differ between two versions of a node.
|
||||
fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> {
|
||||
let mut out = Vec::new();
|
||||
if a.fills != b.fills {
|
||||
out.push("fills");
|
||||
}
|
||||
if a.strokes != b.strokes || a.stroke != b.stroke {
|
||||
out.push("strokes");
|
||||
}
|
||||
if a.effects != b.effects {
|
||||
out.push("effects");
|
||||
}
|
||||
if a.opacity != b.opacity || a.blend != b.blend {
|
||||
out.push("opacity");
|
||||
}
|
||||
if a.visible != b.visible {
|
||||
out.push("visible");
|
||||
}
|
||||
if a.name != b.name {
|
||||
out.push("name");
|
||||
}
|
||||
if a.corners != b.corners {
|
||||
out.push("corners");
|
||||
}
|
||||
if let (NodeKind::Text(x), NodeKind::Text(y)) = (&a.kind, &b.kind) {
|
||||
if x.characters != y.characters {
|
||||
out.push("characters");
|
||||
}
|
||||
if x.style != y.style || x.runs != y.runs {
|
||||
out.push("textStyle");
|
||||
}
|
||||
}
|
||||
if let (NodeKind::Instance { component: x, .. }, NodeKind::Instance { component: y, .. }) = (&a.kind, &b.kind)
|
||||
&& x != y
|
||||
{
|
||||
out.push("component");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// After an edit: record overrides inside instances, then rebuild instances of edited components.
|
||||
pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId]) {
|
||||
// 1. Overrides for layers edited inside instances.
|
||||
for id in changed {
|
||||
let Some(n) = doc.node(*id) else { continue };
|
||||
if n.source.is_none() || matches!(n.kind, NodeKind::Instance { .. }) && owning_instance(doc, *id) == Some(*id) && n.parent.is_some_and(|p| owning_instance(doc, p).is_none()) {
|
||||
// Root instances: switching the main is not an override; other edits are.
|
||||
if let (Some(b), Some(n)) = (before.node(*id), doc.node(*id))
|
||||
&& matches!(n.kind, NodeKind::Instance { .. })
|
||||
{
|
||||
let groups: Vec<&str> = diff_groups(b, n).into_iter().filter(|g| *g != "component").collect();
|
||||
if !groups.is_empty()
|
||||
&& let Some(m) = doc.node_mut(*id)
|
||||
{
|
||||
for g in groups {
|
||||
m.overrides.insert(g.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(b) = before.node(*id) {
|
||||
let groups = diff_groups(b, n);
|
||||
if !groups.is_empty()
|
||||
&& let Some(m) = doc.node_mut(*id)
|
||||
{
|
||||
for g in groups {
|
||||
m.overrides.insert(g.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2. Components whose layers changed (or lost layers).
|
||||
let mut comps: BTreeSet<NodeId> = BTreeSet::new();
|
||||
for id in changed {
|
||||
// Edits inside an instance aren't edits of the main.
|
||||
if let Some(c) = owning_component(doc, *id)
|
||||
&& owning_instance(doc, *id).is_none_or(|i| !doc.is_ancestor(c, i))
|
||||
{
|
||||
comps.insert(c);
|
||||
}
|
||||
}
|
||||
for id in before.ids() {
|
||||
if !doc.contains(id)
|
||||
&& let Some(c) = owning_component(before, id)
|
||||
&& doc.contains(c)
|
||||
{
|
||||
comps.insert(c);
|
||||
}
|
||||
}
|
||||
// Instance main swapped or props changed: re-sync those instances.
|
||||
let mut direct: BTreeSet<NodeId> = BTreeSet::new();
|
||||
for id in changed {
|
||||
if let (Some(n), Some(b)) = (doc.node(*id), before.node(*id))
|
||||
&& let (NodeKind::Instance { component: c1, props: p1 }, NodeKind::Instance { component: c2, props: p2 }) = (&n.kind, &b.kind)
|
||||
&& (c1 != c2 || p1 != p2)
|
||||
{
|
||||
direct.insert(*id);
|
||||
}
|
||||
}
|
||||
let mut done: BTreeSet<NodeId> = BTreeSet::new();
|
||||
let mut frontier: Vec<NodeId> = comps.into_iter().collect();
|
||||
let mut rounds = 0;
|
||||
while !frontier.is_empty() && rounds < 8 {
|
||||
rounds += 1;
|
||||
let mut next = Vec::new();
|
||||
for c in frontier {
|
||||
if !done.insert(c) {
|
||||
continue;
|
||||
}
|
||||
for inst in instances_of(doc, c) {
|
||||
sync_instance(doc, inst);
|
||||
// The instance may live inside another component: that one changed too.
|
||||
if let Some(outer) = owning_component(doc, inst)
|
||||
&& !done.contains(&outer)
|
||||
{
|
||||
next.push(outer);
|
||||
}
|
||||
}
|
||||
}
|
||||
frontier = next;
|
||||
}
|
||||
for i in direct {
|
||||
if doc.contains(i) {
|
||||
sync_instance(doc, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a variant name "Size=Large, State=Hover" into pairs.
|
||||
pub fn variant_props(name: &str) -> BTreeMap<String, String> {
|
||||
name.split(',')
|
||||
.filter_map(|kv| {
|
||||
let (k, v) = kv.split_once('=')?;
|
||||
Some((k.trim().to_string(), v.trim().to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The variant in `set` matching `wanted` best (all given properties equal).
|
||||
pub fn find_variant(doc: &Document, set: NodeId, wanted: &BTreeMap<String, String>) -> Option<NodeId> {
|
||||
let s = doc.node(set)?;
|
||||
s.children.iter().copied().find(|c| {
|
||||
doc.node(*c).is_some_and(|n| {
|
||||
let p = variant_props(&n.name);
|
||||
wanted.iter().all(|(k, v)| p.get(k) == Some(v))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
//! The last line of defence: a panic inside a command becomes an [`EngineError::Internal`] and
|
||||
//! the caller rolls the document back. It exists for bugs and doesn't replace `Result`.
|
||||
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
pub fn run<T>(cmd: &str, f: impl FnOnce() -> Result<T>) -> Result<T> {
|
||||
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
|
||||
Ok(r) => r,
|
||||
Err(payload) => {
|
||||
let msg = payload
|
||||
.downcast_ref::<&str>()
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| payload.downcast_ref::<String>().cloned())
|
||||
.unwrap_or_else(|| "panic".to_string());
|
||||
log::error!("command `{cmd}` panicked: {msg}");
|
||||
Err(EngineError::Internal { cmd: cmd.to_string(), msg })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
//! Document inspection as JSON, so agents can verify their work without screenshots.
|
||||
|
||||
use figcraft_doc::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// A node and (to `depth`) its children, with the properties a designer would check.
|
||||
pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
|
||||
let Some(n) = doc.node(id) else { return Value::Null };
|
||||
let c = n.transform.as_coeffs();
|
||||
let mut v = json!({
|
||||
"id": n.id,
|
||||
"name": n.name,
|
||||
"type": n.kind.type_name(),
|
||||
});
|
||||
let Some(o) = v.as_object_mut() else { return v };
|
||||
if !n.is_page() {
|
||||
o.insert("x".into(), json!(round(c[4])));
|
||||
o.insert("y".into(), json!(round(c[5])));
|
||||
o.insert("width".into(), json!(round(n.width)));
|
||||
o.insert("height".into(), json!(round(n.height)));
|
||||
let rot = figcraft_geom::rotation_degrees(n.transform);
|
||||
if rot.abs() > 1e-6 {
|
||||
o.insert("rotation".into(), json!(round(rot)));
|
||||
}
|
||||
if let Some(b) = doc.world_bounds(id) {
|
||||
o.insert("absoluteBounds".into(), json!({"x": round(b.x0), "y": round(b.y0), "width": round(b.width()), "height": round(b.height())}));
|
||||
}
|
||||
if !n.visible {
|
||||
o.insert("visible".into(), json!(false));
|
||||
}
|
||||
if n.locked {
|
||||
o.insert("locked".into(), json!(true));
|
||||
}
|
||||
if n.opacity < 1.0 {
|
||||
o.insert("opacity".into(), json!(n.opacity));
|
||||
}
|
||||
if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) {
|
||||
o.insert("blend".into(), json!(n.blend));
|
||||
}
|
||||
if !n.fills.is_empty() {
|
||||
o.insert("fills".into(), paints(&n.fills));
|
||||
}
|
||||
if !n.strokes.is_empty() {
|
||||
o.insert("strokes".into(), paints(&n.strokes));
|
||||
o.insert("strokeWeight".into(), json!(n.stroke.weight));
|
||||
o.insert("strokeAlign".into(), json!(n.stroke.align));
|
||||
if n.stroke.sides.is_some() {
|
||||
o.insert("strokeSides".into(), json!(n.stroke.sides));
|
||||
}
|
||||
if !n.stroke.dashes.is_empty() {
|
||||
o.insert("dashes".into(), json!(n.stroke.dashes));
|
||||
}
|
||||
}
|
||||
if !n.effects.is_empty() {
|
||||
o.insert("effects".into(), serde_json::to_value(&n.effects).unwrap_or(Value::Null));
|
||||
}
|
||||
if n.corners.radii.iter().any(|r| *r > 0.0) {
|
||||
o.insert("cornerRadius".into(), match n.corners.uniform() {
|
||||
Some(r) => json!(r),
|
||||
None => json!(n.corners.radii),
|
||||
});
|
||||
if n.corners.smoothing > 0.0 {
|
||||
o.insert("cornerSmoothing".into(), json!(n.corners.smoothing));
|
||||
}
|
||||
}
|
||||
if let Some(l) = &n.layout {
|
||||
o.insert("layout".into(), serde_json::to_value(l).unwrap_or(Value::Null));
|
||||
}
|
||||
let parent_al = n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some());
|
||||
if n.layout.is_some() || parent_al || matches!(n.kind, NodeKind::Text(_)) {
|
||||
o.insert("sizing".into(), json!({"horizontal": n.child_layout.sizing_h, "vertical": n.child_layout.sizing_v}));
|
||||
}
|
||||
if n.child_layout.absolute {
|
||||
o.insert("absolute".into(), json!(true));
|
||||
}
|
||||
if !parent_al && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.kind.is_frame_like()) {
|
||||
o.insert("constraints".into(), serde_json::to_value(n.constraints).unwrap_or(Value::Null));
|
||||
}
|
||||
if n.kind.is_frame_like() {
|
||||
o.insert("clip".into(), json!(n.clip));
|
||||
}
|
||||
if n.is_mask {
|
||||
o.insert("isMask".into(), json!(true));
|
||||
}
|
||||
if !n.grids.is_empty() {
|
||||
o.insert("grids".into(), serde_json::to_value(&n.grids).unwrap_or(Value::Null));
|
||||
}
|
||||
if !n.exports.is_empty() {
|
||||
o.insert("exports".into(), serde_json::to_value(&n.exports).unwrap_or(Value::Null));
|
||||
}
|
||||
if !n.interactions.is_empty() {
|
||||
o.insert("interactions".into(), serde_json::to_value(&n.interactions).unwrap_or(Value::Null));
|
||||
}
|
||||
if !n.styles.is_empty() {
|
||||
o.insert("styles".into(), serde_json::to_value(&n.styles).unwrap_or(Value::Null));
|
||||
}
|
||||
if !n.bound.is_empty() {
|
||||
o.insert("boundVariables".into(), json!(n.bound));
|
||||
}
|
||||
if !n.overrides.is_empty() {
|
||||
o.insert("overrides".into(), json!(n.overrides));
|
||||
}
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
o.insert("characters".into(), json!(t.characters));
|
||||
o.insert("fontFamily".into(), json!(t.style.font_family));
|
||||
o.insert("fontStyle".into(), json!(t.style.font_style));
|
||||
o.insert("fontSize".into(), json!(t.style.font_size));
|
||||
o.insert("lineHeight".into(), json!(t.style.line_height.label()));
|
||||
o.insert("letterSpacing".into(), json!(t.style.letter_spacing.label()));
|
||||
o.insert("textAlign".into(), json!(t.align_h));
|
||||
o.insert("textAutoResize".into(), json!(t.auto_resize));
|
||||
if t.style.decoration != TextDecoration::None {
|
||||
o.insert("textDecoration".into(), json!(t.style.decoration));
|
||||
}
|
||||
if !t.runs.is_empty() {
|
||||
o.insert("styledRuns".into(), serde_json::to_value(&t.runs).unwrap_or(Value::Null));
|
||||
}
|
||||
}
|
||||
NodeKind::Ellipse { start, sweep, inner } if (*sweep).abs() < std::f64::consts::TAU - 1e-9 || *inner > 0.0 => {
|
||||
o.insert("arc".into(), json!({"start": start.to_degrees(), "sweep": sweep.to_degrees(), "inner": inner}));
|
||||
}
|
||||
NodeKind::Polygon { count } => {
|
||||
o.insert("pointCount".into(), json!(count));
|
||||
}
|
||||
NodeKind::Star { count, inner_ratio } => {
|
||||
o.insert("pointCount".into(), json!(count));
|
||||
o.insert("innerRadius".into(), json!(inner_ratio));
|
||||
}
|
||||
NodeKind::BooleanOp { op } => {
|
||||
o.insert("booleanOperation".into(), json!(op));
|
||||
}
|
||||
NodeKind::Instance { component, props } => {
|
||||
o.insert("mainComponent".into(), json!(component));
|
||||
o.insert("mainComponentName".into(), json!(doc.node(*component).map(|c| c.name.clone())));
|
||||
if !props.is_empty() {
|
||||
o.insert("componentProperties".into(), json!(props));
|
||||
}
|
||||
}
|
||||
NodeKind::Component { description, props } => {
|
||||
if !description.is_empty() {
|
||||
o.insert("description".into(), json!(description));
|
||||
}
|
||||
if !props.is_empty() {
|
||||
o.insert("componentPropertyDefinitions".into(), serde_json::to_value(props).unwrap_or(Value::Null));
|
||||
}
|
||||
}
|
||||
NodeKind::Vector { network } => {
|
||||
o.insert("vertices".into(), json!(network.vertices.len()));
|
||||
o.insert("segments".into(), json!(network.segments.len()));
|
||||
}
|
||||
NodeKind::Page { background, flows, .. } => {
|
||||
o.insert("background".into(), json!(background.to_css_hex()));
|
||||
if !flows.is_empty() {
|
||||
o.insert("flows".into(), serde_json::to_value(flows).unwrap_or(Value::Null));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if !n.children.is_empty() {
|
||||
if depth > 0 {
|
||||
o.insert("children".into(), Value::Array(n.children.iter().map(|c| node_json(doc, *c, depth - 1)).collect()));
|
||||
} else {
|
||||
o.insert("childCount".into(), json!(n.children.len()));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn round(v: f64) -> f64 {
|
||||
figcraft_geom::round_to(v, 2)
|
||||
}
|
||||
|
||||
fn paints(p: &[figcraft_color::Paint]) -> Value {
|
||||
Value::Array(
|
||||
p.iter()
|
||||
.map(|x| match &x.kind {
|
||||
figcraft_color::PaintKind::Solid { color } => {
|
||||
let mut v = json!({"type": "solid", "color": color.to_css_hex()});
|
||||
if x.opacity < 1.0 {
|
||||
v["opacity"] = json!(x.opacity);
|
||||
}
|
||||
if !x.visible {
|
||||
v["visible"] = json!(false);
|
||||
}
|
||||
v
|
||||
}
|
||||
_ => serde_json::to_value(x).unwrap_or(Value::Null),
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The whole document (current page in depth, other pages summarised).
|
||||
pub fn document(s: &Session, depth: usize, all_pages: bool) -> Value {
|
||||
let Ok(ds) = s.ds() else { return json!({"documents": 0}) };
|
||||
let doc = &ds.doc;
|
||||
let pages: Vec<Value> = doc
|
||||
.pages
|
||||
.iter()
|
||||
.map(|p| if all_pages || *p == ds.page { node_json(doc, *p, depth) } else { node_json(doc, *p, 0) })
|
||||
.collect();
|
||||
json!({
|
||||
"name": doc.name,
|
||||
"path": ds.path,
|
||||
"dirty": ds.is_dirty(),
|
||||
"currentPage": ds.page,
|
||||
"selection": ds.selection,
|
||||
"pages": pages,
|
||||
"styles": doc.styles.iter().map(|st| json!({"id": st.id, "name": st.name, "type": st.kind.type_name()})).collect::<Vec<_>>(),
|
||||
"variables": doc.variables.variables.len(),
|
||||
"images": doc.images.len(),
|
||||
"textEdit": ds.text_edit,
|
||||
"history": {"undo": ds.history.undo.iter().rev().take(20).map(|h| h.label.clone()).collect::<Vec<_>>(), "redo": ds.history.redo.len()},
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,714 @@
|
|||
//! The FigCraft engine.
|
||||
//!
|
||||
//! Every user-visible action is a command with a stable id (`node.create`, `align.left`,
|
||||
//! `layout.add`, `component.create`…) taking JSON params. The egui UI, the CLI, the control channel
|
||||
//! and the MCP server all go through [`Session::execute`], so everything is scriptable and every
|
||||
//! change is one undo step. Pointer tools ([`tools`]) and text editing reduce to the same document
|
||||
//! edits and are journaled the same way.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod catalog;
|
||||
pub mod cmd;
|
||||
pub mod components;
|
||||
pub mod guard;
|
||||
pub mod inspect;
|
||||
pub mod measure;
|
||||
pub mod params;
|
||||
pub mod snap;
|
||||
pub mod textedit;
|
||||
pub mod tools;
|
||||
|
||||
pub use cmd::{CommandSpec, all_commands, find_command};
|
||||
pub use figcraft_doc as doc;
|
||||
pub use tools::{PointerEvent, PointerKind, ToolId};
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum EngineError {
|
||||
#[error("unknown command `{0}`")]
|
||||
UnknownCommand(String),
|
||||
#[error("command `{0}` is not available right now: {1}")]
|
||||
Disabled(String, String),
|
||||
#[error("invalid parameters for `{cmd}`: {msg}")]
|
||||
BadParams { cmd: String, msg: String },
|
||||
#[error("no open document")]
|
||||
NoDocument,
|
||||
#[error("no such node {0}")]
|
||||
NoNode(NodeId),
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
/// A bug: the command panicked. The document was rolled back.
|
||||
#[error("internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug)")]
|
||||
Internal { cmd: String, msg: String },
|
||||
}
|
||||
|
||||
impl From<figcraft_doc::DocError> for EngineError {
|
||||
fn from(e: figcraft_doc::DocError) -> Self {
|
||||
match e {
|
||||
figcraft_doc::DocError::NoNode(id) => EngineError::NoNode(id),
|
||||
other => EngineError::Other(other.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, EngineError>;
|
||||
|
||||
pub fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
EngineError::BadParams { cmd: cmd.to_string(), msg: msg.into() }
|
||||
}
|
||||
|
||||
/// One undo step.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HistoryEntry {
|
||||
pub label: String,
|
||||
pub doc: Arc<Document>,
|
||||
pub selection: Vec<NodeId>,
|
||||
pub page: NodeId,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct History {
|
||||
pub undo: Vec<HistoryEntry>,
|
||||
pub redo: Vec<HistoryEntry>,
|
||||
pub limit: usize,
|
||||
}
|
||||
|
||||
/// The canvas view of a document: what's at the centre of the viewport and the zoom.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ViewState {
|
||||
/// Page coordinate at the viewport centre.
|
||||
pub cx: f64,
|
||||
pub cy: f64,
|
||||
/// Zoom (1 = 100%).
|
||||
pub zoom: f64,
|
||||
/// Viewport size in logical pixels (set by the UI; used by zoom-to-fit).
|
||||
pub vw: f64,
|
||||
pub vh: f64,
|
||||
pub pixel_grid: bool,
|
||||
pub layout_grids: bool,
|
||||
pub rulers: bool,
|
||||
pub outlines: bool,
|
||||
pub pixel_preview: bool,
|
||||
pub show_slices: bool,
|
||||
pub comments: bool,
|
||||
pub frame_outlines: bool,
|
||||
pub snap_geometry: bool,
|
||||
pub snap_objects: bool,
|
||||
pub snap_pixel: bool,
|
||||
pub ui_hidden: bool,
|
||||
}
|
||||
|
||||
impl Default for ViewState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
cx: 0.0,
|
||||
cy: 0.0,
|
||||
zoom: 1.0,
|
||||
vw: 1200.0,
|
||||
vh: 800.0,
|
||||
pixel_grid: true,
|
||||
layout_grids: true,
|
||||
rulers: false,
|
||||
outlines: false,
|
||||
pixel_preview: false,
|
||||
show_slices: false,
|
||||
comments: true,
|
||||
frame_outlines: false,
|
||||
snap_geometry: true,
|
||||
snap_objects: true,
|
||||
snap_pixel: true,
|
||||
ui_hidden: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ViewState {
|
||||
pub const MIN_ZOOM: f64 = 0.02;
|
||||
pub const MAX_ZOOM: f64 = 256.0;
|
||||
/// Page coordinate at the viewport's top-left.
|
||||
pub fn origin(&self) -> (f64, f64) {
|
||||
let z = self.zoom.max(1e-9);
|
||||
(self.cx - self.vw / 2.0 / z, self.cy - self.vh / 2.0 / z)
|
||||
}
|
||||
pub fn set_zoom_at(&mut self, zoom: f64, anchor: (f64, f64)) {
|
||||
let z = if zoom.is_finite() { zoom.clamp(Self::MIN_ZOOM, Self::MAX_ZOOM) } else { self.zoom };
|
||||
let old = self.zoom.max(1e-9);
|
||||
// Keep the page point under `anchor` fixed.
|
||||
self.cx = anchor.0 + (self.cx - anchor.0) * old / z;
|
||||
self.cy = anchor.1 + (self.cy - anchor.1) * old / z;
|
||||
self.zoom = z;
|
||||
}
|
||||
/// Fit a page rectangle into the viewport with margin.
|
||||
pub fn fit(&mut self, r: figcraft_geom::Rect, max_zoom: f64) {
|
||||
if !(r.width() > 0.0 || r.height() > 0.0) || !r.x0.is_finite() {
|
||||
self.cx = r.center().x;
|
||||
self.cy = r.center().y;
|
||||
return;
|
||||
}
|
||||
let margin = 64.0;
|
||||
let zx = (self.vw - margin * 2.0).max(50.0) / r.width().max(1e-6);
|
||||
let zy = (self.vh - margin * 2.0).max(50.0) / r.height().max(1e-6);
|
||||
self.zoom = zx.min(zy).clamp(Self::MIN_ZOOM, max_zoom);
|
||||
self.cx = r.center().x;
|
||||
self.cy = r.center().y;
|
||||
}
|
||||
pub fn page_to_screen(&self, x: f64, y: f64) -> (f64, f64) {
|
||||
let (ox, oy) = self.origin();
|
||||
((x - ox) * self.zoom, (y - oy) * self.zoom)
|
||||
}
|
||||
pub fn screen_to_page(&self, x: f64, y: f64) -> (f64, f64) {
|
||||
let (ox, oy) = self.origin();
|
||||
let z = self.zoom.max(1e-9);
|
||||
(ox + x / z, oy + y / z)
|
||||
}
|
||||
}
|
||||
|
||||
/// An in-progress gesture: the document before it started, so previews re-apply from scratch.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Interaction {
|
||||
pub label: String,
|
||||
pub doc: Arc<Document>,
|
||||
pub selection: Vec<NodeId>,
|
||||
}
|
||||
|
||||
/// The text being edited on the canvas.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TextEdit {
|
||||
pub id: NodeId,
|
||||
/// Caret position and selection anchor (char indices).
|
||||
pub caret: usize,
|
||||
pub anchor: usize,
|
||||
/// The node was created by this edit (deleted if left empty).
|
||||
pub created: bool,
|
||||
}
|
||||
|
||||
/// Per-document editing state.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DocState {
|
||||
pub doc: Arc<Document>,
|
||||
pub selection: Vec<NodeId>,
|
||||
pub page: NodeId,
|
||||
pub history: History,
|
||||
saved_doc: Arc<Document>,
|
||||
pub path: Option<String>,
|
||||
pub revision: u64,
|
||||
pub view: ViewState,
|
||||
pub interaction: Option<Interaction>,
|
||||
pub text_edit: Option<TextEdit>,
|
||||
/// Hovered node (canvas hover outline).
|
||||
pub hover: Option<NodeId>,
|
||||
/// Vector edit mode: the vector being edited and its selected vertices.
|
||||
pub vector_edit: Option<(NodeId, Vec<usize>)>,
|
||||
pub uid: u64,
|
||||
}
|
||||
|
||||
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
|
||||
impl DocState {
|
||||
pub fn new(mut doc: Document, path: Option<String>) -> Self {
|
||||
if doc.pages.is_empty() {
|
||||
doc.repair();
|
||||
}
|
||||
let page = doc.current_page.filter(|p| doc.pages.contains(p)).or_else(|| doc.first_page()).unwrap_or(NodeId(0));
|
||||
let doc = Arc::new(doc);
|
||||
let mut st = Self {
|
||||
saved_doc: doc.clone(),
|
||||
doc,
|
||||
selection: Vec::new(),
|
||||
page,
|
||||
history: History { limit: 1000, ..Default::default() },
|
||||
path,
|
||||
revision: 1,
|
||||
view: ViewState::default(),
|
||||
interaction: None,
|
||||
text_edit: None,
|
||||
hover: None,
|
||||
vector_edit: None,
|
||||
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||
};
|
||||
st.zoom_to_fit();
|
||||
st
|
||||
}
|
||||
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
!Arc::ptr_eq(&self.doc, &self.saved_doc)
|
||||
}
|
||||
|
||||
pub fn mark_saved(&mut self) {
|
||||
self.saved_doc = self.doc.clone();
|
||||
}
|
||||
|
||||
pub fn doc_mut(&mut self) -> &mut Document {
|
||||
Arc::make_mut(&mut self.doc)
|
||||
}
|
||||
|
||||
/// Fit the current page's content (or centre on the origin when empty).
|
||||
pub fn zoom_to_fit(&mut self) {
|
||||
match self.doc.page_content_bounds(self.page) {
|
||||
Some(b) => self.view.fit(b, 1.0),
|
||||
None => {
|
||||
self.view.zoom = 1.0;
|
||||
self.view.cx = self.view.vw / 2.0 - 100.0;
|
||||
self.view.cy = self.view.vh / 2.0 - 100.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop selected ids that no longer exist or aren't on the current page.
|
||||
pub fn clean_selection(&mut self) {
|
||||
let doc = self.doc.clone();
|
||||
let page = self.page;
|
||||
self.selection.retain(|id| doc.contains(*id) && doc.page_of(*id) == Some(page) && *id != page);
|
||||
let mut seen = BTreeSet::new();
|
||||
self.selection.retain(|id| seen.insert(*id));
|
||||
if let Some(te) = &self.text_edit
|
||||
&& !doc.contains(te.id)
|
||||
{
|
||||
self.text_edit = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// App-wide preferences that commands read (nudge amounts, behaviours).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Prefs {
|
||||
pub nudge_small: f64,
|
||||
pub nudge_big: f64,
|
||||
pub keep_tool: bool,
|
||||
pub rename_duplicates: bool,
|
||||
pub highlight_on_hover: bool,
|
||||
pub number_keys_opacity: bool,
|
||||
pub show_dimensions: bool,
|
||||
pub theme: String,
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
nudge_small: 1.0,
|
||||
nudge_big: 10.0,
|
||||
keep_tool: false,
|
||||
rename_duplicates: false,
|
||||
highlight_on_hover: true,
|
||||
number_keys_opacity: true,
|
||||
show_dimensions: true,
|
||||
theme: "light".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A request from a command to the UI (open a dialog or panel, show a message). Programmatic
|
||||
/// callers ignore them.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct UiRequest {
|
||||
pub kind: String,
|
||||
pub data: Value,
|
||||
}
|
||||
|
||||
/// Clipboard contents (internal; the UI mirrors to the system clipboard).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Clipboard {
|
||||
/// Copied subtrees: (root, nodes) in z-order.
|
||||
pub nodes: Vec<(NodeId, Vec<figcraft_doc::Node>)>,
|
||||
pub images: std::collections::BTreeMap<String, figcraft_doc::ImageData>,
|
||||
/// Copied properties (Copy properties / Paste properties).
|
||||
pub props: Option<Value>,
|
||||
/// The page-space bounds of the copied selection.
|
||||
pub bounds: Option<figcraft_geom::Rect>,
|
||||
pub source_doc: u64,
|
||||
}
|
||||
|
||||
/// The editing session: open documents, the current tool, clipboard and prefs.
|
||||
pub struct Session {
|
||||
pub docs: Vec<DocState>,
|
||||
pub active: usize,
|
||||
pub clipboard: Clipboard,
|
||||
pub prefs: Prefs,
|
||||
pub tool: tools::ToolState,
|
||||
pub ui_requests: Vec<UiRequest>,
|
||||
/// Command log (id + params) for actions/automation.
|
||||
pub journal: Vec<(String, Value)>,
|
||||
/// Recently used colours.
|
||||
pub recent_colors: Vec<figcraft_color::Color>,
|
||||
/// The last command executed through [`Session::execute`] (for "Repeat").
|
||||
pub last_command: Option<(String, Value)>,
|
||||
/// Status / toast message for the UI.
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// A session with one new document.
|
||||
pub fn new() -> Self {
|
||||
let mut s = Self::empty();
|
||||
s.docs.push(DocState::new(Document::new("Untitled"), None));
|
||||
s
|
||||
}
|
||||
|
||||
/// A session with no documents (the home screen).
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
docs: Vec::new(),
|
||||
active: 0,
|
||||
clipboard: Clipboard::default(),
|
||||
prefs: Prefs::default(),
|
||||
tool: tools::ToolState::default(),
|
||||
ui_requests: Vec::new(),
|
||||
journal: Vec::new(),
|
||||
recent_colors: Vec::new(),
|
||||
last_command: None,
|
||||
message: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_document(doc: Document) -> Self {
|
||||
let mut s = Self::empty();
|
||||
s.docs.push(DocState::new(doc, None));
|
||||
s
|
||||
}
|
||||
|
||||
pub fn ds(&self) -> Result<&DocState> {
|
||||
self.docs.get(self.active).ok_or(EngineError::NoDocument)
|
||||
}
|
||||
|
||||
pub fn ds_mut(&mut self) -> Result<&mut DocState> {
|
||||
self.docs.get_mut(self.active).ok_or(EngineError::NoDocument)
|
||||
}
|
||||
|
||||
pub fn doc(&self) -> Result<&Document> {
|
||||
Ok(self.ds()?.doc.as_ref())
|
||||
}
|
||||
|
||||
pub fn doc_mut(&mut self) -> Result<&mut Document> {
|
||||
Ok(self.ds_mut()?.doc_mut())
|
||||
}
|
||||
|
||||
pub fn selection(&self) -> Vec<NodeId> {
|
||||
self.ds().map(|d| d.selection.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn set_selection(&mut self, ids: Vec<NodeId>) {
|
||||
if let Ok(ds) = self.ds_mut() {
|
||||
ds.selection = ids;
|
||||
ds.clean_selection();
|
||||
ds.revision += 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn page(&self) -> Result<NodeId> {
|
||||
Ok(self.ds()?.page)
|
||||
}
|
||||
|
||||
/// Run a command by id. Mutations become one undo step; a failure or panic leaves the document
|
||||
/// as it was.
|
||||
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
let before = self.ds().ok().map(|d| (d.doc.clone(), d.selection.clone(), d.page, d.uid));
|
||||
let result = guard::run(id, || (spec.run)(self, params));
|
||||
match &result {
|
||||
Ok(_) => {
|
||||
self.after_command(spec, before);
|
||||
if spec.history {
|
||||
self.last_command = Some((id.to_string(), params.clone()));
|
||||
}
|
||||
self.journal.push((id.to_string(), params.clone()));
|
||||
if self.journal.len() > 5000 {
|
||||
self.journal.drain(..1000);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// Roll back any partial edit.
|
||||
if let Some((doc, sel, page, uid)) = before
|
||||
&& let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid)
|
||||
&& ds.interaction.is_none()
|
||||
{
|
||||
ds.doc = doc;
|
||||
ds.selection = sel;
|
||||
ds.page = page;
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Bookkeeping after a successful command: normalise what changed, push undo, bump revision.
|
||||
fn after_command(&mut self, spec: &CommandSpec, before: Option<(Arc<Document>, Vec<NodeId>, NodeId, u64)>) {
|
||||
let Some((bdoc, bsel, bpage, uid)) = before else {
|
||||
if let Ok(ds) = self.ds_mut() {
|
||||
ds.revision += 1;
|
||||
}
|
||||
return;
|
||||
};
|
||||
let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) else { return };
|
||||
let changed = !Arc::ptr_eq(&ds.doc, &bdoc);
|
||||
if changed {
|
||||
normalize(ds.doc_mut(), &bdoc);
|
||||
if spec.history && ds.interaction.is_none() {
|
||||
ds.history.undo.push(HistoryEntry { label: spec.label.to_string(), doc: bdoc, selection: bsel, page: bpage });
|
||||
if ds.history.undo.len() > ds.history.limit.max(1) {
|
||||
ds.history.undo.remove(0);
|
||||
}
|
||||
ds.history.redo.clear();
|
||||
}
|
||||
}
|
||||
ds.clean_selection();
|
||||
ds.revision += 1;
|
||||
}
|
||||
|
||||
/// Start a gesture: later [`preview`](Self::preview) calls re-apply edits to this snapshot.
|
||||
pub fn begin_interaction(&mut self, label: &str) -> Result<()> {
|
||||
let ds = self.ds_mut()?;
|
||||
if ds.interaction.is_none() {
|
||||
ds.interaction = Some(Interaction { label: label.to_string(), doc: ds.doc.clone(), selection: ds.selection.clone() });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replace the document with `f(snapshot)` for a live preview during a gesture.
|
||||
pub fn preview(&mut self, f: impl FnOnce(&mut Document, &mut Vec<NodeId>) -> Result<()>) -> Result<()> {
|
||||
let ds = self.ds_mut()?;
|
||||
let Some(it) = ds.interaction.clone() else { return Err(EngineError::Other("no gesture in progress".into())) };
|
||||
let mut doc = (*it.doc).clone();
|
||||
let mut sel = it.selection.clone();
|
||||
let res = f(&mut doc, &mut sel);
|
||||
if res.is_ok() {
|
||||
normalize(&mut doc, &it.doc);
|
||||
ds.doc = Arc::new(doc);
|
||||
ds.selection = sel;
|
||||
ds.clean_selection();
|
||||
ds.revision += 1;
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
/// Finish a gesture as one undo step (no step if nothing changed).
|
||||
pub fn commit_interaction(&mut self) -> Result<()> {
|
||||
let ds = self.ds_mut()?;
|
||||
let Some(it) = ds.interaction.take() else { return Ok(()) };
|
||||
if !Arc::ptr_eq(&it.doc, &ds.doc) && *it.doc != *ds.doc {
|
||||
ds.history.undo.push(HistoryEntry { label: it.label, doc: it.doc, selection: it.selection, page: ds.page });
|
||||
ds.history.redo.clear();
|
||||
} else {
|
||||
ds.doc = it.doc;
|
||||
}
|
||||
ds.revision += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Abandon a gesture, restoring the document.
|
||||
pub fn cancel_interaction(&mut self) -> Result<()> {
|
||||
let ds = self.ds_mut()?;
|
||||
if let Some(it) = ds.interaction.take() {
|
||||
ds.doc = it.doc;
|
||||
ds.selection = it.selection;
|
||||
ds.revision += 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn undo(&mut self) -> Result<bool> {
|
||||
let ds = self.ds_mut()?;
|
||||
ds.interaction = None;
|
||||
ds.text_edit = None;
|
||||
let Some(e) = ds.history.undo.pop() else { return Ok(false) };
|
||||
let redo = HistoryEntry { label: e.label.clone(), doc: ds.doc.clone(), selection: ds.selection.clone(), page: ds.page };
|
||||
ds.history.redo.push(redo);
|
||||
ds.doc = e.doc;
|
||||
ds.selection = e.selection;
|
||||
if ds.doc.pages.contains(&e.page) {
|
||||
ds.page = e.page;
|
||||
}
|
||||
ds.clean_selection();
|
||||
ds.revision += 1;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn redo(&mut self) -> Result<bool> {
|
||||
let ds = self.ds_mut()?;
|
||||
ds.interaction = None;
|
||||
ds.text_edit = None;
|
||||
let Some(e) = ds.history.redo.pop() else { return Ok(false) };
|
||||
let undo = HistoryEntry { label: e.label.clone(), doc: ds.doc.clone(), selection: ds.selection.clone(), page: ds.page };
|
||||
ds.history.undo.push(undo);
|
||||
ds.doc = e.doc;
|
||||
ds.selection = e.selection;
|
||||
if ds.doc.pages.contains(&e.page) {
|
||||
ds.page = e.page;
|
||||
}
|
||||
ds.clean_selection();
|
||||
ds.revision += 1;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Queue a UI request (dialogs etc.).
|
||||
pub fn request_ui(&mut self, kind: &str, data: Value) {
|
||||
self.ui_requests.push(UiRequest { kind: kind.to_string(), data });
|
||||
}
|
||||
|
||||
/// Run several commands as one undo step.
|
||||
pub fn batch(&mut self, label: &str, cmds: &[(String, Value)]) -> Result<Vec<Value>> {
|
||||
let before = self.ds()?.doc.clone();
|
||||
let sel = self.selection();
|
||||
let page = self.page()?;
|
||||
let mut out = Vec::new();
|
||||
let saved_len = self.ds()?.history.undo.len();
|
||||
for (id, p) in cmds {
|
||||
match self.execute(id, p) {
|
||||
Ok(v) => out.push(v),
|
||||
Err(e) => {
|
||||
let ds = self.ds_mut()?;
|
||||
ds.doc = before;
|
||||
ds.selection = sel;
|
||||
ds.history.undo.truncate(saved_len);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
let ds = self.ds_mut()?;
|
||||
ds.history.undo.truncate(saved_len);
|
||||
if !Arc::ptr_eq(&before, &ds.doc) {
|
||||
ds.history.undo.push(HistoryEntry { label: label.to_string(), doc: before, selection: sel, page });
|
||||
ds.history.redo.clear();
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A summary for status displays.
|
||||
pub fn status(&self) -> Value {
|
||||
match self.ds() {
|
||||
Ok(ds) => json!({
|
||||
"document": ds.doc.name,
|
||||
"path": ds.path,
|
||||
"dirty": ds.is_dirty(),
|
||||
"page": ds.page,
|
||||
"selection": ds.selection,
|
||||
"tool": self.tool.tool.id(),
|
||||
"zoom": ds.view.zoom,
|
||||
"undo": ds.history.undo.len(),
|
||||
"redo": ds.history.redo.len(),
|
||||
"documents": self.docs.len(),
|
||||
}),
|
||||
Err(_) => json!({"documents": 0}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Nodes whose `Arc` differs from `before` (new or edited).
|
||||
pub fn changed_nodes(doc: &Document, before: &Document) -> Vec<NodeId> {
|
||||
doc.ids()
|
||||
.filter(|id| match (doc.node_arc(*id), before.node_arc(*id)) {
|
||||
(Some(a), Some(b)) => !Arc::ptr_eq(a, b),
|
||||
_ => true,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Keep derived state consistent after edits: text boxes resize to their content, groups wrap
|
||||
/// their children, instances follow their main components, auto layout re-flows.
|
||||
pub fn normalize(doc: &mut Document, before: &Document) {
|
||||
let changed = changed_nodes(doc, before);
|
||||
if changed.is_empty() && doc.node_count() == before.node_count() {
|
||||
return;
|
||||
}
|
||||
let m = measure::TextMeasure;
|
||||
// 0. Variable bindings and style links feed node values.
|
||||
cmd::styles::apply_bindings(doc);
|
||||
if doc.styles != before.styles {
|
||||
let changed_styles: Vec<String> = doc.styles.iter().filter(|s| !before.styles.contains(s)).map(|s| s.id.clone()).collect();
|
||||
for sid in changed_styles {
|
||||
cmd::styles::propagate_style(doc, &sid);
|
||||
}
|
||||
}
|
||||
let changed = changed_nodes(doc, before);
|
||||
// 1. Text auto-size.
|
||||
for id in &changed {
|
||||
let Some(n) = doc.node(*id) else { continue };
|
||||
if let Some((w, h)) = figcraft_text::auto_size(n)
|
||||
&& ((w - n.width).abs() > 1e-6 || (h - n.height).abs() > 1e-6)
|
||||
{
|
||||
figcraft_layout::set_size(doc, *id, w, h);
|
||||
}
|
||||
}
|
||||
// 2. Instances follow edited main components (and record overrides inside instances).
|
||||
components::sync_after_edit(doc, before, &changed);
|
||||
// 3. Groups / booleans wrap their children.
|
||||
let mut fit: BTreeSet<NodeId> = BTreeSet::new();
|
||||
for id in changed_nodes(doc, before) {
|
||||
if doc.node(id).is_some_and(|n| n.kind.bounds_follow_children()) {
|
||||
fit.insert(id);
|
||||
}
|
||||
for a in doc.ancestors(id) {
|
||||
if doc.node(a).is_some_and(|n| n.kind.bounds_follow_children()) {
|
||||
fit.insert(a);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Removed children change their parents too.
|
||||
for id in before.ids() {
|
||||
if !doc.contains(id)
|
||||
&& let Some(p) = before.node(id).and_then(|n| n.parent)
|
||||
&& doc.node(p).is_some_and(|n| n.kind.bounds_follow_children())
|
||||
{
|
||||
fit.insert(p);
|
||||
}
|
||||
}
|
||||
// Deepest first.
|
||||
let mut fit: Vec<NodeId> = fit.into_iter().collect();
|
||||
fit.sort_by_key(|id| std::cmp::Reverse(doc.ancestors(*id).len()));
|
||||
for id in fit {
|
||||
if doc.node(id).is_some_and(|n| n.children.is_empty()) {
|
||||
// Empty groups disappear, like Figma.
|
||||
let _ = doc.remove(id);
|
||||
} else {
|
||||
let _ = doc.fit_to_children(id);
|
||||
}
|
||||
}
|
||||
// 4. Auto layout for affected frames.
|
||||
let mut frames: BTreeSet<NodeId> = BTreeSet::new();
|
||||
let changed = changed_nodes(doc, before);
|
||||
for id in &changed {
|
||||
let mut chain = vec![*id];
|
||||
chain.extend(doc.ancestors(*id));
|
||||
for c in chain {
|
||||
if doc.node(c).is_some_and(|n| n.layout.is_some()) {
|
||||
frames.insert(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
for id in before.ids() {
|
||||
if !doc.contains(id)
|
||||
&& let Some(p) = before.node(id).and_then(|n| n.parent)
|
||||
{
|
||||
let mut chain = vec![p];
|
||||
chain.extend(doc.ancestors(p));
|
||||
for c in chain {
|
||||
if doc.node(c).is_some_and(|n| n.layout.is_some()) {
|
||||
frames.insert(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut frames: Vec<NodeId> = frames.into_iter().collect();
|
||||
frames.sort_by_key(|id| std::cmp::Reverse(doc.ancestors(*id).len()));
|
||||
for f in frames {
|
||||
figcraft_layout::layout_frame(doc, f, &m);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
//! Text measurement for the layout engine.
|
||||
|
||||
use figcraft_doc::{Document, NodeId, NodeKind, TextAutoResize};
|
||||
|
||||
pub struct TextMeasure;
|
||||
|
||||
impl figcraft_layout::Measure for TextMeasure {
|
||||
fn text_height(&self, doc: &Document, id: NodeId, width: f64) -> Option<f64> {
|
||||
let n = doc.node(id)?;
|
||||
let NodeKind::Text(t) = &n.kind else { return None };
|
||||
let mut t = t.clone();
|
||||
if t.auto_resize == TextAutoResize::WidthAndHeight {
|
||||
t.auto_resize = TextAutoResize::Height;
|
||||
}
|
||||
Some(figcraft_text::layout(&t, Some(width), None).height.max(1.0))
|
||||
}
|
||||
fn text_size(&self, doc: &Document, id: NodeId) -> Option<(f64, f64)> {
|
||||
let n = doc.node(id)?;
|
||||
figcraft_text::auto_size(n)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
//! Parameter parsing helpers shared by commands (lenient: agents send numbers as strings, ids as
|
||||
//! "12" or "0:12", colours as hex or CSS).
|
||||
|
||||
use figcraft_color::{BlendMode, Color, Paint};
|
||||
use figcraft_doc::NodeId;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{EngineError, Result, Session, bad};
|
||||
|
||||
pub fn get<'a>(p: &'a Value, key: &str) -> Option<&'a Value> {
|
||||
p.get(key).filter(|v| !v.is_null())
|
||||
}
|
||||
|
||||
pub fn f64_of(v: &Value) -> Option<f64> {
|
||||
match v {
|
||||
Value::Number(n) => n.as_f64().filter(|f| f.is_finite()),
|
||||
Value::String(s) => s.trim().trim_end_matches("px").trim_end_matches('%').trim().parse::<f64>().ok().filter(|f| f.is_finite()),
|
||||
Value::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn num(p: &Value, key: &str) -> Option<f64> {
|
||||
get(p, key).and_then(f64_of)
|
||||
}
|
||||
|
||||
pub fn num_or(p: &Value, key: &str, d: f64) -> f64 {
|
||||
num(p, key).unwrap_or(d)
|
||||
}
|
||||
|
||||
pub fn req_num(cmd: &str, p: &Value, key: &str) -> Result<f64> {
|
||||
num(p, key).ok_or_else(|| bad(cmd, format!("`{key}` must be a number")))
|
||||
}
|
||||
|
||||
pub fn string(p: &Value, key: &str) -> Option<String> {
|
||||
match get(p, key)? {
|
||||
Value::String(s) => Some(s.clone()),
|
||||
Value::Number(n) => Some(n.to_string()),
|
||||
Value::Bool(b) => Some(b.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn req_string(cmd: &str, p: &Value, key: &str) -> Result<String> {
|
||||
string(p, key).ok_or_else(|| bad(cmd, format!("`{key}` must be a string")))
|
||||
}
|
||||
|
||||
pub fn boolean(p: &Value, key: &str) -> Option<bool> {
|
||||
match get(p, key)? {
|
||||
Value::Bool(b) => Some(*b),
|
||||
Value::Number(n) => n.as_f64().map(|f| f != 0.0),
|
||||
Value::String(s) => match s.to_ascii_lowercase().as_str() {
|
||||
"true" | "yes" | "on" | "1" => Some(true),
|
||||
"false" | "no" | "off" | "0" => Some(false),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id_of(v: &Value) -> Option<NodeId> {
|
||||
match v {
|
||||
Value::Number(n) => n.as_u64().map(NodeId),
|
||||
Value::String(s) => s.parse().ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(p: &Value, key: &str) -> Option<NodeId> {
|
||||
get(p, key).and_then(id_of)
|
||||
}
|
||||
|
||||
pub fn req_id(cmd: &str, p: &Value, key: &str) -> Result<NodeId> {
|
||||
id(p, key).ok_or_else(|| bad(cmd, format!("`{key}` must be a node id")))
|
||||
}
|
||||
|
||||
/// `ids` (array), `id` (single), or the current selection.
|
||||
pub fn targets(s: &Session, p: &Value) -> Vec<NodeId> {
|
||||
if let Some(Value::Array(a)) = get(p, "ids") {
|
||||
return a.iter().filter_map(id_of).collect();
|
||||
}
|
||||
if let Some(i) = id(p, "id") {
|
||||
return vec![i];
|
||||
}
|
||||
s.selection()
|
||||
}
|
||||
|
||||
/// Like [`targets`] but an empty result is an error.
|
||||
pub fn req_targets(cmd: &str, s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
let t = targets(s, p);
|
||||
if t.is_empty() {
|
||||
return Err(EngineError::Disabled(cmd.to_string(), "nothing is selected (pass `ids`)".into()));
|
||||
}
|
||||
if let Ok(doc) = s.doc() {
|
||||
for i in &t {
|
||||
if !doc.contains(*i) {
|
||||
return Err(EngineError::NoNode(*i));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
pub fn color_of(v: &Value) -> Option<Color> {
|
||||
match v {
|
||||
Value::String(s) => Color::parse_css(s),
|
||||
Value::Object(_) => serde_json::from_value::<Color>(v.clone()).ok().map(Color::sanitized),
|
||||
Value::Array(a) => {
|
||||
let c: Vec<f32> = a.iter().filter_map(|x| x.as_f64()).map(|f| f as f32).collect();
|
||||
match c.as_slice() {
|
||||
[r, g, b] => Some(Color::new(*r, *g, *b, 1.0)),
|
||||
[r, g, b, a] => Some(Color::new(*r, *g, *b, *a)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn color(p: &Value, key: &str) -> Option<Color> {
|
||||
get(p, key).and_then(color_of)
|
||||
}
|
||||
|
||||
/// A paint from JSON: a colour string/object (solid) or a full paint object.
|
||||
pub fn paint_of(v: &Value) -> Option<Paint> {
|
||||
if let Some(c) = color_of(v) {
|
||||
return Some(Paint::solid(c));
|
||||
}
|
||||
let mut p: Paint = serde_json::from_value(v.clone()).ok()?;
|
||||
p.opacity = if p.opacity.is_finite() { p.opacity.clamp(0.0, 1.0) } else { 1.0 };
|
||||
Some(p)
|
||||
}
|
||||
|
||||
pub fn paints_of(v: &Value) -> Option<Vec<Paint>> {
|
||||
match v {
|
||||
Value::Array(a) => a.iter().map(paint_of).collect(),
|
||||
other => paint_of(other).map(|p| vec![p]),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn blend(p: &Value, key: &str) -> Option<BlendMode> {
|
||||
string(p, key).and_then(|s| BlendMode::from_name(&s))
|
||||
}
|
||||
|
||||
/// Serde-deserialize `key` leniently into `T`.
|
||||
pub fn parse<T: serde::de::DeserializeOwned>(cmd: &str, p: &Value, key: &str) -> Result<Option<T>> {
|
||||
match get(p, key) {
|
||||
None => Ok(None),
|
||||
Some(v) => serde_json::from_value(v.clone()).map(Some).map_err(|e| bad(cmd, format!("`{key}`: {e}"))),
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
//! Snapping and smart guides: while moving or resizing, edges and centres snap to nearby layers'
|
||||
//! edges/centres, the parent frame, layout grid columns and page guides; the matches come back as
|
||||
//! guide lines (with spacing labels) for the canvas to draw in red.
|
||||
|
||||
use figcraft_doc::{Document, NodeId, NodeKind};
|
||||
use figcraft_geom::Rect;
|
||||
use serde::Serialize;
|
||||
|
||||
/// A guide line to draw: from (x0, y0) to (x1, y1) in page coordinates, with an optional label
|
||||
/// (a distance) at its middle.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct Guide {
|
||||
pub x0: f64,
|
||||
pub y0: f64,
|
||||
pub x1: f64,
|
||||
pub y1: f64,
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
/// Snap candidates gathered once at the start of a gesture.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Targets {
|
||||
/// Vertical lines (x) with the y-span of the rect they came from.
|
||||
pub xs: Vec<(f64, f64, f64)>,
|
||||
/// Horizontal lines (y) with the x-span.
|
||||
pub ys: Vec<(f64, f64, f64)>,
|
||||
/// Sibling boxes (for spacing labels).
|
||||
pub boxes: Vec<Rect>,
|
||||
}
|
||||
|
||||
impl Targets {
|
||||
/// Gather snap lines from the siblings of `moving` (and the parent frame), page guides and
|
||||
/// layout grids, excluding the moving nodes and their descendants.
|
||||
pub fn gather(doc: &Document, page: NodeId, moving: &[NodeId]) -> Targets {
|
||||
let mut t = Targets::default();
|
||||
let push = |r: Rect, t: &mut Targets| {
|
||||
for x in [r.x0, r.center().x, r.x1] {
|
||||
t.xs.push((x, r.y0, r.y1));
|
||||
}
|
||||
for y in [r.y0, r.center().y, r.y1] {
|
||||
t.ys.push((y, r.x0, r.x1));
|
||||
}
|
||||
t.boxes.push(r);
|
||||
};
|
||||
let parent = moving.first().and_then(|m| doc.node(*m)).and_then(|n| n.parent).unwrap_or(page);
|
||||
// The parent frame's own box (and its layout grids).
|
||||
if parent != page
|
||||
&& let Some(b) = doc.world_bounds(parent)
|
||||
{
|
||||
for x in [b.x0, b.center().x, b.x1] {
|
||||
t.xs.push((x, b.y0, b.y1));
|
||||
}
|
||||
for y in [b.y0, b.center().y, b.y1] {
|
||||
t.ys.push((y, b.x0, b.x1));
|
||||
}
|
||||
let (gx, gy) = figcraft_layout::grid_snap_lines(doc, parent);
|
||||
for x in gx {
|
||||
t.xs.push((x, b.y0, b.y1));
|
||||
}
|
||||
for y in gy {
|
||||
t.ys.push((y, b.x0, b.x1));
|
||||
}
|
||||
}
|
||||
// Siblings and, at the top level, all top-level layers.
|
||||
if let Some(p) = doc.node(parent) {
|
||||
for c in &p.children {
|
||||
if moving.contains(c) || !doc.node(*c).is_some_and(|n| n.visible) {
|
||||
continue;
|
||||
}
|
||||
if let Some(b) = doc.world_bounds(*c) {
|
||||
push(b, &mut t);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(NodeKind::Page { guides, .. }) = doc.node(page).map(|n| &n.kind) {
|
||||
for g in guides {
|
||||
if g.vertical {
|
||||
t.xs.push((g.offset, -1e7, 1e7));
|
||||
} else {
|
||||
t.ys.push((g.offset, -1e7, 1e7));
|
||||
}
|
||||
}
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// Snap a moving box: returns the (dx, dy) correction and guides to draw.
|
||||
pub fn snap_rect(&self, r: Rect, threshold: f64) -> (f64, f64, Vec<Guide>) {
|
||||
let xs = [r.x0, r.center().x, r.x1];
|
||||
let ys = [r.y0, r.center().y, r.y1];
|
||||
let best = |cands: &[(f64, f64, f64)], vals: &[f64]| -> Option<f64> {
|
||||
let mut best: Option<f64> = None;
|
||||
for (line, _, _) in cands {
|
||||
for v in vals {
|
||||
let d = line - v;
|
||||
if d.abs() <= threshold && best.is_none_or(|b| d.abs() < b.abs()) {
|
||||
best = Some(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
};
|
||||
let dx = best(&self.xs, &xs).unwrap_or(0.0);
|
||||
let dy = best(&self.ys, &ys).unwrap_or(0.0);
|
||||
let snapped = Rect::new(r.x0 + dx, r.y0 + dy, r.x1 + dx, r.y1 + dy);
|
||||
let guides = self.guides_for(snapped);
|
||||
(dx, dy, guides)
|
||||
}
|
||||
|
||||
/// Snap a single point (resize handles, drawing).
|
||||
pub fn snap_point(&self, x: f64, y: f64, threshold: f64) -> (f64, f64, Vec<Guide>) {
|
||||
let mut bx: Option<f64> = None;
|
||||
for (line, _, _) in &self.xs {
|
||||
let d = line - x;
|
||||
if d.abs() <= threshold && bx.is_none_or(|b| d.abs() < b.abs()) {
|
||||
bx = Some(d);
|
||||
}
|
||||
}
|
||||
let mut by: Option<f64> = None;
|
||||
for (line, _, _) in &self.ys {
|
||||
let d = line - y;
|
||||
if d.abs() <= threshold && by.is_none_or(|b| d.abs() < b.abs()) {
|
||||
by = Some(d);
|
||||
}
|
||||
}
|
||||
let (nx, ny) = (x + bx.unwrap_or(0.0), y + by.unwrap_or(0.0));
|
||||
let mut g = Vec::new();
|
||||
if bx.is_some() {
|
||||
let span = self.xs.iter().filter(|(l, _, _)| (l - nx).abs() < 1e-6).fold((ny, ny), |a, (_, y0, y1)| (a.0.min(*y0), a.1.max(*y1)));
|
||||
g.push(Guide { x0: nx, y0: span.0, x1: nx, y1: span.1, label: None });
|
||||
}
|
||||
if by.is_some() {
|
||||
let span = self.ys.iter().filter(|(l, _, _)| (l - ny).abs() < 1e-6).fold((nx, nx), |a, (_, x0, x1)| (a.0.min(*x0), a.1.max(*x1)));
|
||||
g.push(Guide { x0: span.0, y0: ny, x1: span.1, y1: ny, label: None });
|
||||
}
|
||||
(bx.unwrap_or(0.0), by.unwrap_or(0.0), g)
|
||||
}
|
||||
|
||||
/// Guide lines for edges of `r` that coincide with targets.
|
||||
pub fn guides_for(&self, r: Rect) -> Vec<Guide> {
|
||||
let mut out = Vec::new();
|
||||
for x in [r.x0, r.center().x, r.x1] {
|
||||
let hits: Vec<&(f64, f64, f64)> = self.xs.iter().filter(|(l, _, _)| (l - x).abs() < 1e-6).collect();
|
||||
if !hits.is_empty() {
|
||||
let y0 = hits.iter().map(|h| h.1).fold(r.y0, f64::min).max(-1e6);
|
||||
let y1 = hits.iter().map(|h| h.2).fold(r.y1, f64::max).min(1e6);
|
||||
out.push(Guide { x0: x, y0, x1: x, y1, label: None });
|
||||
}
|
||||
}
|
||||
for y in [r.y0, r.center().y, r.y1] {
|
||||
let hits: Vec<&(f64, f64, f64)> = self.ys.iter().filter(|(l, _, _)| (l - y).abs() < 1e-6).collect();
|
||||
if !hits.is_empty() {
|
||||
let x0 = hits.iter().map(|h| h.1).fold(r.x0, f64::min).max(-1e6);
|
||||
let x1 = hits.iter().map(|h| h.2).fold(r.x1, f64::max).min(1e6);
|
||||
out.push(Guide { x0, y0: y, x1, y1: y, label: None });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Distance lines from `r` to the nearest box on each side (Alt-hover measuring), or to `other`.
|
||||
pub fn measure(r: Rect, other: Rect) -> Vec<Guide> {
|
||||
let mut out = Vec::new();
|
||||
let fmt = |d: f64| {
|
||||
let v = (d * 100.0).round() / 100.0;
|
||||
if v.fract() == 0.0 { format!("{}", v as i64) } else { format!("{v}") }
|
||||
};
|
||||
if other.contains(r.origin()) && other.contains(figcraft_geom::Point::new(r.x1, r.y1)) {
|
||||
// Inside: distances to each edge of the container.
|
||||
let cy = r.center().y;
|
||||
let cx = r.center().x;
|
||||
out.push(Guide { x0: other.x0, y0: cy, x1: r.x0, y1: cy, label: Some(fmt(r.x0 - other.x0)) });
|
||||
out.push(Guide { x0: r.x1, y0: cy, x1: other.x1, y1: cy, label: Some(fmt(other.x1 - r.x1)) });
|
||||
out.push(Guide { x0: cx, y0: other.y0, x1: cx, y1: r.y0, label: Some(fmt(r.y0 - other.y0)) });
|
||||
out.push(Guide { x0: cx, y0: r.y1, x1: cx, y1: other.y1, label: Some(fmt(other.y1 - r.y1)) });
|
||||
return out;
|
||||
}
|
||||
let cy = (r.y0.max(other.y0) + r.y1.min(other.y1)) / 2.0;
|
||||
let cx = (r.x0.max(other.x0) + r.x1.min(other.x1)) / 2.0;
|
||||
let cy = if r.y0.max(other.y0) <= r.y1.min(other.y1) { cy } else { r.center().y };
|
||||
let cx = if r.x0.max(other.x0) <= r.x1.min(other.x1) { cx } else { r.center().x };
|
||||
if other.x0 >= r.x1 {
|
||||
out.push(Guide { x0: r.x1, y0: cy, x1: other.x0, y1: cy, label: Some(fmt(other.x0 - r.x1)) });
|
||||
} else if other.x1 <= r.x0 {
|
||||
out.push(Guide { x0: other.x1, y0: cy, x1: r.x0, y1: cy, label: Some(fmt(r.x0 - other.x1)) });
|
||||
}
|
||||
if other.y0 >= r.y1 {
|
||||
out.push(Guide { x0: cx, y0: r.y1, x1: cx, y1: other.y0, label: Some(fmt(other.y0 - r.y1)) });
|
||||
} else if other.y1 <= r.y0 {
|
||||
out.push(Guide { x0: cx, y0: other.y1, x1: cx, y1: r.y0, label: Some(fmt(r.y0 - other.y1)) });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use figcraft_doc::NodeKind;
|
||||
|
||||
fn sess() -> Session {
|
||||
Session::new()
|
||||
}
|
||||
|
||||
fn id(v: &Value) -> NodeId {
|
||||
params::id_of(&v["id"]).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_select_undo_redo() {
|
||||
let mut s = sess();
|
||||
let r = s.execute("node.create", &json!({"type": "rectangle", "x": 10, "y": 20, "width": 30, "height": 40})).unwrap();
|
||||
let rid = id(&r);
|
||||
assert_eq!(s.selection(), vec![rid]);
|
||||
assert_eq!(s.doc().unwrap().world_bounds(rid).unwrap().x0, 10.0);
|
||||
assert!(s.undo().unwrap());
|
||||
assert!(!s.doc().unwrap().contains(rid));
|
||||
assert!(s.redo().unwrap());
|
||||
assert!(s.doc().unwrap().contains(rid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_set_and_errors_roll_back() {
|
||||
let mut s = sess();
|
||||
let rid = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
s.execute("node.set", &json!({"id": rid, "props": {"fill": "#ff0000", "cornerRadius": 4, "opacity": 0.5, "name": "Red"}})).unwrap();
|
||||
let n = s.doc().unwrap().node(rid).unwrap().clone();
|
||||
assert_eq!(n.name, "Red");
|
||||
assert_eq!(n.corners.radii, [4.0; 4]);
|
||||
assert_eq!(n.fills[0].solid_color().unwrap().to_hex(), "FF0000");
|
||||
let undo = s.ds().unwrap().history.undo.len();
|
||||
assert!(s.execute("node.set", &json!({"id": rid, "props": {"name": "X", "bogus": 1}})).is_err());
|
||||
assert_eq!(s.doc().unwrap().node(rid).unwrap().name, "Red");
|
||||
assert_eq!(s.ds().unwrap().history.undo.len(), undo);
|
||||
assert!(s.execute("no.such", &json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_ungroup_and_frame() {
|
||||
let mut s = sess();
|
||||
let a = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
let b = id(&s.execute("shape.ellipse", &json!({"x": 50, "y": 50, "width": 10, "height": 10})).unwrap());
|
||||
let g = id(&s.execute("object.group", &json!({"ids": [a, b]})).unwrap());
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(doc.node(g).unwrap().kind.type_name(), "GROUP");
|
||||
assert_eq!(doc.world_bounds(g).unwrap().width(), 60.0);
|
||||
s.execute("object.ungroup", &json!({"ids": [g]})).unwrap();
|
||||
assert!(!s.doc().unwrap().contains(g));
|
||||
let f = id(&s.execute("object.frameSelection", &json!({"ids": [a, b]})).unwrap());
|
||||
assert_eq!(s.doc().unwrap().node(a).unwrap().parent, Some(f));
|
||||
assert_eq!(s.doc().unwrap().world_bounds(a).unwrap().x0, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_layout_add_hugs() {
|
||||
let mut s = sess();
|
||||
let a = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 20})).unwrap());
|
||||
let b = id(&s.execute("shape.rectangle", &json!({"x": 60, "y": 0, "width": 40, "height": 20})).unwrap());
|
||||
let r = s.execute("layout.add", &json!({"ids": [a, b]})).unwrap();
|
||||
let f = params::id_of(&r["ids"][0]).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let fnode = doc.node(f).unwrap();
|
||||
assert!(fnode.layout.is_some());
|
||||
assert_eq!(fnode.width, 100.0);
|
||||
// Change gap: frame hugs.
|
||||
s.execute("node.set", &json!({"id": f, "props": {"gap": 0}})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(f).unwrap().width, 80.0);
|
||||
// Padding.
|
||||
s.execute("node.set", &json!({"id": f, "props": {"padding": 10}})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(f).unwrap().width, 100.0);
|
||||
assert_eq!(s.doc().unwrap().node(f).unwrap().height, 40.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_create_autosize_and_edit() {
|
||||
let mut s = sess();
|
||||
let t = id(&s.execute("text.create", &json!({"text": "Hello", "x": 0, "y": 0, "props": {"fontSize": 24}})).unwrap());
|
||||
let w0 = s.doc().unwrap().node(t).unwrap().width;
|
||||
textedit::begin(&mut s, t, Some(5), false).unwrap();
|
||||
textedit::insert(&mut s, " world").unwrap();
|
||||
textedit::key(&mut s, "Backspace", false, false, false).unwrap();
|
||||
textedit::end(&mut s).unwrap();
|
||||
let n = s.doc().unwrap().node(t).unwrap().clone();
|
||||
assert_eq!(n.kind.text().unwrap().characters, "Hello worl");
|
||||
if figcraft_text::find_face("Inter", "Regular").is_some() {
|
||||
assert!(n.width > w0);
|
||||
}
|
||||
// One undo step for the whole edit session.
|
||||
s.undo().unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "Hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn components_and_instances_sync() {
|
||||
let mut s = sess();
|
||||
let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 50, "name": "Button"})).unwrap());
|
||||
let label = id(&s.execute("text.create", &json!({"text": "OK", "x": 10, "y": 10, "parent": f})).unwrap());
|
||||
let c = id(&s.execute("component.create", &json!({"ids": [f]})).unwrap());
|
||||
assert_eq!(c, f);
|
||||
let i = id(&s.execute("instance.create", &json!({"component": c, "x": 200, "y": 0})).unwrap());
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(doc.node(i).unwrap().children.len(), 1);
|
||||
let inst_label = doc.node(i).unwrap().children[0];
|
||||
// Edit the main: instance follows.
|
||||
s.execute("node.set", &json!({"id": c, "props": {"fill": "#00ff00"}})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(i).unwrap().fills[0].solid_color().unwrap().to_hex(), "00FF00");
|
||||
// Override text in the instance, then change main text: override wins.
|
||||
s.execute("text.set", &json!({"id": inst_label, "characters": "Cancel"})).unwrap();
|
||||
s.execute("text.set", &json!({"id": label, "characters": "Yes"})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let kid = doc.node(i).unwrap().children[0];
|
||||
assert_eq!(kid, inst_label, "ids are stable across syncs");
|
||||
assert_eq!(doc.node(kid).unwrap().kind.text().unwrap().characters, "Cancel");
|
||||
// Reset clears overrides.
|
||||
s.execute("component.resetInstance", &json!({"ids": [i]})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let kid = doc.node(i).unwrap().children[0];
|
||||
assert_eq!(doc.node(kid).unwrap().kind.text().unwrap().characters, "Yes");
|
||||
// Detach.
|
||||
s.execute("component.detach", &json!({"ids": [i]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(i).unwrap().kind.type_name(), "FRAME");
|
||||
assert!(s.doc().unwrap().validate().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variants_switch() {
|
||||
let mut s = sess();
|
||||
let a = id(&s.execute("node.create", &json!({"type": "component", "x": 0, "y": 0, "width": 40, "height": 40, "name": "State=Default", "props": {"fill": "#ff0000"}})).unwrap());
|
||||
let b = id(&s.execute("node.create", &json!({"type": "component", "x": 60, "y": 0, "width": 40, "height": 40, "name": "State=Hover", "props": {"fill": "#0000ff"}})).unwrap());
|
||||
s.execute("component.combineVariants", &json!({"ids": [a, b]})).unwrap();
|
||||
let i = id(&s.execute("instance.create", &json!({"component": a, "x": 0, "y": 200})).unwrap());
|
||||
s.execute("instance.setProperties", &json!({"ids": [i], "properties": {"State": "Hover"}})).unwrap();
|
||||
let n = s.doc().unwrap().node(i).unwrap().clone();
|
||||
assert!(matches!(n.kind, NodeKind::Instance { component, .. } if component == b));
|
||||
assert_eq!(n.fills[0].solid_color().unwrap().to_hex(), "0000FF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styles_and_variables() {
|
||||
let mut s = sess();
|
||||
let r = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10, "props": {"fill": "#123456"}})).unwrap());
|
||||
let st = s.execute("style.create", &json!({"type": "paint", "name": "Brand/Primary", "from": r})).unwrap();
|
||||
let sid = st["id"].as_str().unwrap().to_string();
|
||||
let r2 = id(&s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
s.execute("style.apply", &json!({"ids": [r2], "style": "Brand/Primary"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(r2).unwrap().fills[0].solid_color().unwrap().to_hex(), "123456");
|
||||
s.execute("style.update", &json!({"style": sid, "value": ["#ff00ff"]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(r2).unwrap().fills[0].solid_color().unwrap().to_hex(), "FF00FF");
|
||||
// Variables with modes.
|
||||
s.execute("variable.createCollection", &json!({"name": "Theme", "modes": ["Light", "Dark"]})).unwrap();
|
||||
s.execute("variable.create", &json!({"collection": "Theme", "name": "bg", "type": "color", "values": {"Light": "#ffffff", "Dark": "#000000"}})).unwrap();
|
||||
s.execute("variable.bind", &json!({"ids": [r], "field": "fill", "variable": "bg"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(r).unwrap().fills[0].solid_color().unwrap().to_hex(), "FFFFFF");
|
||||
let page = s.page().unwrap();
|
||||
s.execute("variable.setMode", &json!({"ids": [page], "collection": "Theme", "mode": "Dark"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(r).unwrap().fills[0].solid_color().unwrap().to_hex(), "000000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_distribute_zorder() {
|
||||
let mut s = sess();
|
||||
let a = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
let b = id(&s.execute("shape.rectangle", &json!({"x": 30, "y": 20, "width": 10, "height": 10})).unwrap());
|
||||
let c = id(&s.execute("shape.rectangle", &json!({"x": 100, "y": 5, "width": 10, "height": 10})).unwrap());
|
||||
s.execute("align.top", &json!({"ids": [a, b, c]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().world_bounds(b).unwrap().y0, 0.0);
|
||||
s.execute("distribute.horizontal", &json!({"ids": [a, b, c]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().world_bounds(b).unwrap().x0, 50.0);
|
||||
s.execute("arrange.back", &json!({"ids": [c]})).unwrap();
|
||||
let page = s.page().unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(page).unwrap().children[0], c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_paste_and_duplicate() {
|
||||
let mut s = sess();
|
||||
let a = id(&s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 10, "height": 10})).unwrap());
|
||||
s.execute("edit.copy", &json!({"ids": [a]})).unwrap();
|
||||
let r = s.execute("edit.paste", &json!({})).unwrap();
|
||||
let pid = params::id_of(&r["ids"][0]).unwrap();
|
||||
assert_ne!(pid, a);
|
||||
assert_eq!(s.doc().unwrap().world_bounds(pid).unwrap().x0, 5.0);
|
||||
let d = s.execute("edit.duplicate", &json!({"ids": [a]})).unwrap();
|
||||
assert_eq!(d["ids"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boolean_and_flatten() {
|
||||
let mut s = sess();
|
||||
let a = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap());
|
||||
let b = id(&s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap());
|
||||
let g = id(&s.execute("bool.union", &json!({"ids": [a, b]})).unwrap());
|
||||
assert_eq!(s.doc().unwrap().node(g).unwrap().kind.type_name(), "BOOLEAN_OPERATION");
|
||||
let v = id(&s.execute("vector.flatten", &json!({"ids": [g]})).unwrap());
|
||||
let n = s.doc().unwrap().node(v).unwrap().clone();
|
||||
assert_eq!(n.kind.type_name(), "VECTOR");
|
||||
assert_eq!((n.width, n.height), (30.0, 30.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pointer_draw_move_resize() {
|
||||
let mut s = sess();
|
||||
s.set_tool(ToolId::Rectangle);
|
||||
let z = 1.0;
|
||||
s.pointer(&PointerEvent::at(PointerKind::Down, 10.0, 10.0, z)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Move, 60.0, 40.0, z)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Up, 60.0, 40.0, z)).unwrap();
|
||||
let r = s.selection()[0];
|
||||
assert_eq!(s.doc().unwrap().world_bounds(r).unwrap(), figcraft_geom::Rect::new(10.0, 10.0, 60.0, 40.0));
|
||||
assert_eq!(s.tool.tool, ToolId::Move);
|
||||
let undo_len = s.ds().unwrap().history.undo.len();
|
||||
// Move it.
|
||||
s.pointer(&PointerEvent::at(PointerKind::Down, 30.0, 20.0, z)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Move, 130.0, 120.0, z)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Up, 130.0, 120.0, z)).unwrap();
|
||||
assert_eq!(s.doc().unwrap().world_bounds(r).unwrap().x0, 110.0);
|
||||
assert_eq!(s.ds().unwrap().history.undo.len(), undo_len + 1);
|
||||
// Resize from the bottom-right corner.
|
||||
s.pointer(&PointerEvent::at(PointerKind::Down, 160.0, 140.0, z)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Move, 210.0, 190.0, z)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Up, 210.0, 190.0, z)).unwrap();
|
||||
let b = s.doc().unwrap().world_bounds(r).unwrap();
|
||||
assert_eq!((b.width(), b.height()), (100.0, 80.0));
|
||||
// Marquee empty space deselects then selects.
|
||||
s.pointer(&PointerEvent::at(PointerKind::Down, 500.0, 500.0, z)).unwrap();
|
||||
assert!(s.selection().is_empty());
|
||||
s.pointer(&PointerEvent::at(PointerKind::Move, 100.0, 100.0, z)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Up, 100.0, 100.0, z)).unwrap();
|
||||
assert_eq!(s.selection(), vec![r]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_command_survives_junk_params() {
|
||||
// A junk-parameter sweep: no command may panic (guard turns panics into Internal errors,
|
||||
// which would fail this test).
|
||||
let junk = [json!({}), json!(null), json!({"ids": ["x", -1, 99999]}), json!({"id": "nope", "x": "NaN", "props": 5}), json!([1, 2, 3])];
|
||||
for spec in all_commands() {
|
||||
if matches!(spec.id, "file.open" | "file.save" | "file.saveAs" | "file.import" | "file.export" | "file.exportSvg" | "file.placeImage" | "image.place" | "file.revert") {
|
||||
continue;
|
||||
}
|
||||
for j in &junk {
|
||||
let mut s = sess();
|
||||
let _ = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10}));
|
||||
let _ = s.execute("text.create", &json!({"text": "t"}));
|
||||
if let Err(EngineError::Internal { cmd, msg }) = s.execute(spec.id, j) {
|
||||
panic!("{cmd} panicked on {j}: {msg}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parity_catalog_mostly_covered() {
|
||||
let (done, total, missing) = catalog::parity(&[]);
|
||||
assert!(total > 150);
|
||||
assert!(done as f64 / total as f64 > 0.8, "{done}/{total} missing: {missing:?}");
|
||||
}
|
||||
|
|
@ -0,0 +1,263 @@
|
|||
//! On-canvas text editing: caret, selection, typing. A whole editing session is one undo step.
|
||||
|
||||
use figcraft_doc::{NodeId, NodeKind, TextData};
|
||||
|
||||
use crate::{EngineError, Result, Session, TextEdit};
|
||||
|
||||
fn text_of(s: &Session, id: NodeId) -> Result<TextData> {
|
||||
match &s.doc()?.get(id)?.kind {
|
||||
NodeKind::Text(t) => Ok(t.clone()),
|
||||
_ => Err(EngineError::Other(format!("{id} is not a text layer"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Start editing text node `id` with the caret at `caret` (None = select all).
|
||||
pub fn begin(s: &mut Session, id: NodeId, caret: Option<usize>, created: bool) -> Result<()> {
|
||||
let t = text_of(s, id)?;
|
||||
let n = t.char_count();
|
||||
s.begin_interaction("Edit text")?;
|
||||
let ds = s.ds_mut()?;
|
||||
let (caret, anchor) = match caret {
|
||||
Some(c) => (c.min(n), c.min(n)),
|
||||
None => (n, 0),
|
||||
};
|
||||
ds.text_edit = Some(TextEdit { id, caret, anchor, created });
|
||||
ds.selection = vec![id];
|
||||
ds.revision += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Finish editing: an empty new text layer is removed; the session becomes one undo step.
|
||||
pub fn end(s: &mut Session) -> Result<()> {
|
||||
let Some(te) = s.ds()?.text_edit.clone() else { return Ok(()) };
|
||||
s.ds_mut()?.text_edit = None;
|
||||
let empty = text_of(s, te.id).map(|t| t.characters.trim().is_empty()).unwrap_or(false);
|
||||
if empty {
|
||||
let doc = s.doc_mut()?;
|
||||
let _ = doc.remove(te.id);
|
||||
s.ds_mut()?.selection.clear();
|
||||
}
|
||||
s.commit_interaction()?;
|
||||
if empty && te.created {
|
||||
// Nothing to undo for a text box that was never filled.
|
||||
let ds = s.ds_mut()?;
|
||||
if let Some(last) = ds.history.undo.last()
|
||||
&& *last.doc == *ds.doc
|
||||
{
|
||||
ds.history.undo.pop();
|
||||
}
|
||||
}
|
||||
let ds = s.ds_mut()?;
|
||||
ds.revision += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn range(te: &TextEdit) -> (usize, usize) {
|
||||
(te.caret.min(te.anchor), te.caret.max(te.anchor))
|
||||
}
|
||||
|
||||
fn edit(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit)) -> Result<()> {
|
||||
let ds = s.ds_mut()?;
|
||||
let Some(mut te) = ds.text_edit.clone() else { return Err(EngineError::Other("not editing text".into())) };
|
||||
let before = ds.doc.clone();
|
||||
let doc = ds.doc_mut();
|
||||
let n = doc.get_mut(te.id)?;
|
||||
let NodeKind::Text(t) = &mut n.kind else { return Err(EngineError::Other("not a text layer".into())) };
|
||||
f(t, &mut te);
|
||||
let len = t.char_count();
|
||||
te.caret = te.caret.min(len);
|
||||
te.anchor = te.anchor.min(len);
|
||||
if n.source.is_some() {
|
||||
n.overrides.insert("characters".into());
|
||||
}
|
||||
crate::normalize(doc, &before);
|
||||
ds.text_edit = Some(te);
|
||||
ds.revision += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Type `text` replacing the selection.
|
||||
pub fn insert(s: &mut Session, text: &str) -> Result<()> {
|
||||
let text = text.replace("\r\n", "\n").replace('\r', "\n");
|
||||
edit(s, |t, te| {
|
||||
let (a, b) = range(te);
|
||||
t.replace_range(a, b, &text);
|
||||
te.caret = a + text.chars().count();
|
||||
te.anchor = te.caret;
|
||||
})
|
||||
}
|
||||
|
||||
fn is_word(c: char) -> bool {
|
||||
c.is_alphanumeric() || c == '_'
|
||||
}
|
||||
|
||||
fn word_left(chars: &[char], mut i: usize) -> usize {
|
||||
while i > 0 && chars.get(i - 1).is_some_and(|c| !is_word(*c)) {
|
||||
i -= 1;
|
||||
}
|
||||
while i > 0 && chars.get(i - 1).is_some_and(|c| is_word(*c)) {
|
||||
i -= 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
fn word_right(chars: &[char], mut i: usize) -> usize {
|
||||
let n = chars.len();
|
||||
while i < n && chars.get(i).is_some_and(|c| !is_word(*c)) {
|
||||
i += 1;
|
||||
}
|
||||
while i < n && chars.get(i).is_some_and(|c| is_word(*c)) {
|
||||
i += 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// A named key: Backspace, Delete, Left, Right, Up, Down, Home, End, Enter, Escape, SelectAll.
|
||||
/// `word` is Alt (word-wise), `line` is Cmd (line-wise).
|
||||
pub fn key(s: &mut Session, key: &str, shift: bool, word: bool, line: bool) -> Result<()> {
|
||||
let Some(te) = s.ds()?.text_edit.clone() else { return Err(EngineError::Other("not editing text".into())) };
|
||||
let t = text_of(s, te.id)?;
|
||||
let chars: Vec<char> = t.characters.chars().collect();
|
||||
let n = chars.len();
|
||||
match key {
|
||||
"Escape" => return end(s),
|
||||
"Enter" => return insert(s, "\n"),
|
||||
"Tab" => return insert(s, "\t"),
|
||||
"Backspace" | "Delete" => {
|
||||
return edit(s, |t, te| {
|
||||
let (a, b) = range(te);
|
||||
let (a, b) = if a != b {
|
||||
(a, b)
|
||||
} else if key == "Backspace" {
|
||||
let from = if line { line_start(&chars, a) } else if word { word_left(&chars, a) } else { a.saturating_sub(1) };
|
||||
(from, a)
|
||||
} else {
|
||||
let to = if line { line_end(&chars, a) } else if word { word_right(&chars, a) } else { (a + 1).min(n) };
|
||||
(a, to)
|
||||
};
|
||||
t.replace_range(a, b, "");
|
||||
te.caret = a;
|
||||
te.anchor = a;
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Caret movement.
|
||||
let layout = {
|
||||
let doc = s.doc()?;
|
||||
let node = doc.get(te.id)?;
|
||||
figcraft_text::layout_node(node).map(|(l, _)| l)
|
||||
};
|
||||
let (a, b) = range(&te);
|
||||
let cur = te.caret;
|
||||
let next = match key {
|
||||
"Left" if !shift && a != b => a,
|
||||
"Right" if !shift && a != b => b,
|
||||
"Left" if line => line_start(&chars, cur),
|
||||
"Right" if line => line_end(&chars, cur),
|
||||
"Left" if word => word_left(&chars, cur),
|
||||
"Right" if word => word_right(&chars, cur),
|
||||
"Left" => cur.saturating_sub(1),
|
||||
"Right" => (cur + 1).min(n),
|
||||
"Home" => line_start(&chars, cur),
|
||||
"End" => line_end(&chars, cur),
|
||||
"Up" | "Down" => match &layout {
|
||||
_ if line => {
|
||||
if key == "Up" {
|
||||
0
|
||||
} else {
|
||||
n
|
||||
}
|
||||
}
|
||||
Some(l) => {
|
||||
let (x, li) = l.caret(cur);
|
||||
let target = if key == "Up" { li.checked_sub(1) } else { Some(li + 1).filter(|k| *k < l.lines.len()) };
|
||||
match target.and_then(|k| l.lines.get(k)) {
|
||||
Some(line) => l.hit(x, line.top + line.height / 2.0),
|
||||
None => {
|
||||
if key == "Up" {
|
||||
0
|
||||
} else {
|
||||
n
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None => cur,
|
||||
},
|
||||
"SelectAll" => {
|
||||
let ds = s.ds_mut()?;
|
||||
if let Some(te) = ds.text_edit.as_mut() {
|
||||
te.anchor = 0;
|
||||
te.caret = n;
|
||||
}
|
||||
ds.revision += 1;
|
||||
return Ok(());
|
||||
}
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let ds = s.ds_mut()?;
|
||||
if let Some(te) = ds.text_edit.as_mut() {
|
||||
te.caret = next;
|
||||
if !shift {
|
||||
te.anchor = next;
|
||||
}
|
||||
}
|
||||
ds.revision += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn line_start(chars: &[char], mut i: usize) -> usize {
|
||||
while i > 0 && chars.get(i - 1).is_some_and(|c| *c != '\n') {
|
||||
i -= 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
fn line_end(chars: &[char], mut i: usize) -> usize {
|
||||
while i < chars.len() && chars.get(i).is_some_and(|c| *c != '\n') {
|
||||
i += 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// Place the caret (and optionally extend the selection) at char `index`.
|
||||
pub fn set_caret(s: &mut Session, index: usize, extend: bool) -> Result<()> {
|
||||
let ds = s.ds_mut()?;
|
||||
if let Some(te) = ds.text_edit.as_mut() {
|
||||
te.caret = index;
|
||||
if !extend {
|
||||
te.anchor = index;
|
||||
}
|
||||
}
|
||||
ds.revision += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Select the word at `index` (double-click).
|
||||
pub fn select_word(s: &mut Session, index: usize) -> Result<()> {
|
||||
let Some(te) = s.ds()?.text_edit.clone() else { return Ok(()) };
|
||||
let chars: Vec<char> = text_of(s, te.id)?.characters.chars().collect();
|
||||
let mut a = index.min(chars.len());
|
||||
let mut b = a;
|
||||
while a > 0 && chars.get(a - 1).is_some_and(|c| is_word(*c)) {
|
||||
a -= 1;
|
||||
}
|
||||
while b < chars.len() && chars.get(b).is_some_and(|c| is_word(*c)) {
|
||||
b += 1;
|
||||
}
|
||||
let ds = s.ds_mut()?;
|
||||
if let Some(te) = ds.text_edit.as_mut() {
|
||||
te.anchor = a;
|
||||
te.caret = b;
|
||||
}
|
||||
ds.revision += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The selected range while editing (for styling commands), or None.
|
||||
pub fn selected_range(s: &Session) -> Option<(NodeId, usize, usize)> {
|
||||
let te = s.ds().ok()?.text_edit.clone()?;
|
||||
let (a, b) = range(&te);
|
||||
Some((te.id, a, b))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,17 @@
|
|||
[package]
|
||||
name = "figcraft-format"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "FigCraft native file format (.figcraft: zip of document.json + images) and image import helpers"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
figcraft-doc = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
image = { workspace = true }
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
//! The native `.figcraft` format.
|
||||
//!
|
||||
//! A zip archive holding `document.json` (the [`Document`] as JSON, with a `format`/`version`
|
||||
//! header), `images/<sha256>` for every image the document uses, and an optional
|
||||
//! `thumbnail.png`. Plain JSON (the `document.json` alone) also opens. Reading is lenient:
|
||||
//! structural problems are repaired (see [`Document::repair`]) and reported.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_doc::{Document, ImageData};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::Digest;
|
||||
|
||||
pub const EXTENSION: &str = "figcraft";
|
||||
pub const FORMAT: &str = "figcraft";
|
||||
pub const VERSION: u32 = 1;
|
||||
/// Largest archive member we read (guards against zip bombs).
|
||||
const MAX_MEMBER: u64 = 512 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum FormatError {
|
||||
Io(String),
|
||||
Corrupt(String),
|
||||
Newer(u32),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for FormatError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
FormatError::Io(e) => write!(f, "{e}"),
|
||||
FormatError::Corrupt(e) => write!(f, "not a valid FigCraft file: {e}"),
|
||||
FormatError::Newer(v) => write!(f, "this file was saved by a newer FigCraft (format version {v})"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for FormatError {}
|
||||
|
||||
/// Content hash used as an image's key.
|
||||
pub fn image_hash(bytes: &[u8]) -> String {
|
||||
let d = sha2::Sha256::digest(bytes);
|
||||
d.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Register encoded image bytes with the document; returns the hash and pixel size.
|
||||
pub fn add_image(doc: &mut Document, bytes: Vec<u8>) -> Result<(String, u32, u32), FormatError> {
|
||||
let (w, h) = image::ImageReader::new(std::io::Cursor::new(&bytes))
|
||||
.with_guessed_format()
|
||||
.map_err(|e| FormatError::Corrupt(e.to_string()))?
|
||||
.into_dimensions()
|
||||
.map_err(|e| FormatError::Corrupt(format!("unsupported image: {e}")))?;
|
||||
let hash = image_hash(&bytes);
|
||||
doc.images.insert(hash.clone(), ImageData { bytes: Arc::new(bytes), width: w, height: h });
|
||||
Ok((hash, w, h))
|
||||
}
|
||||
|
||||
/// Serialize the document (without images) to the JSON value stored in `document.json`.
|
||||
pub fn to_json(doc: &Document) -> Result<Value, FormatError> {
|
||||
let mut v = serde_json::to_value(doc).map_err(|e| FormatError::Io(e.to_string()))?;
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("format".into(), json!(FORMAT));
|
||||
o.insert("version".into(), json!(VERSION));
|
||||
let images: Vec<Value> = doc.images.iter().map(|(k, i)| json!({"hash": k, "width": i.width, "height": i.height})).collect();
|
||||
o.insert("imageIndex".into(), Value::Array(images));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Parse `document.json` content.
|
||||
pub fn from_json(text: &str) -> Result<(Document, Vec<String>), FormatError> {
|
||||
let v: Value = serde_json::from_str(text).map_err(|e| FormatError::Corrupt(e.to_string()))?;
|
||||
if let Some(ver) = v.get("version").and_then(Value::as_u64)
|
||||
&& ver > u64::from(VERSION)
|
||||
{
|
||||
return Err(FormatError::Newer(ver as u32));
|
||||
}
|
||||
let mut doc: Document = serde_json::from_value(v).map_err(|e| FormatError::Corrupt(e.to_string()))?;
|
||||
let issues = doc.validate();
|
||||
if !issues.is_empty() {
|
||||
doc.repair();
|
||||
}
|
||||
Ok((doc, issues))
|
||||
}
|
||||
|
||||
/// Write a `.figcraft` archive to bytes.
|
||||
pub fn save_bytes(doc: &Document, thumbnail_png: Option<&[u8]>) -> Result<Vec<u8>, FormatError> {
|
||||
let mut buf = std::io::Cursor::new(Vec::new());
|
||||
{
|
||||
let mut z = zip::ZipWriter::new(&mut buf);
|
||||
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
z.start_file("document.json", opts).map_err(|e| FormatError::Io(e.to_string()))?;
|
||||
let json = serde_json::to_vec(&to_json(doc)?).map_err(|e| FormatError::Io(e.to_string()))?;
|
||||
z.write_all(&json).map_err(|e| FormatError::Io(e.to_string()))?;
|
||||
for (hash, img) in &doc.images {
|
||||
z.start_file(format!("images/{hash}"), stored).map_err(|e| FormatError::Io(e.to_string()))?;
|
||||
z.write_all(&img.bytes).map_err(|e| FormatError::Io(e.to_string()))?;
|
||||
}
|
||||
if let Some(t) = thumbnail_png {
|
||||
z.start_file("thumbnail.png", stored).map_err(|e| FormatError::Io(e.to_string()))?;
|
||||
z.write_all(t).map_err(|e| FormatError::Io(e.to_string()))?;
|
||||
}
|
||||
z.finish().map_err(|e| FormatError::Io(e.to_string()))?;
|
||||
}
|
||||
Ok(buf.into_inner())
|
||||
}
|
||||
|
||||
/// Read a `.figcraft` archive (or bare JSON) from bytes. Returns the document and any repairs made.
|
||||
pub fn load_bytes(bytes: &[u8]) -> Result<(Document, Vec<String>), FormatError> {
|
||||
let trimmed = bytes.iter().position(|b| !b.is_ascii_whitespace()).and_then(|i| bytes.get(i));
|
||||
if trimmed == Some(&b'{') {
|
||||
let text = std::str::from_utf8(bytes).map_err(|e| FormatError::Corrupt(e.to_string()))?;
|
||||
return from_json(text);
|
||||
}
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| FormatError::Corrupt(e.to_string()))?;
|
||||
let text = {
|
||||
let f = z.by_name("document.json").map_err(|e| FormatError::Corrupt(e.to_string()))?;
|
||||
let mut s = String::new();
|
||||
f.take(MAX_MEMBER).read_to_string(&mut s).map_err(|e| FormatError::Corrupt(e.to_string()))?;
|
||||
s
|
||||
};
|
||||
let (mut doc, issues) = from_json(&text)?;
|
||||
let names: Vec<String> = z.file_names().filter(|n| n.starts_with("images/")).map(str::to_string).collect();
|
||||
for name in names {
|
||||
let Ok(f) = z.by_name(&name) else { continue };
|
||||
let mut data = Vec::new();
|
||||
if f.take(MAX_MEMBER).read_to_end(&mut data).is_err() {
|
||||
continue;
|
||||
}
|
||||
let hash = name.trim_start_matches("images/").to_string();
|
||||
let (w, h) = image::ImageReader::new(std::io::Cursor::new(&data)).with_guessed_format().ok().and_then(|r| r.into_dimensions().ok()).unwrap_or((0, 0));
|
||||
doc.images.insert(hash, ImageData { bytes: Arc::new(data), width: w, height: h });
|
||||
}
|
||||
Ok((doc, issues))
|
||||
}
|
||||
|
||||
/// Save atomically: write a temp file next to `path`, then rename over it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn save_file(doc: &Document, path: &std::path::Path, thumbnail_png: Option<&[u8]>) -> Result<(), FormatError> {
|
||||
let bytes = save_bytes(doc, thumbnail_png)?;
|
||||
let tmp = path.with_extension("figcraft.tmp");
|
||||
std::fs::write(&tmp, &bytes).map_err(|e| FormatError::Io(format!("{}: {e}", tmp.display())))?;
|
||||
std::fs::rename(&tmp, path).map_err(|e| FormatError::Io(format!("{}: {e}", path.display())))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn load_file(path: &std::path::Path) -> Result<(Document, Vec<String>), FormatError> {
|
||||
let bytes = std::fs::read(path).map_err(|e| FormatError::Io(format!("{}: {e}", path.display())))?;
|
||||
load_bytes(&bytes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use figcraft_doc::NodeKind;
|
||||
|
||||
fn png() -> Vec<u8> {
|
||||
let img = image::RgbaImage::from_pixel(3, 2, image::Rgba([255, 0, 0, 255]));
|
||||
let mut out = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_with_images() {
|
||||
let mut d = Document::new("My file");
|
||||
let p = d.first_page().unwrap();
|
||||
d.create(p, NodeKind::Rectangle, "R", 1.0, 2.0, 3.0, 4.0).unwrap();
|
||||
let (hash, w, h) = add_image(&mut d, png()).unwrap();
|
||||
assert_eq!((w, h), (3, 2));
|
||||
let bytes = save_bytes(&d, None).unwrap();
|
||||
let (back, issues) = load_bytes(&bytes).unwrap();
|
||||
assert!(issues.is_empty());
|
||||
assert_eq!(back.name, "My file");
|
||||
assert_eq!(back.node_count(), d.node_count());
|
||||
assert!(back.images.contains_key(&hash));
|
||||
// Bare JSON opens too.
|
||||
let json = serde_json::to_vec(&to_json(&d).unwrap()).unwrap();
|
||||
assert!(load_bytes(&json).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_is_an_error() {
|
||||
assert!(load_bytes(b"not a zip").is_err());
|
||||
assert!(load_bytes(b"{\"nope\":1}").is_err());
|
||||
assert!(matches!(load_bytes(b"{\"version\":999}"), Err(FormatError::Newer(999))));
|
||||
assert!(add_image(&mut Document::new("x"), b"zzz".to_vec()).is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
[package]
|
||||
name = "figcraft-layout"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "FigCraft layout: auto layout (horizontal, vertical, wrap, grid; hug/fill/fixed), constraints, layout grid geometry"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
figcraft-geom = { workspace = true }
|
||||
figcraft-doc = { workspace = true }
|
||||
|
|
@ -0,0 +1,577 @@
|
|||
//! FigCraft layout.
|
||||
//!
|
||||
//! - [`layout_page`] / [`layout_frame`]: Figma-style auto layout. Children of an auto layout frame
|
||||
//! are stacked horizontally or vertically (optionally wrapping) or placed on a grid, with gap,
|
||||
//! padding and alignment; each child is Fixed, Hug (its own content) or Fill (shares the free
|
||||
//! space). A Hug container sizes to its children. Absolute children are left alone.
|
||||
//! - [`apply_constraints`]: children of a regular frame follow their constraints when it resizes.
|
||||
//! - [`grid_lines`]: the column/row rectangles of a layout grid, for drawing and snapping.
|
||||
//!
|
||||
//! Text measurement is injected ([`Measure`]) so this crate doesn't depend on fonts.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use figcraft_doc::{
|
||||
AutoLayout, CrossAlign, Document, GridAlign, GridKind, HConstraint, LayoutGrid, LayoutMode, MainAlign, NodeId, NodeKind, Sizing, TextAutoResize, VConstraint,
|
||||
};
|
||||
use figcraft_geom::{Affine, Point, Rect};
|
||||
|
||||
/// Measures text for a given width (auto-height text inside a Fill container).
|
||||
pub trait Measure {
|
||||
/// The height a text node needs at `width`.
|
||||
fn text_height(&self, doc: &Document, id: NodeId, width: f64) -> Option<f64>;
|
||||
/// Natural (auto width) size of a text node.
|
||||
fn text_size(&self, doc: &Document, id: NodeId) -> Option<(f64, f64)>;
|
||||
}
|
||||
|
||||
/// A measurer that never changes text sizes.
|
||||
pub struct NoMeasure;
|
||||
|
||||
impl Measure for NoMeasure {
|
||||
fn text_height(&self, _: &Document, _: NodeId, _: f64) -> Option<f64> {
|
||||
None
|
||||
}
|
||||
fn text_size(&self, _: &Document, _: NodeId) -> Option<(f64, f64)> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn clamp_size(v: f64, min: Option<f64>, max: Option<f64>) -> f64 {
|
||||
let mut v = if v.is_finite() { v.max(0.0) } else { 0.0 };
|
||||
if let Some(m) = max.filter(|m| m.is_finite()) {
|
||||
v = v.min(m);
|
||||
}
|
||||
if let Some(m) = min.filter(|m| m.is_finite()) {
|
||||
v = v.max(m);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// The child's box size as seen by the parent (rotation → bounding box).
|
||||
fn outer_size(doc: &Document, id: NodeId) -> (f64, f64) {
|
||||
let Some(n) = doc.node(id) else { return (0.0, 0.0) };
|
||||
let b = figcraft_geom::transform_rect_bbox(linear(n.transform), n.local_rect());
|
||||
(b.width(), b.height())
|
||||
}
|
||||
|
||||
fn linear(a: Affine) -> Affine {
|
||||
let c = a.as_coeffs();
|
||||
Affine::new([c[0], c[1], c[2], c[3], 0.0, 0.0])
|
||||
}
|
||||
|
||||
/// Place `id` so its (rotated) bounding box's top-left is at `(x, y)` in the parent.
|
||||
fn place(doc: &mut Document, id: NodeId, x: f64, y: f64) {
|
||||
let Some(n) = doc.node(id) else { return };
|
||||
let lin = linear(n.transform);
|
||||
let b = figcraft_geom::transform_rect_bbox(lin, n.local_rect());
|
||||
let t = Affine::translate((x - b.x0, y - b.y0)) * lin;
|
||||
if let Some(n) = doc.node_mut(id) {
|
||||
n.transform = t;
|
||||
}
|
||||
}
|
||||
|
||||
/// Resize a node's own box (vectors scale their network to stay inside it).
|
||||
pub fn set_size(doc: &mut Document, id: NodeId, w: f64, h: f64) {
|
||||
let Some(n) = doc.node_mut(id) else { return };
|
||||
let (w, h) = (if w.is_finite() { w.max(0.0) } else { n.width }, if h.is_finite() { h.max(0.0) } else { n.height });
|
||||
let (ow, oh) = (n.width, n.height);
|
||||
if (ow - w).abs() < 1e-9 && (oh - h).abs() < 1e-9 {
|
||||
return;
|
||||
}
|
||||
if let NodeKind::Vector { network } = &mut n.kind {
|
||||
let sx = if ow > 1e-9 { w / ow } else { 1.0 };
|
||||
let sy = if oh > 1e-9 { h / oh } else { 1.0 };
|
||||
network.scale(sx, sy);
|
||||
}
|
||||
n.width = w;
|
||||
n.height = h;
|
||||
}
|
||||
|
||||
/// Lay out every auto layout frame on a page (children before parents).
|
||||
pub fn layout_page(doc: &mut Document, page: NodeId, m: &dyn Measure) {
|
||||
let order = doc.subtree(page);
|
||||
for id in order.into_iter().rev() {
|
||||
if doc.node(id).is_some_and(|n| n.layout.is_some() && n.kind.is_frame_like()) {
|
||||
layout_frame(doc, id, m);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lay out the ancestors of `id` that are auto layout frames (innermost first), and `id` itself.
|
||||
pub fn layout_up(doc: &mut Document, id: NodeId, m: &dyn Measure) {
|
||||
let mut chain = vec![id];
|
||||
chain.extend(doc.ancestors(id));
|
||||
for c in chain {
|
||||
if doc.node(c).is_some_and(|n| n.layout.is_some() && n.kind.is_frame_like()) {
|
||||
layout_frame(doc, c, m);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Item {
|
||||
id: NodeId,
|
||||
main: f64,
|
||||
cross: f64,
|
||||
fill_main: bool,
|
||||
fill_cross: bool,
|
||||
min_main: Option<f64>,
|
||||
max_main: Option<f64>,
|
||||
min_cross: Option<f64>,
|
||||
max_cross: Option<f64>,
|
||||
}
|
||||
|
||||
/// Lay out one auto layout frame's children and size the frame if it hugs.
|
||||
pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
|
||||
let Some(f) = doc.node(frame) else { return };
|
||||
let Some(al) = f.layout.clone() else { return };
|
||||
let horizontal = matches!(al.mode, LayoutMode::Horizontal);
|
||||
let (hug_w, hug_h) = (f.child_layout.sizing_h == Sizing::Hug, f.child_layout.sizing_v == Sizing::Hug);
|
||||
let (fw, fh) = (f.width, f.height);
|
||||
let stroke_pad = if al.strokes_included && f.has_visible_stroke() { f.stroke.weight } else { 0.0 };
|
||||
let [pt, pr, pb, pl] = al.padding.map(|p| if p.is_finite() { p.max(0.0) } else { 0.0 });
|
||||
let (pt, pr, pb, pl) = (pt + stroke_pad, pr + stroke_pad, pb + stroke_pad, pl + stroke_pad);
|
||||
let kids: Vec<NodeId> = f.children.iter().copied().filter(|c| doc.node(*c).is_some_and(|n| n.visible && !n.child_layout.absolute)).collect();
|
||||
|
||||
// Text children: refresh their natural size first.
|
||||
for c in &kids {
|
||||
if let Some(n) = doc.node(*c)
|
||||
&& let NodeKind::Text(t) = &n.kind
|
||||
&& t.auto_resize == TextAutoResize::WidthAndHeight
|
||||
&& let Some((w, h)) = m.text_size(doc, *c)
|
||||
{
|
||||
set_size(doc, *c, w, h);
|
||||
}
|
||||
}
|
||||
|
||||
if matches!(al.mode, LayoutMode::Grid) {
|
||||
layout_grid(doc, frame, &al, &kids, (pt, pr, pb, pl), m);
|
||||
return;
|
||||
}
|
||||
|
||||
let inner_main_fixed = if horizontal { fw - pl - pr } else { fh - pt - pb };
|
||||
let inner_cross_fixed = if horizontal { fh - pt - pb } else { fw - pl - pr };
|
||||
let hug_main = if horizontal { hug_w } else { hug_h };
|
||||
let hug_cross = if horizontal { hug_h } else { hug_w };
|
||||
|
||||
let mut items: Vec<Item> = Vec::with_capacity(kids.len());
|
||||
for c in &kids {
|
||||
let Some(n) = doc.node(*c) else { continue };
|
||||
let (w, h) = outer_size(doc, *c);
|
||||
let cl = &n.child_layout;
|
||||
let (sm, sc) = if horizontal { (cl.sizing_h, cl.sizing_v) } else { (cl.sizing_v, cl.sizing_h) };
|
||||
let (min_main, max_main, min_cross, max_cross) = if horizontal { (cl.min_w, cl.max_w, cl.min_h, cl.max_h) } else { (cl.min_h, cl.max_h, cl.min_w, cl.max_w) };
|
||||
items.push(Item {
|
||||
id: *c,
|
||||
main: if horizontal { w } else { h },
|
||||
cross: if horizontal { h } else { w },
|
||||
fill_main: sm == Sizing::Fill && !hug_main,
|
||||
fill_cross: sc == Sizing::Fill && !hug_cross,
|
||||
min_main,
|
||||
max_main,
|
||||
min_cross,
|
||||
max_cross,
|
||||
});
|
||||
}
|
||||
|
||||
let gap = if al.gap.is_finite() { al.gap } else { 0.0 };
|
||||
let between = al.main_align == MainAlign::SpaceBetween;
|
||||
|
||||
// Split into lines (wrap only on the main axis of a horizontal layout, like Figma).
|
||||
let mut lines: Vec<Vec<usize>> = Vec::new();
|
||||
if al.wrap && !hug_main {
|
||||
let mut cur: Vec<usize> = Vec::new();
|
||||
let mut used = 0.0;
|
||||
for (i, it) in items.iter().enumerate() {
|
||||
let sz = if it.fill_main { it.min_main.unwrap_or(0.0) } else { it.main };
|
||||
let add = if cur.is_empty() { sz } else { gap.max(0.0) + sz };
|
||||
if !cur.is_empty() && used + add > inner_main_fixed + 1e-6 {
|
||||
lines.push(std::mem::take(&mut cur));
|
||||
used = sz;
|
||||
} else {
|
||||
used += add;
|
||||
}
|
||||
cur.push(i);
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
lines.push(cur);
|
||||
}
|
||||
} else {
|
||||
lines.push((0..items.len()).collect());
|
||||
}
|
||||
|
||||
// Resolve main sizes per line.
|
||||
let mut line_extents: Vec<(f64, f64)> = Vec::new(); // (main used, cross size)
|
||||
for line in &lines {
|
||||
let fixed: f64 = line.iter().filter_map(|i| items.get(*i)).filter(|it| !it.fill_main).map(|it| it.main).sum();
|
||||
let gaps = gap * line.len().saturating_sub(1) as f64;
|
||||
let fills: Vec<usize> = line.iter().copied().filter(|i| items.get(*i).is_some_and(|it| it.fill_main)).collect();
|
||||
if !fills.is_empty() {
|
||||
let mut free = (inner_main_fixed - fixed - if between { 0.0 } else { gaps }).max(0.0);
|
||||
let mut open: Vec<usize> = fills.clone();
|
||||
// Distribute, honouring min/max (a few rounds).
|
||||
for _ in 0..4 {
|
||||
if open.is_empty() {
|
||||
break;
|
||||
}
|
||||
let share = free / open.len() as f64;
|
||||
let mut still = Vec::new();
|
||||
let mut used = 0.0;
|
||||
for i in &open {
|
||||
let Some(it) = items.get_mut(*i) else { continue };
|
||||
let v = clamp_size(share, it.min_main, it.max_main);
|
||||
it.main = v;
|
||||
if (v - share).abs() > 1e-9 {
|
||||
used += v;
|
||||
} else {
|
||||
still.push(*i);
|
||||
}
|
||||
}
|
||||
if still.len() == open.len() {
|
||||
break;
|
||||
}
|
||||
free = (free - used).max(0.0);
|
||||
open = still;
|
||||
}
|
||||
}
|
||||
let main_used: f64 = line.iter().filter_map(|i| items.get(*i)).map(|it| it.main).sum::<f64>() + gaps;
|
||||
let cross: f64 = line.iter().filter_map(|i| items.get(*i)).filter(|it| !it.fill_cross).map(|it| it.cross).fold(0.0, f64::max);
|
||||
line_extents.push((main_used, cross));
|
||||
}
|
||||
|
||||
// Container size.
|
||||
let content_main = line_extents.iter().map(|l| l.0).fold(0.0, f64::max);
|
||||
let cross_gap = if al.cross_gap.is_finite() { al.cross_gap } else { 0.0 };
|
||||
let content_cross: f64 = line_extents.iter().map(|l| l.1).sum::<f64>() + cross_gap * lines.len().saturating_sub(1) as f64;
|
||||
let (mut nw, mut nh) = (fw, fh);
|
||||
let fcl = doc.node(frame).map(|n| n.child_layout.clone()).unwrap_or_default();
|
||||
if hug_main {
|
||||
let v = content_main + if horizontal { pl + pr } else { pt + pb };
|
||||
if horizontal {
|
||||
nw = clamp_size(v, fcl.min_w, fcl.max_w);
|
||||
} else {
|
||||
nh = clamp_size(v, fcl.min_h, fcl.max_h);
|
||||
}
|
||||
}
|
||||
if hug_cross {
|
||||
// Fill-cross children inside a hugging container use their own size.
|
||||
let max_fill_cross = items.iter().filter(|it| it.fill_cross).map(|it| it.cross).fold(0.0, f64::max);
|
||||
let cc = if lines.len() <= 1 { content_cross.max(max_fill_cross) } else { content_cross };
|
||||
let v = cc + if horizontal { pt + pb } else { pl + pr };
|
||||
if horizontal {
|
||||
nh = clamp_size(v, fcl.min_h, fcl.max_h);
|
||||
} else {
|
||||
nw = clamp_size(v, fcl.min_w, fcl.max_w);
|
||||
}
|
||||
}
|
||||
set_size(doc, frame, nw, nh);
|
||||
let inner_main = if horizontal { nw - pl - pr } else { nh - pt - pb };
|
||||
let inner_cross = if horizontal { nh - pt - pb } else { nw - pl - pr };
|
||||
let _ = inner_cross_fixed;
|
||||
|
||||
// Position.
|
||||
let mut cross_pos = if horizontal { pt } else { pl };
|
||||
let single = lines.len() == 1;
|
||||
for (li, line) in lines.iter().enumerate() {
|
||||
let (used, line_cross) = line_extents.get(li).copied().unwrap_or((0.0, 0.0));
|
||||
let line_cross = if single { inner_cross } else { line_cross };
|
||||
let n_items = line.len();
|
||||
let (mut pos, step_gap) = match al.main_align {
|
||||
MainAlign::Min => (0.0, gap),
|
||||
MainAlign::Center => ((inner_main - used) / 2.0, gap),
|
||||
MainAlign::Max => (inner_main - used, gap),
|
||||
MainAlign::SpaceBetween => {
|
||||
let total: f64 = line.iter().filter_map(|i| items.get(*i)).map(|it| it.main).sum();
|
||||
if n_items > 1 { (0.0, (inner_main - total) / (n_items - 1) as f64) } else { ((inner_main - total) / 2.0, 0.0) }
|
||||
}
|
||||
};
|
||||
pos += if horizontal { pl } else { pt };
|
||||
for i in line {
|
||||
let Some(it) = items.get(*i) else { continue };
|
||||
let cross_size = if it.fill_cross { clamp_size(line_cross, it.min_cross, it.max_cross) } else { it.cross };
|
||||
let off = match al.cross_align {
|
||||
CrossAlign::Min | CrossAlign::Baseline => 0.0,
|
||||
CrossAlign::Center => (line_cross - cross_size) / 2.0,
|
||||
CrossAlign::Max => line_cross - cross_size,
|
||||
};
|
||||
let (w, h) = if horizontal { (it.main, cross_size) } else { (cross_size, it.main) };
|
||||
// Resize only when the layout decides the size (fill, or text that must re-wrap).
|
||||
if it.fill_main || it.fill_cross {
|
||||
resize_child(doc, it.id, w, h, m);
|
||||
}
|
||||
let (x, y) = if horizontal { (pos, cross_pos + off) } else { (cross_pos + off, pos) };
|
||||
place(doc, it.id, x, y);
|
||||
pos += it.main + step_gap;
|
||||
}
|
||||
cross_pos += line_cross + cross_gap;
|
||||
}
|
||||
// Hugging after fill-cross text re-wrapped: heights may have changed; one more pass is cheap.
|
||||
let _ = AutoLayout::default();
|
||||
}
|
||||
|
||||
fn resize_child(doc: &mut Document, id: NodeId, w: f64, h: f64, m: &dyn Measure) {
|
||||
let is_text_height = doc.node(id).is_some_and(|n| matches!(&n.kind, NodeKind::Text(t) if t.auto_resize != TextAutoResize::None && t.auto_resize != TextAutoResize::Truncate));
|
||||
if is_text_height {
|
||||
if let Some(n) = doc.node_mut(id)
|
||||
&& let NodeKind::Text(t) = &mut n.kind
|
||||
&& t.auto_resize == TextAutoResize::WidthAndHeight
|
||||
{
|
||||
t.auto_resize = TextAutoResize::Height;
|
||||
}
|
||||
let th = m.text_height(doc, id, w).unwrap_or(h);
|
||||
set_size(doc, id, w, th);
|
||||
} else {
|
||||
let old = doc.node(id).map(|n| (n.width, n.height));
|
||||
set_size(doc, id, w, h);
|
||||
if let Some((ow, oh)) = old
|
||||
&& doc.node(id).is_some_and(|n| n.layout.is_none() && n.kind.is_frame_like())
|
||||
{
|
||||
apply_constraints(doc, id, ow, oh);
|
||||
} else if doc.node(id).is_some_and(|n| n.layout.is_some()) {
|
||||
layout_frame(doc, id, m);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn layout_grid(doc: &mut Document, frame: NodeId, al: &AutoLayout, kids: &[NodeId], pad: (f64, f64, f64, f64), m: &dyn Measure) {
|
||||
let (pt, pr, pb, pl) = pad;
|
||||
let Some(f) = doc.node(frame) else { return };
|
||||
let cols = al.grid_columns.clamp(1, 1000) as usize;
|
||||
let rows_needed = kids.len().div_ceil(cols).max(al.grid_rows.clamp(1, 1000) as usize);
|
||||
let (fw, fh) = (f.width, f.height);
|
||||
let gx = if al.gap.is_finite() { al.gap.max(0.0) } else { 0.0 };
|
||||
let gy = if al.cross_gap.is_finite() { al.cross_gap.max(0.0) } else { 0.0 };
|
||||
let hug_w = f.child_layout.sizing_h == Sizing::Hug;
|
||||
let hug_h = f.child_layout.sizing_v == Sizing::Hug;
|
||||
// Track sizes: equal (1fr) unless hugging, then the max child per track.
|
||||
let mut col_w = vec![0.0f64; cols];
|
||||
let mut row_h = vec![0.0f64; rows_needed];
|
||||
for (i, c) in kids.iter().enumerate() {
|
||||
let (w, h) = outer_size(doc, *c);
|
||||
if let Some(cw) = col_w.get_mut(i % cols) {
|
||||
*cw = cw.max(w);
|
||||
}
|
||||
if let Some(rh) = row_h.get_mut(i / cols) {
|
||||
*rh = rh.max(h);
|
||||
}
|
||||
}
|
||||
let total_w = if hug_w { col_w.iter().sum::<f64>() + gx * (cols - 1) as f64 + pl + pr } else { fw };
|
||||
let total_h = if hug_h { row_h.iter().sum::<f64>() + gy * (rows_needed - 1) as f64 + pt + pb } else { fh };
|
||||
set_size(doc, frame, total_w, total_h);
|
||||
if !hug_w {
|
||||
let cw = ((total_w - pl - pr - gx * (cols - 1) as f64) / cols as f64).max(0.0);
|
||||
col_w = vec![cw; cols];
|
||||
}
|
||||
if !hug_h {
|
||||
let rh = ((total_h - pt - pb - gy * (rows_needed - 1) as f64) / rows_needed as f64).max(0.0);
|
||||
row_h = vec![rh; rows_needed];
|
||||
}
|
||||
for (i, c) in kids.iter().enumerate() {
|
||||
let (ci, ri) = (i % cols, i / cols);
|
||||
let x = pl + col_w.iter().take(ci).sum::<f64>() + gx * ci as f64;
|
||||
let y = pt + row_h.iter().take(ri).sum::<f64>() + gy * ri as f64;
|
||||
let (cw, rh) = (col_w.get(ci).copied().unwrap_or(0.0), row_h.get(ri).copied().unwrap_or(0.0));
|
||||
let Some(n) = doc.node(*c) else { continue };
|
||||
let (fill_w, fill_h) = (n.child_layout.sizing_h == Sizing::Fill, n.child_layout.sizing_v == Sizing::Fill);
|
||||
let (w, h) = outer_size(doc, *c);
|
||||
let (nw, nh) = (if fill_w { cw } else { w }, if fill_h { rh } else { h });
|
||||
if fill_w || fill_h {
|
||||
resize_child(doc, *c, nw, nh, m);
|
||||
}
|
||||
let ox = match al.main_align {
|
||||
MainAlign::Center => (cw - nw) / 2.0,
|
||||
MainAlign::Max => cw - nw,
|
||||
_ => 0.0,
|
||||
};
|
||||
let oy = match al.cross_align {
|
||||
CrossAlign::Center => (rh - nh) / 2.0,
|
||||
CrossAlign::Max => rh - nh,
|
||||
_ => 0.0,
|
||||
};
|
||||
place(doc, *c, x + ox, y + oy);
|
||||
}
|
||||
}
|
||||
|
||||
/// After frame `id` (without auto layout) changed size from `(ow, oh)`, move/resize its children
|
||||
/// per their constraints (recursively for children frames that resize).
|
||||
pub fn apply_constraints(doc: &mut Document, id: NodeId, ow: f64, oh: f64) {
|
||||
let Some(f) = doc.node(id) else { return };
|
||||
if f.layout.is_some() {
|
||||
return;
|
||||
}
|
||||
let (nw, nh) = (f.width, f.height);
|
||||
if (nw - ow).abs() < 1e-9 && (nh - oh).abs() < 1e-9 {
|
||||
return;
|
||||
}
|
||||
let sx = if ow > 1e-9 { nw / ow } else { 1.0 };
|
||||
let sy = if oh > 1e-9 { nh / oh } else { 1.0 };
|
||||
let kids = f.children.clone();
|
||||
// Group children follow the group's own bounds; constraints apply to frames' children.
|
||||
if f.kind.bounds_follow_children() {
|
||||
return;
|
||||
}
|
||||
for c in kids {
|
||||
let Some(n) = doc.node(c) else { continue };
|
||||
let lin = linear(n.transform);
|
||||
let b = figcraft_geom::transform_rect_bbox(n.transform, n.local_rect());
|
||||
let (cw, ch) = (n.width, n.height);
|
||||
let (bw, bh) = (b.width(), b.height());
|
||||
let (mut x0, mut x1) = (b.x0, b.x1);
|
||||
let (mut y0, mut y1) = (b.y0, b.y1);
|
||||
match n.constraints.horizontal {
|
||||
HConstraint::Left => {}
|
||||
HConstraint::Right => {
|
||||
x0 += nw - ow;
|
||||
x1 += nw - ow;
|
||||
}
|
||||
HConstraint::LeftRight => x1 += nw - ow,
|
||||
HConstraint::Center => {
|
||||
let d = (nw - ow) / 2.0;
|
||||
x0 += d;
|
||||
x1 += d;
|
||||
}
|
||||
HConstraint::Scale => {
|
||||
x0 *= sx;
|
||||
x1 *= sx;
|
||||
}
|
||||
}
|
||||
match n.constraints.vertical {
|
||||
VConstraint::Top => {}
|
||||
VConstraint::Bottom => {
|
||||
y0 += nh - oh;
|
||||
y1 += nh - oh;
|
||||
}
|
||||
VConstraint::TopBottom => y1 += nh - oh,
|
||||
VConstraint::Center => {
|
||||
let d = (nh - oh) / 2.0;
|
||||
y0 += d;
|
||||
y1 += d;
|
||||
}
|
||||
VConstraint::Scale => {
|
||||
y0 *= sy;
|
||||
y1 *= sy;
|
||||
}
|
||||
}
|
||||
// New local size from the bbox change (axis-aligned children exactly; rotated approximately).
|
||||
let kw = if bw > 1e-9 { (x1 - x0) / bw } else { 1.0 };
|
||||
let kh = if bh > 1e-9 { (y1 - y0) / bh } else { 1.0 };
|
||||
let (new_w, new_h) = (cw * kw, ch * kh);
|
||||
let resized = (new_w - cw).abs() > 1e-9 || (new_h - ch).abs() > 1e-9;
|
||||
if resized {
|
||||
set_size(doc, c, new_w, new_h);
|
||||
if let Some(cn) = doc.node_mut(c) {
|
||||
cn.transform = lin;
|
||||
}
|
||||
}
|
||||
place(doc, c, x0, y0);
|
||||
if resized && doc.node(c).is_some_and(|n| n.kind.is_frame_like() || n.kind.bounds_follow_children()) {
|
||||
if doc.node(c).is_some_and(|n| n.kind.bounds_follow_children()) {
|
||||
scale_group_children(doc, c, kw, kh);
|
||||
} else {
|
||||
apply_constraints(doc, c, cw, ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scale a group's children when the group is resized (groups scale their contents).
|
||||
pub fn scale_group_children(doc: &mut Document, id: NodeId, kx: f64, ky: f64) {
|
||||
let Some(n) = doc.node(id) else { return };
|
||||
for c in n.children.clone() {
|
||||
let Some(cn) = doc.node(c) else { continue };
|
||||
let b = figcraft_geom::transform_rect_bbox(cn.transform, cn.local_rect());
|
||||
let (w, h) = (cn.width, cn.height);
|
||||
set_size(doc, c, w * kx, h * ky);
|
||||
place(doc, c, b.x0 * kx, b.y0 * ky);
|
||||
if doc.node(c).is_some_and(|n| n.kind.bounds_follow_children()) {
|
||||
scale_group_children(doc, c, kx, ky);
|
||||
} else if doc.node(c).is_some_and(|n| n.kind.is_frame_like()) {
|
||||
apply_constraints(doc, c, w, h);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The rectangles (frame-local) a layout grid draws: columns, rows or the square grid's lines.
|
||||
pub fn grid_lines(g: &LayoutGrid, w: f64, h: f64) -> Vec<Rect> {
|
||||
let mut out = Vec::new();
|
||||
match g.kind {
|
||||
GridKind::Grid => {
|
||||
let s = if g.size.is_finite() && g.size >= 1.0 { g.size } else { 10.0 };
|
||||
let mut x = 0.0;
|
||||
let mut guard = 0;
|
||||
while x <= w && guard < 4000 {
|
||||
out.push(Rect::new(x, 0.0, x, h));
|
||||
x += s;
|
||||
guard += 1;
|
||||
}
|
||||
let mut y = 0.0;
|
||||
guard = 0;
|
||||
while y <= h && guard < 4000 {
|
||||
out.push(Rect::new(0.0, y, w, y));
|
||||
y += s;
|
||||
guard += 1;
|
||||
}
|
||||
}
|
||||
GridKind::Columns | GridKind::Rows => {
|
||||
let cols = g.kind == GridKind::Columns;
|
||||
let total = if cols { w } else { h };
|
||||
let count = g.count.clamp(1, 500) as usize;
|
||||
let gutter = if g.gutter.is_finite() { g.gutter.max(0.0) } else { 0.0 };
|
||||
let offset = if g.offset.is_finite() { g.offset.max(0.0) } else { 0.0 };
|
||||
let (start, size) = match g.align {
|
||||
GridAlign::Stretch => {
|
||||
let size = ((total - 2.0 * offset - gutter * (count - 1) as f64) / count as f64).max(0.0);
|
||||
(offset, size)
|
||||
}
|
||||
_ => {
|
||||
let size = if g.size.is_finite() && g.size > 0.0 { g.size } else { 60.0 };
|
||||
let span = size * count as f64 + gutter * (count - 1) as f64;
|
||||
let start = match g.align {
|
||||
GridAlign::Min => offset,
|
||||
GridAlign::Center => (total - span) / 2.0,
|
||||
_ => total - offset - span,
|
||||
};
|
||||
(start, size)
|
||||
}
|
||||
};
|
||||
for i in 0..count {
|
||||
let a = start + i as f64 * (size + gutter);
|
||||
out.push(if cols { Rect::new(a, 0.0, a + size, h) } else { Rect::new(0.0, a, w, a + size) });
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Snap targets of a frame's visible layout grids (page coordinates): column/row edges.
|
||||
pub fn grid_snap_lines(doc: &Document, frame: NodeId) -> (Vec<f64>, Vec<f64>) {
|
||||
let Some(f) = doc.node(frame) else { return (vec![], vec![]) };
|
||||
let xf = doc.world_transform(frame);
|
||||
let mut xs = Vec::new();
|
||||
let mut ys = Vec::new();
|
||||
for g in f.grids.iter().filter(|g| g.visible) {
|
||||
for r in grid_lines(g, f.width, f.height) {
|
||||
let a = xf * Point::new(r.x0, r.y0);
|
||||
let b = xf * Point::new(r.x1, r.y1);
|
||||
match g.kind {
|
||||
GridKind::Columns => {
|
||||
xs.push(a.x);
|
||||
xs.push(b.x);
|
||||
}
|
||||
GridKind::Rows => {
|
||||
ys.push(a.y);
|
||||
ys.push(b.y);
|
||||
}
|
||||
GridKind::Grid => {
|
||||
if (r.x0 - r.x1).abs() < 1e-9 {
|
||||
xs.push(a.x);
|
||||
} else {
|
||||
ys.push(a.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(xs, ys)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
use super::*;
|
||||
use figcraft_doc::{AutoLayout, Document, NodeKind};
|
||||
|
||||
fn setup(mode: LayoutMode) -> (Document, NodeId, Vec<NodeId>) {
|
||||
let mut d = Document::new("t");
|
||||
let p = d.first_page().unwrap();
|
||||
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 300.0, 100.0).unwrap();
|
||||
d.get_mut(f).unwrap().layout = Some(AutoLayout { mode, gap: 10.0, padding: [5.0, 5.0, 5.0, 5.0], ..Default::default() });
|
||||
let a = d.create(f, NodeKind::Rectangle, "A", 0.0, 0.0, 50.0, 20.0).unwrap();
|
||||
let b = d.create(f, NodeKind::Rectangle, "B", 0.0, 0.0, 30.0, 40.0).unwrap();
|
||||
(d, f, vec![a, b])
|
||||
}
|
||||
|
||||
fn pos(d: &Document, id: NodeId) -> (f64, f64, f64, f64) {
|
||||
let n = d.node(id).unwrap();
|
||||
let c = n.transform.as_coeffs();
|
||||
(c[4], c[5], n.width, n.height)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_stack() {
|
||||
let (mut d, f, k) = setup(LayoutMode::Horizontal);
|
||||
layout_frame(&mut d, f, &NoMeasure);
|
||||
assert_eq!(pos(&d, k[0]), (5.0, 5.0, 50.0, 20.0));
|
||||
assert_eq!(pos(&d, k[1]), (65.0, 5.0, 30.0, 40.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hug_container() {
|
||||
let (mut d, f, _) = setup(LayoutMode::Horizontal);
|
||||
let n = d.get_mut(f).unwrap();
|
||||
n.child_layout.sizing_h = Sizing::Hug;
|
||||
n.child_layout.sizing_v = Sizing::Hug;
|
||||
layout_frame(&mut d, f, &NoMeasure);
|
||||
let n = d.node(f).unwrap();
|
||||
assert_eq!((n.width, n.height), (5.0 + 50.0 + 10.0 + 30.0 + 5.0, 50.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_center_alignment() {
|
||||
let (mut d, f, k) = setup(LayoutMode::Vertical);
|
||||
d.get_mut(f).unwrap().layout.as_mut().unwrap().cross_align = CrossAlign::Center;
|
||||
layout_frame(&mut d, f, &NoMeasure);
|
||||
// Inner width 290; A (50 wide) centred → x = 5 + 120.
|
||||
assert_eq!(pos(&d, k[0]).0, 125.0);
|
||||
assert_eq!(pos(&d, k[1]).1, 5.0 + 20.0 + 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_children_share_space() {
|
||||
let (mut d, f, k) = setup(LayoutMode::Horizontal);
|
||||
for c in &k {
|
||||
d.get_mut(*c).unwrap().child_layout.sizing_h = Sizing::Fill;
|
||||
}
|
||||
d.get_mut(k[1]).unwrap().child_layout.sizing_v = Sizing::Fill;
|
||||
layout_frame(&mut d, f, &NoMeasure);
|
||||
// Inner 290 - gap 10 = 280 → 140 each.
|
||||
assert_eq!(pos(&d, k[0]).2, 140.0);
|
||||
assert_eq!(pos(&d, k[1]).0, 5.0 + 140.0 + 10.0);
|
||||
assert_eq!(pos(&d, k[1]).3, 90.0);
|
||||
// Max width on one.
|
||||
d.get_mut(k[0]).unwrap().child_layout.max_w = Some(100.0);
|
||||
layout_frame(&mut d, f, &NoMeasure);
|
||||
assert_eq!(pos(&d, k[0]).2, 100.0);
|
||||
assert_eq!(pos(&d, k[1]).2, 180.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_between_and_absolute() {
|
||||
let (mut d, f, k) = setup(LayoutMode::Horizontal);
|
||||
d.get_mut(f).unwrap().layout.as_mut().unwrap().main_align = MainAlign::SpaceBetween;
|
||||
let abs = d.create(f, NodeKind::Rectangle, "Abs", 7.0, 7.0, 10.0, 10.0).unwrap();
|
||||
d.get_mut(abs).unwrap().child_layout.absolute = true;
|
||||
layout_frame(&mut d, f, &NoMeasure);
|
||||
assert_eq!(pos(&d, k[1]).0, 300.0 - 5.0 - 30.0);
|
||||
assert_eq!(pos(&d, abs).0, 7.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_into_rows() {
|
||||
let mut d = Document::new("t");
|
||||
let p = d.first_page().unwrap();
|
||||
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
d.get_mut(f).unwrap().layout = Some(AutoLayout { wrap: true, gap: 10.0, cross_gap: 5.0, padding: [0.0; 4], ..Default::default() });
|
||||
d.get_mut(f).unwrap().child_layout.sizing_v = Sizing::Hug;
|
||||
let ids: Vec<NodeId> = (0..5).map(|i| d.create(f, NodeKind::Rectangle, &format!("R{i}"), 0.0, 0.0, 40.0, 20.0).unwrap()).collect();
|
||||
layout_frame(&mut d, f, &NoMeasure);
|
||||
// Two per row (40+10+40 = 90 ≤ 100).
|
||||
assert_eq!(pos(&d, ids[2]), (0.0, 25.0, 40.0, 20.0));
|
||||
assert_eq!(d.node(f).unwrap().height, 20.0 * 3.0 + 5.0 * 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_mode() {
|
||||
let mut d = Document::new("t");
|
||||
let p = d.first_page().unwrap();
|
||||
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 210.0, 110.0).unwrap();
|
||||
d.get_mut(f).unwrap().layout = Some(AutoLayout { mode: LayoutMode::Grid, grid_columns: 2, grid_rows: 2, gap: 10.0, cross_gap: 10.0, padding: [0.0; 4], ..Default::default() });
|
||||
let ids: Vec<NodeId> = (0..4).map(|i| d.create(f, NodeKind::Rectangle, &format!("R{i}"), 0.0, 0.0, 10.0, 10.0).unwrap()).collect();
|
||||
layout_frame(&mut d, f, &NoMeasure);
|
||||
assert_eq!(pos(&d, ids[3]).0, 110.0);
|
||||
assert_eq!(pos(&d, ids[3]).1, 60.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_hug_propagates_with_layout_page() {
|
||||
let mut d = Document::new("t");
|
||||
let p = d.first_page().unwrap();
|
||||
let outer = d.create(p, NodeKind::Frame, "Outer", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
d.get_mut(outer).unwrap().layout = Some(AutoLayout { padding: [0.0; 4], ..Default::default() });
|
||||
d.get_mut(outer).unwrap().child_layout.sizing_h = Sizing::Hug;
|
||||
let inner = d.create(outer, NodeKind::Frame, "Inner", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
d.get_mut(inner).unwrap().layout = Some(AutoLayout { padding: [0.0; 4], gap: 0.0, ..Default::default() });
|
||||
d.get_mut(inner).unwrap().child_layout.sizing_h = Sizing::Hug;
|
||||
d.create(inner, NodeKind::Rectangle, "R", 0.0, 0.0, 77.0, 10.0).unwrap();
|
||||
layout_page(&mut d, p, &NoMeasure);
|
||||
assert_eq!(d.node(inner).unwrap().width, 77.0);
|
||||
assert_eq!(d.node(outer).unwrap().width, 77.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constraints_on_resize() {
|
||||
let mut d = Document::new("t");
|
||||
let p = d.first_page().unwrap();
|
||||
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
let r = d.create(f, NodeKind::Rectangle, "R", 80.0, 10.0, 10.0, 10.0).unwrap();
|
||||
d.get_mut(r).unwrap().constraints = figcraft_doc::Constraints { horizontal: HConstraint::Right, vertical: VConstraint::TopBottom };
|
||||
let c = d.create(f, NodeKind::Rectangle, "C", 45.0, 45.0, 10.0, 10.0).unwrap();
|
||||
d.get_mut(c).unwrap().constraints = figcraft_doc::Constraints { horizontal: HConstraint::Center, vertical: VConstraint::Scale };
|
||||
set_size(&mut d, f, 200.0, 200.0);
|
||||
apply_constraints(&mut d, f, 100.0, 100.0);
|
||||
assert_eq!(pos(&d, r), (180.0, 10.0, 10.0, 110.0));
|
||||
assert_eq!(pos(&d, c), (95.0, 90.0, 10.0, 20.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_lines_columns_stretch() {
|
||||
let g = LayoutGrid { kind: GridKind::Columns, count: 4, gutter: 20.0, offset: 10.0, ..LayoutGrid::default() };
|
||||
let r = grid_lines(&g, 400.0, 100.0);
|
||||
assert_eq!(r.len(), 4);
|
||||
assert_eq!(r[0].x0, 10.0);
|
||||
assert!((r[3].x1 - 390.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_values() {
|
||||
let (mut d, f, _) = setup(LayoutMode::Horizontal);
|
||||
let n = d.get_mut(f).unwrap();
|
||||
n.layout.as_mut().unwrap().gap = f64::NAN;
|
||||
n.layout.as_mut().unwrap().padding = [f64::INFINITY, -1.0, f64::NAN, 1e300];
|
||||
n.layout.as_mut().unwrap().grid_columns = 0;
|
||||
layout_frame(&mut d, f, &NoMeasure);
|
||||
d.get_mut(f).unwrap().layout.as_mut().unwrap().mode = LayoutMode::Grid;
|
||||
layout_frame(&mut d, f, &NoMeasure);
|
||||
let _ = grid_lines(&LayoutGrid { size: 0.0, ..Default::default() }, 1e9, 1e9);
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ pub mod images;
|
|||
pub mod paint;
|
||||
|
||||
pub use draw::{node_outline, subtree_outline, ts_path};
|
||||
pub use tiny_skia::Pixmap;
|
||||
pub use tiny_skia::{FillRule, Pixmap};
|
||||
|
||||
use figcraft_color::Color;
|
||||
use figcraft_doc::{Document, NodeId, NodeKind};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
[package]
|
||||
name = "figcraft-svg"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "FigCraft SVG export (shapes, gradients, images, strokes, effects as filters, text as outlines) and import (via usvg)"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
figcraft-geom = { workspace = true }
|
||||
figcraft-color = { workspace = true }
|
||||
figcraft-doc = { workspace = true }
|
||||
figcraft-render = { workspace = true }
|
||||
figcraft-pathops = { workspace = true }
|
||||
usvg = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
//! SVG import: usvg's normalised tree → a frame of vector nodes (groups kept as groups).
|
||||
|
||||
use figcraft_color::{Color, ColorStop, Gradient, GradientKind, Paint};
|
||||
use figcraft_doc::{Document, Node, NodeId, NodeKind, StrokeAlign, StrokeCap, StrokeJoin, VectorNetwork, WindingRule};
|
||||
use figcraft_geom::{Affine, BezPath, Point, Rect, Shape};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ImportError(pub String);
|
||||
|
||||
impl std::fmt::Display for ImportError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "SVG import failed: {}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn xf(t: usvg::Transform) -> Affine {
|
||||
Affine::new([f64::from(t.sx), f64::from(t.ky), f64::from(t.kx), f64::from(t.sy), f64::from(t.tx), f64::from(t.ty)])
|
||||
}
|
||||
|
||||
fn to_bez(p: &usvg::tiny_skia_path::Path) -> BezPath {
|
||||
use usvg::tiny_skia_path::PathSegment::*;
|
||||
let mut b = BezPath::new();
|
||||
for s in p.segments() {
|
||||
match s {
|
||||
MoveTo(a) => b.move_to((f64::from(a.x), f64::from(a.y))),
|
||||
LineTo(a) => b.line_to((f64::from(a.x), f64::from(a.y))),
|
||||
QuadTo(c, a) => b.quad_to((f64::from(c.x), f64::from(c.y)), (f64::from(a.x), f64::from(a.y))),
|
||||
CubicTo(c1, c2, a) => b.curve_to((f64::from(c1.x), f64::from(c1.y)), (f64::from(c2.x), f64::from(c2.y)), (f64::from(a.x), f64::from(a.y))),
|
||||
Close => b.close_path(),
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
fn color(c: usvg::Color, op: f32) -> Color {
|
||||
Color::new(f32::from(c.red) / 255.0, f32::from(c.green) / 255.0, f32::from(c.blue) / 255.0, op)
|
||||
}
|
||||
|
||||
/// Convert a usvg paint into ours for a node whose local box is `bbox` (absolute coordinates) and
|
||||
/// whose absolute transform is `abs`.
|
||||
fn paint(p: &usvg::Paint, opacity: f32, bbox: Rect, abs: Affine) -> Option<Paint> {
|
||||
let to_unit = |pt: Point| -> (f64, f64) {
|
||||
let w = bbox.width().max(1e-9);
|
||||
let h = bbox.height().max(1e-9);
|
||||
((pt.x - bbox.x0) / w, (pt.y - bbox.y0) / h)
|
||||
};
|
||||
match p {
|
||||
usvg::Paint::Color(c) => Some(Paint::solid(color(*c, 1.0)).with_opacity(opacity)),
|
||||
usvg::Paint::LinearGradient(g) => {
|
||||
let gt = abs * xf(g.transform());
|
||||
let a = gt * Point::new(f64::from(g.x1()), f64::from(g.y1()));
|
||||
let b = gt * Point::new(f64::from(g.x2()), f64::from(g.y2()));
|
||||
let stops = g.stops().iter().map(|s| ColorStop { position: s.offset().get(), color: color(s.color(), s.opacity().get()) }).collect();
|
||||
let from = to_unit(a);
|
||||
let to = to_unit(b);
|
||||
Some(Paint::gradient(Gradient { kind: GradientKind::Linear, stops, from, to, width: (from.0 - (to.1 - from.1), from.1 + (to.0 - from.0)) }).with_opacity(opacity))
|
||||
}
|
||||
usvg::Paint::RadialGradient(g) => {
|
||||
let gt = abs * xf(g.transform());
|
||||
let c = gt * Point::new(f64::from(g.cx()), f64::from(g.cy()));
|
||||
let r = f64::from(g.r().get());
|
||||
let ex = gt * Point::new(f64::from(g.cx()) + r, f64::from(g.cy()));
|
||||
let ey = gt * Point::new(f64::from(g.cx()), f64::from(g.cy()) + r);
|
||||
let stops = g.stops().iter().map(|s| ColorStop { position: s.offset().get(), color: color(s.color(), s.opacity().get()) }).collect();
|
||||
Some(Paint::gradient(Gradient { kind: GradientKind::Radial, stops, from: to_unit(c), to: to_unit(ex), width: to_unit(ey) }).with_opacity(opacity))
|
||||
}
|
||||
usvg::Paint::Pattern(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
trait WithOpacity {
|
||||
fn with_opacity(self, o: f32) -> Self;
|
||||
}
|
||||
|
||||
impl WithOpacity for Paint {
|
||||
fn with_opacity(mut self, o: f32) -> Self {
|
||||
self.opacity *= o.clamp(0.0, 1.0);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
struct Ctx<'a> {
|
||||
doc: &'a mut Document,
|
||||
/// Inverse of the root frame's placement: absolute SVG → frame-local.
|
||||
count: usize,
|
||||
}
|
||||
|
||||
const MAX_NODES: usize = 100_000;
|
||||
|
||||
fn add_group(cx: &mut Ctx, g: &usvg::Group, parent: NodeId, parent_abs: Affine, depth: usize) {
|
||||
if depth > 128 {
|
||||
return;
|
||||
}
|
||||
for child in g.children() {
|
||||
if cx.count > MAX_NODES {
|
||||
return;
|
||||
}
|
||||
match child {
|
||||
usvg::Node::Group(sub) => {
|
||||
let abs = xf(sub.abs_transform());
|
||||
let id = cx.doc.alloc_id();
|
||||
let name = if sub.id().is_empty() { "Group".to_string() } else { sub.id().to_string() };
|
||||
let mut n = Node::new(id, NodeKind::Group, name, 0.0, 0.0);
|
||||
n.opacity = sub.opacity().get();
|
||||
if cx.doc.insert(n, parent, None).is_err() {
|
||||
continue;
|
||||
}
|
||||
cx.count += 1;
|
||||
add_group(cx, sub, id, abs, depth + 1);
|
||||
if cx.doc.node(id).is_some_and(|n| n.children.is_empty()) {
|
||||
let _ = cx.doc.remove(id);
|
||||
} else {
|
||||
let _ = cx.doc.fit_to_children(id);
|
||||
}
|
||||
}
|
||||
usvg::Node::Path(p) => {
|
||||
if !p.is_visible() {
|
||||
continue;
|
||||
}
|
||||
let abs = xf(p.abs_transform());
|
||||
let mut path = to_bez(p.data());
|
||||
path.apply_affine(parent_abs.inverse() * abs);
|
||||
// Place the vector at its bbox origin in the parent.
|
||||
let bb = path.bounding_box();
|
||||
if !(bb.x0.is_finite() && bb.y0.is_finite()) {
|
||||
continue;
|
||||
}
|
||||
path.apply_affine(Affine::translate((-bb.x0, -bb.y0)));
|
||||
let mut net = VectorNetwork::from_path(&path);
|
||||
let even = p.fill().is_some_and(|f| f.rule() == usvg::FillRule::EvenOdd);
|
||||
for r in &mut net.regions {
|
||||
r.winding = if even { WindingRule::EvenOdd } else { WindingRule::NonZero };
|
||||
}
|
||||
if p.fill().is_none() {
|
||||
net.regions.clear();
|
||||
}
|
||||
let id = cx.doc.alloc_id();
|
||||
let name = if p.id().is_empty() { "Vector".to_string() } else { p.id().to_string() };
|
||||
let mut n = Node::new(id, NodeKind::Vector { network: net }, name, bb.width(), bb.height());
|
||||
n.transform = Affine::translate((bb.x0, bb.y0));
|
||||
let abs_bbox = figcraft_geom::transform_rect_bbox(parent_abs, bb);
|
||||
// Paint coordinates are absolute: express them in the vector's box.
|
||||
n.fills = p.fill().and_then(|f| paint(f.paint(), f.opacity().get(), abs_bbox, Affine::IDENTITY)).into_iter().collect();
|
||||
if let Some(s) = p.stroke() {
|
||||
n.strokes = paint(s.paint(), s.opacity().get(), abs_bbox, Affine::IDENTITY).into_iter().collect();
|
||||
n.stroke.weight = f64::from(s.width().get());
|
||||
n.stroke.align = StrokeAlign::Center;
|
||||
n.stroke.join = match s.linejoin() {
|
||||
usvg::LineJoin::Round => StrokeJoin::Round,
|
||||
usvg::LineJoin::Bevel => StrokeJoin::Bevel,
|
||||
_ => StrokeJoin::Miter,
|
||||
};
|
||||
let cap = match s.linecap() {
|
||||
usvg::LineCap::Round => StrokeCap::Round,
|
||||
usvg::LineCap::Square => StrokeCap::Square,
|
||||
usvg::LineCap::Butt => StrokeCap::None,
|
||||
};
|
||||
n.stroke.cap_start = cap;
|
||||
n.stroke.cap_end = cap;
|
||||
n.stroke.miter_limit = f64::from(s.miterlimit().get());
|
||||
if let Some(d) = s.dasharray() {
|
||||
n.stroke.dashes = d.iter().map(|v| f64::from(*v)).collect();
|
||||
}
|
||||
} else {
|
||||
n.strokes.clear();
|
||||
}
|
||||
if cx.doc.insert(n, parent, None).is_ok() {
|
||||
cx.count += 1;
|
||||
}
|
||||
}
|
||||
usvg::Node::Text(t) => {
|
||||
let flat = t.flattened();
|
||||
add_group(cx, flat, parent, parent_abs, depth + 1);
|
||||
}
|
||||
usvg::Node::Image(img) => {
|
||||
let bytes = match img.kind() {
|
||||
usvg::ImageKind::PNG(b) | usvg::ImageKind::JPEG(b) | usvg::ImageKind::GIF(b) | usvg::ImageKind::WEBP(b) => b.as_ref().clone(),
|
||||
usvg::ImageKind::SVG(_) => continue,
|
||||
};
|
||||
let hash = simple_hash(&bytes);
|
||||
let abs = xf(img.abs_transform());
|
||||
let local = parent_abs.inverse() * abs;
|
||||
let size = img.size();
|
||||
let id = cx.doc.alloc_id();
|
||||
let mut n = Node::new(id, NodeKind::Rectangle, "Image", f64::from(size.width()), f64::from(size.height()));
|
||||
n.transform = local;
|
||||
n.fills = vec![Paint::image(hash.clone(), figcraft_color::ImageScaleMode::Fill)];
|
||||
let (w, h) = (size.width() as u32, size.height() as u32);
|
||||
cx.doc.images.insert(hash, figcraft_doc::ImageData { bytes: std::sync::Arc::new(bytes), width: w, height: h });
|
||||
if cx.doc.insert(n, parent, None).is_ok() {
|
||||
cx.count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn simple_hash(b: &[u8]) -> String {
|
||||
// FNV-1a 128 (two lanes); the format crate re-keys by sha256 on save if needed.
|
||||
let mut h1: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
let mut h2: u64 = 0x8422_2325_cbf2_9ce4;
|
||||
for (i, x) in b.iter().enumerate() {
|
||||
h1 ^= u64::from(*x);
|
||||
h1 = h1.wrapping_mul(0x0100_0000_01b3);
|
||||
h2 ^= u64::from(*x).wrapping_add(i as u64);
|
||||
h2 = h2.wrapping_mul(0x0100_0000_01b3);
|
||||
}
|
||||
format!("{h1:016x}{h2:016x}")
|
||||
}
|
||||
|
||||
/// Import SVG bytes as a new frame under `parent` at `(x, y)`. Returns the frame id.
|
||||
pub fn import_svg(doc: &mut Document, data: &[u8], parent: NodeId, x: f64, y: f64, name: &str) -> Result<NodeId, ImportError> {
|
||||
let opts = usvg::Options::default();
|
||||
let tree = usvg::Tree::from_data(data, &opts).map_err(|e| ImportError(e.to_string()))?;
|
||||
let size = tree.size();
|
||||
let frame = doc
|
||||
.create(parent, NodeKind::Frame, name, x, y, f64::from(size.width()), f64::from(size.height()))
|
||||
.map_err(|e| ImportError(e.to_string()))?;
|
||||
if let Some(f) = doc.node_mut(frame) {
|
||||
f.fills.clear();
|
||||
}
|
||||
let mut cx = Ctx { doc, count: 0 };
|
||||
add_group(&mut cx, tree.root(), frame, Affine::IDENTITY, 0);
|
||||
Ok(frame)
|
||||
}
|
||||
|
|
@ -0,0 +1,356 @@
|
|||
//! SVG export and import.
|
||||
//!
|
||||
//! Export writes one `<svg>` sized to the exported nodes' bounds. Shapes become `<path>`s with
|
||||
//! their fills (solid, linear/radial gradients; angular and diamond fall back to their first
|
||||
//! colour... or a raster pattern), strokes are written as filled outlines so Inside/Outside
|
||||
//! alignment survives exactly, text is outlined, images are embedded as data URIs, and shadows
|
||||
//! and blurs become SVG filters.
|
||||
//!
|
||||
//! Import (via usvg) turns an SVG file into a frame of vector nodes.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod import;
|
||||
pub use import::{import_svg, ImportError};
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use base64::Engine as _;
|
||||
use figcraft_color::{Color, GradientKind, Paint, PaintKind};
|
||||
use figcraft_doc::{Document, EffectKind, Node, NodeId, NodeKind};
|
||||
use figcraft_geom::{Affine, BezPath, Rect};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SvgOptions {
|
||||
/// Include node ids as `id` attributes (Figma's "Include id attribute").
|
||||
pub include_ids: bool,
|
||||
/// Outline text (always true for now: no font embedding).
|
||||
pub outline_text: bool,
|
||||
}
|
||||
|
||||
impl Default for SvgOptions {
|
||||
fn default() -> Self {
|
||||
Self { include_ids: false, outline_text: true }
|
||||
}
|
||||
}
|
||||
|
||||
struct W<'a> {
|
||||
doc: &'a Document,
|
||||
defs: String,
|
||||
body: String,
|
||||
next: u32,
|
||||
opts: &'a SvgOptions,
|
||||
}
|
||||
|
||||
fn num(v: f64) -> String {
|
||||
if !v.is_finite() {
|
||||
return "0".into();
|
||||
}
|
||||
let r = (v * 1000.0).round() / 1000.0;
|
||||
let s = format!("{r}");
|
||||
if s == "-0" { "0".into() } else { s }
|
||||
}
|
||||
|
||||
fn path_d(p: &BezPath) -> String {
|
||||
use figcraft_geom::kurbo::PathEl::*;
|
||||
let mut s = String::new();
|
||||
for el in p.elements() {
|
||||
match *el {
|
||||
MoveTo(a) => {
|
||||
let _ = write!(s, "M{} {}", num(a.x), num(a.y));
|
||||
}
|
||||
LineTo(a) => {
|
||||
let _ = write!(s, "L{} {}", num(a.x), num(a.y));
|
||||
}
|
||||
QuadTo(a, b) => {
|
||||
let _ = write!(s, "Q{} {} {} {}", num(a.x), num(a.y), num(b.x), num(b.y));
|
||||
}
|
||||
CurveTo(a, b, c) => {
|
||||
let _ = write!(s, "C{} {} {} {} {} {}", num(a.x), num(a.y), num(b.x), num(b.y), num(c.x), num(c.y));
|
||||
}
|
||||
ClosePath => s.push('Z'),
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn matrix(a: Affine) -> String {
|
||||
let c = a.as_coeffs();
|
||||
format!("matrix({} {} {} {} {} {})", num(c[0]), num(c[1]), num(c[2]), num(c[3]), num(c[4]), num(c[5]))
|
||||
}
|
||||
|
||||
fn esc(s: &str) -> String {
|
||||
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
|
||||
fn hex(c: Color) -> String {
|
||||
format!("#{}", c.to_hex())
|
||||
}
|
||||
|
||||
impl W<'_> {
|
||||
fn id(&mut self, prefix: &str) -> String {
|
||||
self.next += 1;
|
||||
format!("{prefix}{}", self.next)
|
||||
}
|
||||
|
||||
/// `fill="..." [fill-opacity]` for a paint on a w×h box; `None` when it paints nothing.
|
||||
fn paint_attr(&mut self, p: &Paint, w: f64, h: f64) -> Option<String> {
|
||||
if !p.visible || p.opacity <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let blend = if matches!(p.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" style=\"mix-blend-mode:{}\"", p.blend.css())
|
||||
};
|
||||
match &p.kind {
|
||||
PaintKind::Solid { color } => {
|
||||
let a = color.a * p.opacity;
|
||||
let op = if a < 0.999 { format!(" fill-opacity=\"{}\"", num(f64::from(a))) } else { String::new() };
|
||||
Some(format!("fill=\"{}\"{op}{blend}", hex(*color)))
|
||||
}
|
||||
PaintKind::Gradient(g) => {
|
||||
let gid = self.id("paint");
|
||||
let stops: String = g
|
||||
.sorted_stops()
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let a = s.color.a * p.opacity;
|
||||
let op = if a < 0.999 { format!(" stop-opacity=\"{}\"", num(f64::from(a))) } else { String::new() };
|
||||
format!("<stop offset=\"{}\" stop-color=\"{}\"{op}/>", num(f64::from(s.position)), hex(s.color))
|
||||
})
|
||||
.collect();
|
||||
let (f, t, k) = ((g.from.0 * w, g.from.1 * h), (g.to.0 * w, g.to.1 * h), (g.width.0 * w, g.width.1 * h));
|
||||
match g.kind {
|
||||
GradientKind::Linear => {
|
||||
let _ = write!(
|
||||
self.defs,
|
||||
"<linearGradient id=\"{gid}\" x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" gradientUnits=\"userSpaceOnUse\">{stops}</linearGradient>",
|
||||
num(f.0),
|
||||
num(f.1),
|
||||
num(t.0),
|
||||
num(t.1)
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
// Unit circle mapped onto the handle ellipse.
|
||||
let m = Affine::new([t.0 - f.0, t.1 - f.1, k.0 - f.0, k.1 - f.1, f.0, f.1]);
|
||||
let _ = write!(
|
||||
self.defs,
|
||||
"<radialGradient id=\"{gid}\" cx=\"0\" cy=\"0\" r=\"1\" gradientUnits=\"userSpaceOnUse\" gradientTransform=\"{}\">{stops}</radialGradient>",
|
||||
matrix(m)
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(format!("fill=\"url(#{gid})\"{blend}"))
|
||||
}
|
||||
PaintKind::Image(img) => {
|
||||
let data = self.doc.images.get(&img.image)?;
|
||||
let pid = self.id("pattern");
|
||||
let iid = self.id("image");
|
||||
let mime = sniff_mime(&data.bytes);
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(data.bytes.as_slice());
|
||||
let t = figcraft_render::paint::image_transform(img, f64::from(data.width), f64::from(data.height), w, h);
|
||||
let m = Affine::new([f64::from(t.sx), f64::from(t.ky), f64::from(t.kx), f64::from(t.sy), f64::from(t.tx), f64::from(t.ty)]);
|
||||
let (pw, ph) = if img.scale_mode == figcraft_color::ImageScaleMode::Tile {
|
||||
(f64::from(data.width), f64::from(data.height))
|
||||
} else {
|
||||
(f64::from(data.width) * 4.0, f64::from(data.height) * 4.0)
|
||||
};
|
||||
let _ = write!(
|
||||
self.defs,
|
||||
"<pattern id=\"{pid}\" patternUnits=\"userSpaceOnUse\" width=\"{}\" height=\"{}\" patternTransform=\"{}\"><image id=\"{iid}\" width=\"{}\" height=\"{}\" href=\"data:{mime};base64,{b64}\"/></pattern>",
|
||||
num(pw),
|
||||
num(ph),
|
||||
matrix(m),
|
||||
data.width,
|
||||
data.height
|
||||
);
|
||||
let op = if p.opacity < 0.999 { format!(" fill-opacity=\"{}\"", num(f64::from(p.opacity))) } else { String::new() };
|
||||
Some(format!("fill=\"url(#{pid})\"{op}{blend}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn filter_for(&mut self, n: &Node) -> Option<String> {
|
||||
let fx: Vec<&figcraft_doc::Effect> = n.effects.iter().filter(|e| e.visible).collect();
|
||||
if fx.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let fid = self.id("filter");
|
||||
let mut f = format!("<filter id=\"{fid}\" x=\"-50%\" y=\"-50%\" width=\"200%\" height=\"200%\" color-interpolation-filters=\"sRGB\">");
|
||||
let mut merge = Vec::new();
|
||||
for (i, e) in fx.iter().enumerate() {
|
||||
match &e.kind {
|
||||
EffectKind::DropShadow(s) => {
|
||||
let _ = write!(
|
||||
f,
|
||||
"<feMorphology in=\"SourceAlpha\" operator=\"{}\" radius=\"{}\" result=\"s{i}m\"/><feOffset in=\"s{i}m\" dx=\"{}\" dy=\"{}\"/><feGaussianBlur stdDeviation=\"{}\"/><feColorMatrix type=\"matrix\" values=\"0 0 0 0 {} 0 0 0 0 {} 0 0 0 0 {} 0 0 0 {} 0\" result=\"s{i}\"/>",
|
||||
if s.spread >= 0.0 { "dilate" } else { "erode" },
|
||||
num(s.spread.abs()),
|
||||
num(s.offset.0),
|
||||
num(s.offset.1),
|
||||
num(s.radius / 2.0),
|
||||
num(f64::from(s.color.r)),
|
||||
num(f64::from(s.color.g)),
|
||||
num(f64::from(s.color.b)),
|
||||
num(f64::from(s.color.a))
|
||||
);
|
||||
merge.push(format!("s{i}"));
|
||||
}
|
||||
EffectKind::LayerBlur { radius } => {
|
||||
let _ = write!(f, "<feGaussianBlur in=\"SourceGraphic\" stdDeviation=\"{}\" result=\"blur\"/>", num(radius / 2.0));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let has_blur = fx.iter().any(|e| matches!(e.kind, EffectKind::LayerBlur { .. }));
|
||||
f.push_str("<feMerge>");
|
||||
for m in &merge {
|
||||
let _ = write!(f, "<feMergeNode in=\"{m}\"/>");
|
||||
}
|
||||
let _ = write!(f, "<feMergeNode in=\"{}\"/></feMerge></filter>", if has_blur { "blur" } else { "SourceGraphic" });
|
||||
self.defs.push_str(&f);
|
||||
Some(fid)
|
||||
}
|
||||
|
||||
fn node(&mut self, id: NodeId, xf: Affine, depth: usize) {
|
||||
if depth > 256 {
|
||||
return;
|
||||
}
|
||||
let Some(n) = self.doc.node(id) else { return };
|
||||
if !n.visible || n.is_mask {
|
||||
return;
|
||||
}
|
||||
let xf = xf * n.transform;
|
||||
let mut attrs = String::new();
|
||||
if self.opts.include_ids {
|
||||
let _ = write!(attrs, " id=\"{}\"", esc(&n.name.replace(' ', "_")));
|
||||
}
|
||||
if n.opacity < 0.999 {
|
||||
let _ = write!(attrs, " opacity=\"{}\"", num(f64::from(n.opacity)));
|
||||
}
|
||||
if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) {
|
||||
let _ = write!(attrs, " style=\"mix-blend-mode:{}\"", n.blend.css());
|
||||
}
|
||||
if let Some(fid) = self.filter_for(n) {
|
||||
let _ = write!(attrs, " filter=\"url(#{fid})\"");
|
||||
}
|
||||
let _ = write!(self.body, "<g{attrs}>");
|
||||
let (w, h) = (n.width, n.height);
|
||||
let outline = match &n.kind {
|
||||
NodeKind::Group | NodeKind::Page { .. } => None,
|
||||
_ => figcraft_render::node_outline(self.doc, n),
|
||||
};
|
||||
let closed = figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. } | NodeKind::Text(_));
|
||||
if let Some((path, rule)) = &outline
|
||||
&& closed
|
||||
{
|
||||
let fr = if *rule == figcraft_render::FillRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" };
|
||||
for p in &n.fills {
|
||||
if let Some(a) = self.paint_attr(p, w, h) {
|
||||
let _ = write!(self.body, "<path transform=\"{}\" d=\"{}\" {a}{fr}/>", matrix(xf), path_d(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !matches!(n.kind, NodeKind::BooleanOp { .. }) && !n.children.is_empty() {
|
||||
let clip = n.clip && n.kind.is_frame_like();
|
||||
if clip && let Some((p, _)) = &outline {
|
||||
let cid = self.id("clip");
|
||||
let mut cp = p.clone();
|
||||
cp.apply_affine(xf);
|
||||
let _ = write!(self.defs, "<clipPath id=\"{cid}\"><path d=\"{}\"/></clipPath>", path_d(&cp));
|
||||
let _ = write!(self.body, "<g clip-path=\"url(#{cid})\">");
|
||||
}
|
||||
let kids = n.children.clone();
|
||||
let mut i = 0;
|
||||
while i < kids.len() {
|
||||
let Some(&c) = kids.get(i) else { break };
|
||||
if self.doc.node(c).is_some_and(|cn| cn.is_mask && cn.visible) {
|
||||
let mid = self.id("mask");
|
||||
let saved = std::mem::take(&mut self.body);
|
||||
if let Some(mn) = self.doc.node(c) {
|
||||
let mxf = xf * mn.transform;
|
||||
if let Some((mp, _)) = figcraft_render::node_outline(self.doc, mn) {
|
||||
let _ = write!(self.body, "<path transform=\"{}\" d=\"{}\" fill=\"white\"/>", matrix(mxf), path_d(&mp));
|
||||
}
|
||||
}
|
||||
let mask_body = std::mem::replace(&mut self.body, saved);
|
||||
let _ = write!(self.defs, "<mask id=\"{mid}\" maskUnits=\"userSpaceOnUse\">{mask_body}</mask>");
|
||||
let _ = write!(self.body, "<g mask=\"url(#{mid})\">");
|
||||
let mut j = i + 1;
|
||||
while let Some(&k) = kids.get(j) {
|
||||
if self.doc.node(k).is_some_and(|kn| kn.is_mask) {
|
||||
break;
|
||||
}
|
||||
self.node(k, xf, depth + 1);
|
||||
j += 1;
|
||||
}
|
||||
self.body.push_str("</g>");
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
self.node(c, xf, depth + 1);
|
||||
i += 1;
|
||||
}
|
||||
if clip && outline.is_some() {
|
||||
self.body.push_str("</g>");
|
||||
}
|
||||
}
|
||||
if n.has_visible_stroke() {
|
||||
let geom = match &n.kind {
|
||||
NodeKind::BooleanOp { .. } | NodeKind::Text(_) => outline.as_ref().map(|(p, _)| p.clone()),
|
||||
_ => figcraft_doc::geometry::stroke_geometry(n),
|
||||
};
|
||||
if let Some(g) = geom {
|
||||
let area = figcraft_render::draw::stroke_area(n, &g, closed);
|
||||
let even = n.stroke.sides.is_some() || (n.stroke.dashes.is_empty() && closed && n.stroke.align != figcraft_doc::StrokeAlign::Center);
|
||||
let fr = if even { " fill-rule=\"evenodd\"" } else { "" };
|
||||
for p in &n.strokes {
|
||||
if let Some(a) = self.paint_attr(p, w, h) {
|
||||
let _ = write!(self.body, "<path transform=\"{}\" d=\"{}\" {a}{fr}/>", matrix(xf), path_d(&area));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.body.push_str("</g>");
|
||||
}
|
||||
}
|
||||
|
||||
fn sniff_mime(b: &[u8]) -> &'static str {
|
||||
if b.starts_with(&[0x89, b'P', b'N', b'G']) {
|
||||
"image/png"
|
||||
} else if b.starts_with(&[0xFF, 0xD8]) {
|
||||
"image/jpeg"
|
||||
} else if b.starts_with(b"GIF") {
|
||||
"image/gif"
|
||||
} else if b.len() > 12 && b.get(8..12) == Some(b"WEBP") {
|
||||
"image/webp"
|
||||
} else {
|
||||
"application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
/// Export `ids` (in z-order) as one SVG document covering their export bounds.
|
||||
pub fn export_svg(doc: &Document, ids: &[NodeId], opts: &SvgOptions) -> Option<String> {
|
||||
let bounds: Rect = ids.iter().filter_map(|i| figcraft_render::export_bounds(doc, *i)).reduce(|a, b| a.union(b))?;
|
||||
let mut w = W { doc, defs: String::new(), body: String::new(), next: 0, opts };
|
||||
let origin = Affine::translate((-bounds.x0, -bounds.y0));
|
||||
for id in ids {
|
||||
let parent_xf = doc.node(*id).and_then(|n| n.parent).map(|p| doc.world_transform(p)).unwrap_or(Affine::IDENTITY);
|
||||
w.node(*id, origin * parent_xf, 0);
|
||||
}
|
||||
let (bw, bh) = (bounds.width().max(0.0), bounds.height().max(0.0));
|
||||
let defs = if w.defs.is_empty() { String::new() } else { format!("<defs>{}</defs>", w.defs) };
|
||||
Some(format!(
|
||||
"<svg width=\"{}\" height=\"{}\" viewBox=\"0 0 {} {}\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">{}{}</svg>\n",
|
||||
num(bw),
|
||||
num(bh),
|
||||
num(bw),
|
||||
num(bh),
|
||||
w.body,
|
||||
defs
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
use super::*;
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::{Document, Effect, NodeKind};
|
||||
|
||||
#[test]
|
||||
fn export_basic_shapes() {
|
||||
let mut d = Document::new("t");
|
||||
let p = d.first_page().unwrap();
|
||||
let f = d.create(p, NodeKind::Frame, "Card", 0.0, 0.0, 200.0, 100.0).unwrap();
|
||||
let r = d.create(f, NodeKind::Rectangle, "R", 10.0, 10.0, 50.0, 50.0).unwrap();
|
||||
d.get_mut(r).unwrap().fills = vec![Paint::solid(Color::from_hex("FF0000").unwrap())];
|
||||
d.get_mut(r).unwrap().effects.push(Effect::drop_shadow());
|
||||
let svg = export_svg(&d, &[f], &SvgOptions::default()).unwrap();
|
||||
assert!(svg.starts_with("<svg width=\"200\" height=\"100\""));
|
||||
assert!(svg.contains("#FF0000"));
|
||||
assert!(svg.contains("<filter"));
|
||||
assert!(svg.contains("clip-path"));
|
||||
// Round-trip through import.
|
||||
let mut d2 = Document::new("t2");
|
||||
let p2 = d2.first_page().unwrap();
|
||||
let frame = import_svg(&mut d2, svg.as_bytes(), p2, 0.0, 0.0, "Imported").unwrap();
|
||||
assert!(!d2.subtree(frame).is_empty());
|
||||
assert!(d2.validate().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_rejects_garbage() {
|
||||
let mut d = Document::new("t");
|
||||
let p = d.first_page().unwrap();
|
||||
assert!(import_svg(&mut d, b"not svg", p, 0.0, 0.0, "x").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_gradient_and_groups() {
|
||||
let svg = br##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50">
|
||||
<defs><linearGradient id="g"><stop offset="0" stop-color="#f00"/><stop offset="1" stop-color="#00f"/></linearGradient></defs>
|
||||
<g id="grp"><rect x="10" y="10" width="30" height="30" fill="url(#g)" stroke="#000" stroke-width="2"/></g>
|
||||
<circle cx="70" cy="25" r="20" fill="#0f0" fill-opacity="0.5"/></svg>"##;
|
||||
let mut d = Document::new("t");
|
||||
let p = d.first_page().unwrap();
|
||||
let f = import_svg(&mut d, svg, p, 0.0, 0.0, "x").unwrap();
|
||||
let kinds: Vec<&str> = d.subtree(f).iter().map(|i| d.node(*i).unwrap().kind.type_name()).collect();
|
||||
assert!(kinds.contains(&"GROUP"));
|
||||
assert!(kinds.iter().filter(|k| **k == "VECTOR").count() >= 2);
|
||||
}
|
||||
Loading…
Reference in New Issue