mirror of
https://github.com/penpot/penpot.git
synced 2025-12-27 00:18:52 -05:00
Compare commits
60 Commits
tokens-api
...
eva-replac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91b1c210d1 | ||
|
|
eb1eeb4750 | ||
|
|
a78477592b | ||
|
|
8707ff6511 | ||
|
|
3d8a251741 | ||
|
|
0956b66281 | ||
|
|
007b3f11f9 | ||
|
|
e8201402a7 | ||
|
|
8a22477b96 | ||
|
|
a661b2564f | ||
|
|
2c3732f3f4 | ||
|
|
e16645227b | ||
|
|
45665a3c21 | ||
|
|
3e684ea54f | ||
|
|
179e6a195d | ||
|
|
98039f13d8 | ||
|
|
40c27591f6 | ||
|
|
91d20a46d1 | ||
|
|
50bead7c56 | ||
|
|
b75b999903 | ||
|
|
810f1721c8 | ||
|
|
a4646373cf | ||
|
|
f111cbb2a4 | ||
|
|
7d36bc4025 | ||
|
|
7be8ac3fd7 | ||
|
|
9216d965ef | ||
|
|
520e979363 | ||
|
|
a38f425dd3 | ||
|
|
4e84deca44 | ||
|
|
0d21e52068 | ||
|
|
1b29e9a50f | ||
|
|
9f567c3bf4 | ||
|
|
1ba15e5d10 | ||
|
|
37e45a8bbf | ||
|
|
3471d40f46 | ||
|
|
c6b64a8e39 | ||
|
|
511e80c948 | ||
|
|
f5a640d104 | ||
|
|
3ae7c514e4 | ||
|
|
fad9ed1c48 | ||
|
|
0caaefefea | ||
|
|
b179aa79b1 | ||
|
|
405ddb60d8 | ||
|
|
95c0d42d5b | ||
|
|
721b337511 | ||
|
|
359379be09 | ||
|
|
876d5783cf | ||
|
|
786f73767b | ||
|
|
4282cdcd2c | ||
|
|
e889413f26 | ||
|
|
115273b478 | ||
|
|
fdddd3284a | ||
|
|
51385a04a0 | ||
|
|
f96ed8ccd6 | ||
|
|
bda5de5c1b | ||
|
|
59f3b4db4c | ||
|
|
7ee03ad911 | ||
|
|
130b8c8214 | ||
|
|
0198d41757 | ||
|
|
567a955151 |
@@ -132,3 +132,94 @@ Some naming conventions:
|
||||
(if-let [last-period (str/last-index-of s ".")]
|
||||
[(subs s 0 (inc last-period)) (subs s (inc last-period))]
|
||||
[s ""]))
|
||||
|
||||
;; Tree building functions --------------------------------------------------
|
||||
|
||||
"Build tree structure from flat list of paths"
|
||||
|
||||
"`build-tree-root` is the main function to build the tree."
|
||||
|
||||
"Receives a list of segments with 'name' properties representing paths,
|
||||
and a separator string."
|
||||
"E.g segments = [{... :name 'one/two/three'} {... :name 'one/two/four'} {... :name 'one/five'}]"
|
||||
|
||||
"Transforms into a tree structure like:
|
||||
[{:name 'one'
|
||||
:path 'one'
|
||||
:depth 0
|
||||
:leaf nil
|
||||
:children-fn (fn [] [{:name 'two'
|
||||
:path 'one.two'
|
||||
:depth 1
|
||||
:leaf nil
|
||||
:children-fn (fn [] [{... :name 'three'} {... :name 'four'}])}
|
||||
{:name 'five'
|
||||
:path 'one.five'
|
||||
:depth 1
|
||||
:leaf {... :name 'five'}
|
||||
...}])}]"
|
||||
|
||||
(defn- sort-by-children
|
||||
"Sorts segments so that those with children come first."
|
||||
[segments separator]
|
||||
(sort-by (fn [segment]
|
||||
(let [path (split-path (:name segment) :separator separator)
|
||||
path-length (count path)]
|
||||
(if (= path-length 1)
|
||||
1
|
||||
0)))
|
||||
segments))
|
||||
|
||||
(defn- group-by-first-segment
|
||||
"Groups segments by their first path segment and update segment name."
|
||||
[segments separator]
|
||||
(reduce (fn [acc segment]
|
||||
(let [[first-segment & remaining-segments] (split-path (:name segment) :separator separator)
|
||||
rest-path (when (seq remaining-segments) (join-path remaining-segments :separator separator :with-spaces? false))]
|
||||
(update acc first-segment (fnil conj [])
|
||||
(if rest-path
|
||||
(assoc segment :name rest-path)
|
||||
segment))))
|
||||
{}
|
||||
segments))
|
||||
|
||||
(defn- sort-and-group-segments
|
||||
"Sorts elements and groups them by their first path segment."
|
||||
[segments separator]
|
||||
(let [sorted (sort-by-children segments separator)
|
||||
grouped (group-by-first-segment sorted separator)]
|
||||
grouped))
|
||||
|
||||
(defn- build-tree-node
|
||||
"Builds a single tree node with lazy children."
|
||||
[segment-name remaining-segments separator parent-path depth]
|
||||
(let [current-path (if parent-path
|
||||
(str parent-path "." segment-name)
|
||||
segment-name)
|
||||
|
||||
is-leaf? (and (seq remaining-segments)
|
||||
(every? (fn [segment]
|
||||
(let [remaining-segment-name (first (split-path (:name segment) :separator separator))]
|
||||
(= segment-name remaining-segment-name)))
|
||||
remaining-segments))
|
||||
|
||||
leaf-segment (when is-leaf? (first remaining-segments))
|
||||
node {:name segment-name
|
||||
:path current-path
|
||||
:depth depth
|
||||
:leaf leaf-segment
|
||||
:children-fn (when-not is-leaf?
|
||||
(fn []
|
||||
(let [grouped-elements (sort-and-group-segments remaining-segments separator)]
|
||||
(mapv (fn [[child-segment-name remaining-child-segments]]
|
||||
(build-tree-node child-segment-name remaining-child-segments separator current-path (inc depth)))
|
||||
grouped-elements))))}]
|
||||
node))
|
||||
|
||||
(defn build-tree-root
|
||||
"Builds the root level of the tree."
|
||||
[segments separator]
|
||||
(let [grouped-elements (sort-and-group-segments segments separator)]
|
||||
(mapv (fn [[segment-name remaining-segments]]
|
||||
(build-tree-node segment-name remaining-segments separator nil 0))
|
||||
grouped-elements)))
|
||||
|
||||
@@ -234,16 +234,15 @@
|
||||
"Calculate the boolean content from shape and objects. Returns a
|
||||
packed PathData instance"
|
||||
[shape objects]
|
||||
(let [content (if (fn? wasm:calc-bool-content)
|
||||
(wasm:calc-bool-content (get shape :bool-type)
|
||||
(get shape :shapes))
|
||||
(calc-bool-content* shape objects))]
|
||||
(let [content (calc-bool-content* shape objects)]
|
||||
(impl/path-data content)))
|
||||
|
||||
(defn update-bool-shape
|
||||
"Calculates the selrect+points for the boolean shape"
|
||||
[shape objects]
|
||||
(let [content (calc-bool-content shape objects)
|
||||
(let [content (if (fn? wasm:calc-bool-content)
|
||||
(wasm:calc-bool-content shape objects)
|
||||
(calc-bool-content shape objects))
|
||||
shape (assoc shape :content content)]
|
||||
(update-geometry shape)))
|
||||
|
||||
|
||||
@@ -223,15 +223,19 @@ http {
|
||||
add_header X-Cache-Status $upstream_cache_status;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|jpg|png|svg|ttf|woff|woff2|wasm)$ {
|
||||
location ~* \.(jpg|png|svg|ttf|woff|woff2)$ {
|
||||
add_header Cache-Control "public, max-age=604800" always; # 7 days
|
||||
}
|
||||
|
||||
location ~* \.(js|css|wasm)$ {
|
||||
add_header Cache-Control "no-store" always;
|
||||
}
|
||||
|
||||
location ~ ^/[^/]+/(.*)$ {
|
||||
return 301 " /404";
|
||||
}
|
||||
|
||||
add_header Cache-Control "no-store, no-cache, max-age=0" always;
|
||||
add_header Cache-Control "no-store" always;
|
||||
try_files $uri /index.html$is_args$args /index.html =404;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,5 +50,8 @@
|
||||
|
||||
:shadow-cljs
|
||||
{:main-opts ["-m" "shadow.cljs.devtools.cli"]
|
||||
:jvm-opts ["--sun-misc-unsafe-memory-access=allow" "-Dpenpot.wasm.profile-marks=true"]}
|
||||
:jvm-opts ["--sun-misc-unsafe-memory-access=allow"
|
||||
"-Dpenpot.wasm.profile-marks=true"
|
||||
"-XX:+UnlockExperimentalVMOptions"
|
||||
"-XX:CompileCommand=blackhole,criterium.blackhole.Blackhole::consume"]}
|
||||
}}
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
"@penpot/hljs": "portal:./vendor/hljs",
|
||||
"@penpot/mousetrap": "portal:./vendor/mousetrap",
|
||||
"@penpot/plugins-runtime": "1.3.2",
|
||||
"@penpot/svgo": "penpot/svgo#v3.1",
|
||||
"@penpot/svgo": "penpot/svgo#v3.2",
|
||||
"@penpot/text-editor": "portal:./text-editor",
|
||||
"@tokens-studio/sd-transforms": "1.2.11",
|
||||
"@zip.js/zip.js": "patch:@zip.js/zip.js@npm%3A2.7.60#~/.yarn/patches/@zip.js-zip.js-npm-2.7.60-b6b814410b.patch",
|
||||
|
||||
58
frontend/playwright/data/text-editor/get-file-blank.json
Normal file
58
frontend/playwright/data/text-editor/get-file-blank.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"~:features": {
|
||||
"~#set": [
|
||||
"layout/grid",
|
||||
"styles/v2",
|
||||
"fdata/pointer-map",
|
||||
"fdata/objects-map",
|
||||
"components/v2",
|
||||
"fdata/shape-data-type"
|
||||
]
|
||||
},
|
||||
"~:permissions": {
|
||||
"~:type": "~:membership",
|
||||
"~:is-owner": true,
|
||||
"~:is-admin": true,
|
||||
"~:can-edit": true,
|
||||
"~:can-read": true,
|
||||
"~:is-logged": true
|
||||
},
|
||||
"~:has-media-trimmed": false,
|
||||
"~:comment-thread-seqn": 0,
|
||||
"~:name": "New File 1",
|
||||
"~:revn": 11,
|
||||
"~:modified-at": "~m1713873823633",
|
||||
"~:id": "~uc7ce0794-0992-8105-8004-38f280443849",
|
||||
"~:is-shared": false,
|
||||
"~:version": 46,
|
||||
"~:project-id": "~uc7ce0794-0992-8105-8004-38e630f7920b",
|
||||
"~:created-at": "~m1713536343369",
|
||||
"~:data": {
|
||||
"~:pages": [
|
||||
"~u66697432-c33d-8055-8006-2c62cc084cad"
|
||||
],
|
||||
"~:pages-index": {
|
||||
"~u66697432-c33d-8055-8006-2c62cc084cad": {
|
||||
"~#penpot/pointer": [
|
||||
"~ude58c8f6-c5c2-8196-8004-3df9e2e52d88",
|
||||
{
|
||||
"~:created-at": "~m1713873823636"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"~:id": "~uc7ce0794-0992-8105-8004-38f280443849",
|
||||
"~:options": {
|
||||
"~:components-v2": true
|
||||
},
|
||||
"~:recent-colors": [
|
||||
{
|
||||
"~:color": "#0000ff",
|
||||
"~:opacity": 1,
|
||||
"~:id": null,
|
||||
"~:file-id": null,
|
||||
"~:image": null
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
345
frontend/playwright/data/text-editor/get-file-lorem-ipsum.json
Normal file
345
frontend/playwright/data/text-editor/get-file-lorem-ipsum.json
Normal file
@@ -0,0 +1,345 @@
|
||||
{
|
||||
"~:features": {
|
||||
"~#set": [
|
||||
"fdata/path-data",
|
||||
"plugins/runtime",
|
||||
"design-tokens/v1",
|
||||
"layout/grid",
|
||||
"styles/v2",
|
||||
"fdata/pointer-map",
|
||||
"fdata/objects-map",
|
||||
"components/v2",
|
||||
"fdata/shape-data-type",
|
||||
"text-editor/v2"
|
||||
]
|
||||
},
|
||||
"~:team-id": "~u9e6e22b2-db76-81d6-8006-75d7cdbb8bad",
|
||||
"~:permissions": {
|
||||
"~:type": "~:membership",
|
||||
"~:is-owner": true,
|
||||
"~:is-admin": true,
|
||||
"~:can-edit": true,
|
||||
"~:can-read": true,
|
||||
"~:is-logged": true
|
||||
},
|
||||
"~:has-media-trimmed": false,
|
||||
"~:comment-thread-seqn": 0,
|
||||
"~:name": "Bug 11552",
|
||||
"~:revn": 3,
|
||||
"~:modified-at": "~m1753957736516",
|
||||
"~:vern": 0,
|
||||
"~:id": "~u238a17e0-75ff-8075-8006-934586ea2230",
|
||||
"~:is-shared": false,
|
||||
"~:migrations": {
|
||||
"~#ordered-set": [
|
||||
"legacy-2",
|
||||
"legacy-3",
|
||||
"legacy-5",
|
||||
"legacy-6",
|
||||
"legacy-7",
|
||||
"legacy-8",
|
||||
"legacy-9",
|
||||
"legacy-10",
|
||||
"legacy-11",
|
||||
"legacy-12",
|
||||
"legacy-13",
|
||||
"legacy-14",
|
||||
"legacy-16",
|
||||
"legacy-17",
|
||||
"legacy-18",
|
||||
"legacy-19",
|
||||
"legacy-25",
|
||||
"legacy-26",
|
||||
"legacy-27",
|
||||
"legacy-28",
|
||||
"legacy-29",
|
||||
"legacy-31",
|
||||
"legacy-32",
|
||||
"legacy-33",
|
||||
"legacy-34",
|
||||
"legacy-36",
|
||||
"legacy-37",
|
||||
"legacy-38",
|
||||
"legacy-39",
|
||||
"legacy-40",
|
||||
"legacy-41",
|
||||
"legacy-42",
|
||||
"legacy-43",
|
||||
"legacy-44",
|
||||
"legacy-45",
|
||||
"legacy-46",
|
||||
"legacy-47",
|
||||
"legacy-48",
|
||||
"legacy-49",
|
||||
"legacy-50",
|
||||
"legacy-51",
|
||||
"legacy-52",
|
||||
"legacy-53",
|
||||
"legacy-54",
|
||||
"legacy-55",
|
||||
"legacy-56",
|
||||
"legacy-57",
|
||||
"legacy-59",
|
||||
"legacy-62",
|
||||
"legacy-65",
|
||||
"legacy-66",
|
||||
"legacy-67",
|
||||
"0001-remove-tokens-from-groups",
|
||||
"0002-normalize-bool-content-v2",
|
||||
"0002-clean-shape-interactions",
|
||||
"0003-fix-root-shape",
|
||||
"0003-convert-path-content-v2",
|
||||
"0004-clean-shadow-color",
|
||||
"0005-deprecate-image-type",
|
||||
"0006-fix-old-texts-fills",
|
||||
"0007-clear-invalid-strokes-and-fills-v2",
|
||||
"0008-fix-library-colors-v4",
|
||||
"0009-clean-library-colors",
|
||||
"0009-add-partial-text-touched-flags"
|
||||
]
|
||||
},
|
||||
"~:version": 67,
|
||||
"~:project-id": "~u9e6e22b2-db76-81d6-8006-75d7cdc30669",
|
||||
"~:created-at": "~m1753957644225",
|
||||
"~:data": {
|
||||
"~:pages": ["~u238a17e0-75ff-8075-8006-934586ea2231"],
|
||||
"~:pages-index": {
|
||||
"~u238a17e0-75ff-8075-8006-934586ea2231": {
|
||||
"~:objects": {
|
||||
"~u00000000-0000-0000-0000-000000000000": {
|
||||
"~#shape": {
|
||||
"~:y": 0,
|
||||
"~:hide-fill-on-export": false,
|
||||
"~:transform": {
|
||||
"~#matrix": {
|
||||
"~:a": 1.0,
|
||||
"~:b": 0.0,
|
||||
"~:c": 0.0,
|
||||
"~:d": 1.0,
|
||||
"~:e": 0.0,
|
||||
"~:f": 0.0
|
||||
}
|
||||
},
|
||||
"~:rotation": 0,
|
||||
"~:name": "Root Frame",
|
||||
"~:width": 0.01,
|
||||
"~:type": "~:frame",
|
||||
"~:points": [
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 0.0,
|
||||
"~:y": 0.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 0.01,
|
||||
"~:y": 0.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 0.01,
|
||||
"~:y": 0.01
|
||||
}
|
||||
},
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 0.0,
|
||||
"~:y": 0.01
|
||||
}
|
||||
}
|
||||
],
|
||||
"~:r2": 0,
|
||||
"~:proportion-lock": false,
|
||||
"~:transform-inverse": {
|
||||
"~#matrix": {
|
||||
"~:a": 1.0,
|
||||
"~:b": 0.0,
|
||||
"~:c": 0.0,
|
||||
"~:d": 1.0,
|
||||
"~:e": 0.0,
|
||||
"~:f": 0.0
|
||||
}
|
||||
},
|
||||
"~:r3": 0,
|
||||
"~:r1": 0,
|
||||
"~:id": "~u00000000-0000-0000-0000-000000000000",
|
||||
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
|
||||
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
|
||||
"~:strokes": [],
|
||||
"~:x": 0,
|
||||
"~:proportion": 1.0,
|
||||
"~:r4": 0,
|
||||
"~:selrect": {
|
||||
"~#rect": {
|
||||
"~:x": 0,
|
||||
"~:y": 0,
|
||||
"~:width": 0.01,
|
||||
"~:height": 0.01,
|
||||
"~:x1": 0,
|
||||
"~:y1": 0,
|
||||
"~:x2": 0.01,
|
||||
"~:y2": 0.01
|
||||
}
|
||||
},
|
||||
"~:fills": [
|
||||
{
|
||||
"~:fill-color": "#FFFFFF",
|
||||
"~:fill-opacity": 1
|
||||
}
|
||||
],
|
||||
"~:flip-x": null,
|
||||
"~:height": 0.01,
|
||||
"~:flip-y": null,
|
||||
"~:shapes": ["~ucc6f0580-449c-8019-8006-9345db077fa0"]
|
||||
}
|
||||
},
|
||||
"~ucc6f0580-449c-8019-8006-9345db077fa0": {
|
||||
"~#shape": {
|
||||
"~:y": 438,
|
||||
"~:transform": {
|
||||
"~#matrix": {
|
||||
"~:a": 1.0,
|
||||
"~:b": 0.0,
|
||||
"~:c": 0.0,
|
||||
"~:d": 1.0,
|
||||
"~:e": 0.0,
|
||||
"~:f": 0.0
|
||||
}
|
||||
},
|
||||
"~:rotation": 0,
|
||||
"~:grow-type": "~:auto-width",
|
||||
"~:content": {
|
||||
"~:type": "root",
|
||||
"~:key": "1s4am1jl24s",
|
||||
"~:children": [
|
||||
{
|
||||
"~:type": "paragraph-set",
|
||||
"~:children": [
|
||||
{
|
||||
"~:line-height": "1.2",
|
||||
"~:font-style": "normal",
|
||||
"~:children": [
|
||||
{
|
||||
"~:line-height": "1.2",
|
||||
"~:font-style": "normal",
|
||||
"~:typography-ref-id": null,
|
||||
"~:text-transform": "none",
|
||||
"~:font-id": "sourcesanspro",
|
||||
"~:key": "13p0zwl2yhc",
|
||||
"~:font-size": "14",
|
||||
"~:font-weight": "400",
|
||||
"~:typography-ref-file": null,
|
||||
"~:font-variant-id": "regular",
|
||||
"~:text-decoration": "none",
|
||||
"~:letter-spacing": "0",
|
||||
"~:fills": [
|
||||
{
|
||||
"~:fill-color": "#000000",
|
||||
"~:fill-opacity": 1
|
||||
}
|
||||
],
|
||||
"~:font-family": "sourcesanspro",
|
||||
"~:text": "Lorem ipsum"
|
||||
}
|
||||
],
|
||||
"~:typography-ref-id": null,
|
||||
"~:text-transform": "none",
|
||||
"~:text-align": "left",
|
||||
"~:font-id": "sourcesanspro",
|
||||
"~:key": "20hf3kmyoub",
|
||||
"~:font-size": "14",
|
||||
"~:font-weight": "400",
|
||||
"~:typography-ref-file": null,
|
||||
"~:text-direction": "ltr",
|
||||
"~:type": "paragraph",
|
||||
"~:font-variant-id": "regular",
|
||||
"~:text-decoration": "none",
|
||||
"~:letter-spacing": "0",
|
||||
"~:fills": [
|
||||
{
|
||||
"~:fill-color": "#000000",
|
||||
"~:fill-opacity": 1
|
||||
}
|
||||
],
|
||||
"~:font-family": "sourcesanspro"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"~:vertical-align": "top"
|
||||
},
|
||||
"~:hide-in-viewer": false,
|
||||
"~:name": "Lorem ipsum",
|
||||
"~:width": 77,
|
||||
"~:type": "~:text",
|
||||
"~:points": [
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 404,
|
||||
"~:y": 438
|
||||
}
|
||||
},
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 481,
|
||||
"~:y": 438
|
||||
}
|
||||
},
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 481,
|
||||
"~:y": 455
|
||||
}
|
||||
},
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 404,
|
||||
"~:y": 455
|
||||
}
|
||||
}
|
||||
],
|
||||
"~:transform-inverse": {
|
||||
"~#matrix": {
|
||||
"~:a": 1.0,
|
||||
"~:b": 0.0,
|
||||
"~:c": 0.0,
|
||||
"~:d": 1.0,
|
||||
"~:e": 0.0,
|
||||
"~:f": 0.0
|
||||
}
|
||||
},
|
||||
"~:id": "~ucc6f0580-449c-8019-8006-9345db077fa0",
|
||||
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
|
||||
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
|
||||
"~:x": 404,
|
||||
"~:selrect": {
|
||||
"~#rect": {
|
||||
"~:x": 404,
|
||||
"~:y": 438,
|
||||
"~:width": 77,
|
||||
"~:height": 17,
|
||||
"~:x1": 404,
|
||||
"~:y1": 438,
|
||||
"~:x2": 481,
|
||||
"~:y2": 455
|
||||
}
|
||||
},
|
||||
"~:flip-x": null,
|
||||
"~:height": 17,
|
||||
"~:flip-y": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"~:id": "~u238a17e0-75ff-8075-8006-934586ea2231",
|
||||
"~:name": "Page 1"
|
||||
}
|
||||
},
|
||||
"~:id": "~u238a17e0-75ff-8075-8006-934586ea2230",
|
||||
"~:options": {
|
||||
"~:components-v2": true,
|
||||
"~:base-font-size": "16px"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1 @@
|
||||
{
|
||||
"~:revn": 2,
|
||||
"~:lagged": []
|
||||
|
||||
}
|
||||
w
|
||||
|
||||
4
frontend/playwright/data/text-editor/update-file.json
Normal file
4
frontend/playwright/data/text-editor/update-file.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"~:revn": 2,
|
||||
"~:lagged": []
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{
|
||||
"~:id": "~u088df3d4-d383-80f6-8004-527e50ea4f1f",
|
||||
"~:revn": 21,
|
||||
"~:file-id": "~uc7ce0794-0992-8105-8004-38f280443849",
|
||||
"~:session-id": "~u1dc6d4fa-7bd3-803a-8004-527dd9df2c62",
|
||||
"~:changes": []
|
||||
}
|
||||
]
|
||||
36
frontend/playwright/helpers/Clipboard.js
Normal file
36
frontend/playwright/helpers/Clipboard.js
Normal file
@@ -0,0 +1,36 @@
|
||||
export class Clipboard {
|
||||
static Permission = {
|
||||
ONLY_READ: ['clipboard-read'],
|
||||
ONLY_WRITE: ['clipboard-write'],
|
||||
ALL: ['clipboard-read', 'clipboard-write']
|
||||
}
|
||||
|
||||
static enable(context, permissions) {
|
||||
return context.grantPermissions(permissions)
|
||||
}
|
||||
|
||||
static writeText(page, text) {
|
||||
return page.evaluate((text) => navigator.clipboard.writeText(text), text);
|
||||
}
|
||||
|
||||
static readText(page) {
|
||||
return page.evaluate(() => navigator.clipboard.readText());
|
||||
}
|
||||
|
||||
constructor(page, context) {
|
||||
this.page = page
|
||||
this.context = context
|
||||
}
|
||||
|
||||
enable(permissions) {
|
||||
return Clipboard.enable(this.context, permissions);
|
||||
}
|
||||
|
||||
writeText(text) {
|
||||
return Clipboard.writeText(this.page, text);
|
||||
}
|
||||
|
||||
readText() {
|
||||
return Clipboard.readText(this.page);
|
||||
}
|
||||
}
|
||||
30
frontend/playwright/helpers/Transit.js
Normal file
30
frontend/playwright/helpers/Transit.js
Normal file
@@ -0,0 +1,30 @@
|
||||
export class Transit {
|
||||
static parse(value) {
|
||||
if (typeof value !== 'string')
|
||||
return value
|
||||
|
||||
if (value.startsWith('~'))
|
||||
return value.slice(2)
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
static get(object, ...path) {
|
||||
let aux = object;
|
||||
for (const name of path) {
|
||||
if (typeof name !== 'string') {
|
||||
if (!(name in aux)) {
|
||||
return undefined;
|
||||
}
|
||||
aux = aux[name];
|
||||
} else {
|
||||
const transitName = `~:${name}`;
|
||||
if (!(transitName in aux)) {
|
||||
return undefined;
|
||||
}
|
||||
aux = aux[transitName];
|
||||
}
|
||||
}
|
||||
return this.parse(aux);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,27 @@
|
||||
export class BasePage {
|
||||
/**
|
||||
* Mocks multiple RPC calls in a single call.
|
||||
*
|
||||
* @param {Page} page
|
||||
* @param {object<string, string>} paths
|
||||
* @param {*} options
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static async mockRPCs(page, paths, options) {
|
||||
for (const [path, jsonFilename] of Object.entries(paths)) {
|
||||
await this.mockRPC(page, path, jsonFilename, options)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mocks an RPC call using a file.
|
||||
*
|
||||
* @param {Page} page
|
||||
* @param {string} path
|
||||
* @param {string} jsonFilename
|
||||
* @param {*} options
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static async mockRPC(page, path, jsonFilename, options) {
|
||||
if (!page) {
|
||||
throw new TypeError("Invalid page argument. Must be a Playwright page.");
|
||||
@@ -93,6 +116,10 @@ export class BasePage {
|
||||
return this.#page;
|
||||
}
|
||||
|
||||
async mockRPCs(paths, options) {
|
||||
return BasePage.mockRPCs(this.page, paths, options);
|
||||
}
|
||||
|
||||
async mockRPC(path, jsonFilename, options) {
|
||||
return BasePage.mockRPC(this.page, path, jsonFilename, options);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,146 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { BaseWebSocketPage } from "./BaseWebSocketPage";
|
||||
import { Transit } from '../../helpers/Transit';
|
||||
|
||||
export class WorkspacePage extends BaseWebSocketPage {
|
||||
static TextEditor = class TextEditor {
|
||||
constructor(workspacePage) {
|
||||
this.workspacePage = workspacePage;
|
||||
|
||||
// locators.
|
||||
this.fontSize = this.workspacePage.rightSidebar.getByRole("textbox", {
|
||||
name: "Font Size",
|
||||
});
|
||||
this.lineHeight = this.workspacePage.rightSidebar.getByRole("textbox", {
|
||||
name: "Line Height",
|
||||
});
|
||||
this.letterSpacing = this.workspacePage.rightSidebar.getByRole(
|
||||
"textbox",
|
||||
{
|
||||
name: "Letter Spacing",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
get page() {
|
||||
return this.workspacePage.page;
|
||||
}
|
||||
|
||||
async waitForStyle(locator, styleName) {
|
||||
return locator.evaluate(
|
||||
(element, styleName) => element.style.getPropertyValue(styleName),
|
||||
styleName,
|
||||
);
|
||||
}
|
||||
|
||||
async waitForEditor() {
|
||||
return this.page.waitForSelector('[data-itype="editor"]');
|
||||
}
|
||||
|
||||
async waitForRoot() {
|
||||
return this.page.waitForSelector('[data-itype="root"]');
|
||||
}
|
||||
|
||||
async waitForParagraph(nth) {
|
||||
if (!nth) {
|
||||
return this.page.waitForSelector('[data-itype="paragraph"]');
|
||||
}
|
||||
return this.page.waitForSelector(
|
||||
`[data-itype="paragraph"]:nth-child(${nth})`,
|
||||
);
|
||||
}
|
||||
|
||||
async waitForParagraphStyle(nth, styleName) {
|
||||
const paragraph = await this.waitForParagraph(nth);
|
||||
return this.waitForStyle(paragraph, styleName);
|
||||
}
|
||||
|
||||
async waitForTextSpan(nth = 0) {
|
||||
if (!nth) {
|
||||
return this.page.waitForSelector('[data-itype="inline"]');
|
||||
}
|
||||
return this.page.waitForSelector(
|
||||
`[data-itype="inline"]:nth-child(${nth})`,
|
||||
);
|
||||
}
|
||||
|
||||
async waitForTextSpanContent(nth = 0) {
|
||||
const textSpan = await this.waitForTextSpan(nth);
|
||||
const textContent = await textSpan.textContent();
|
||||
return textContent;
|
||||
}
|
||||
|
||||
async waitForTextSpanStyle(nth, styleName) {
|
||||
const textSpan = await this.waitForTextSpan(nth);
|
||||
return this.waitForStyle(textSpan, styleName);
|
||||
}
|
||||
|
||||
async startEditing() {
|
||||
await this.page.keyboard.press("Enter");
|
||||
return this.waitForEditor();
|
||||
}
|
||||
|
||||
stopEditing() {
|
||||
return this.page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
async moveToLeft(amount = 0) {
|
||||
for (let i = 0; i < amount; i++) {
|
||||
await this.page.keyboard.press("ArrowLeft");
|
||||
}
|
||||
}
|
||||
|
||||
async moveToRight(amount = 0) {
|
||||
for (let i = 0; i < amount; i++) {
|
||||
await this.page.keyboard.press("ArrowRight");
|
||||
}
|
||||
}
|
||||
|
||||
async moveFromStart(offset = 0) {
|
||||
await this.page.keyboard.press("ArrowLeft");
|
||||
await this.moveToRight(offset);
|
||||
}
|
||||
|
||||
async moveFromEnd(offset = 0) {
|
||||
await this.page.keyboard.press("ArrowRight");
|
||||
await this.moveToLeft(offset);
|
||||
}
|
||||
|
||||
async selectFromStart(length, offset = 0) {
|
||||
await this.moveFromStart(offset);
|
||||
await this.page.keyboard.down("Shift");
|
||||
await this.moveToRight(length);
|
||||
await this.page.keyboard.up("Shift");
|
||||
}
|
||||
|
||||
async selectFromEnd(length, offset = 0) {
|
||||
await this.moveFromEnd(offset);
|
||||
await this.page.keyboard.down("Shift");
|
||||
await this.moveToLeft(length);
|
||||
await this.page.keyboard.up("Shift");
|
||||
}
|
||||
|
||||
async changeNumericInput(locator, newValue) {
|
||||
await expect(locator).toBeVisible();
|
||||
await locator.focus();
|
||||
await locator.fill(`${newValue}`);
|
||||
await locator.blur();
|
||||
}
|
||||
|
||||
changeFontSize(newValue) {
|
||||
return this.changeNumericInput(this.fontSize, newValue);
|
||||
}
|
||||
|
||||
changeLineHeight(newValue) {
|
||||
return this.changeNumericInput(this.lineHeight, newValue);
|
||||
}
|
||||
|
||||
changeLetterSpacing(newValue) {
|
||||
return this.changeNumericInput(this.letterSpacing, newValue);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This should be called on `test.beforeEach`.
|
||||
*
|
||||
@@ -11,50 +150,21 @@ export class WorkspacePage extends BaseWebSocketPage {
|
||||
static async init(page) {
|
||||
await BaseWebSocketPage.initWebSockets(page);
|
||||
|
||||
await BaseWebSocketPage.mockRPC(
|
||||
page,
|
||||
"get-profile",
|
||||
"logged-in-user/get-profile-logged-in.json",
|
||||
);
|
||||
await BaseWebSocketPage.mockRPC(
|
||||
page,
|
||||
"get-team-users?file-id=*",
|
||||
"logged-in-user/get-team-users-single-user.json",
|
||||
);
|
||||
await BaseWebSocketPage.mockRPC(
|
||||
page,
|
||||
"get-comment-threads?file-id=*",
|
||||
"workspace/get-comment-threads-empty.json",
|
||||
);
|
||||
await BaseWebSocketPage.mockRPC(
|
||||
page,
|
||||
"get-project?id=*",
|
||||
"workspace/get-project-default.json",
|
||||
);
|
||||
await BaseWebSocketPage.mockRPC(
|
||||
page,
|
||||
"get-team?id=*",
|
||||
"workspace/get-team-default.json",
|
||||
);
|
||||
await BaseWebSocketPage.mockRPC(page, "get-teams", "get-teams.json");
|
||||
|
||||
await BaseWebSocketPage.mockRPC(
|
||||
page,
|
||||
"get-team-members?team-id=*",
|
||||
"logged-in-user/get-team-members-your-penpot.json",
|
||||
);
|
||||
|
||||
await BaseWebSocketPage.mockRPC(
|
||||
page,
|
||||
"get-profiles-for-file-comments?file-id=*",
|
||||
"workspace/get-profile-for-file-comments.json",
|
||||
);
|
||||
|
||||
await BaseWebSocketPage.mockRPC(
|
||||
page,
|
||||
"update-profile-props",
|
||||
"workspace/update-profile-empty.json",
|
||||
);
|
||||
await BaseWebSocketPage.mockRPCs(page, {
|
||||
"get-profile": "logged-in-user/get-profile-logged-in.json",
|
||||
"get-team-users?file-id=*":
|
||||
"logged-in-user/get-team-users-single-user.json",
|
||||
"get-comment-threads?file-id=*":
|
||||
"workspace/get-comment-threads-empty.json",
|
||||
"get-project?id=*": "workspace/get-project-default.json",
|
||||
"get-team?id=*": "workspace/get-team-default.json",
|
||||
"get-teams": "get-teams.json",
|
||||
"get-team-members?team-id=*":
|
||||
"logged-in-user/get-team-members-your-penpot.json",
|
||||
"get-profiles-for-file-comments?file-id=*":
|
||||
"workspace/get-profile-for-file-comments.json",
|
||||
"update-profile-props": "workspace/update-profile-empty.json",
|
||||
});
|
||||
}
|
||||
|
||||
static anyTeamId = "c7ce0794-0992-8105-8004-38e630f7920a";
|
||||
@@ -62,9 +172,20 @@ export class WorkspacePage extends BaseWebSocketPage {
|
||||
static anyFileId = "c7ce0794-0992-8105-8004-38f280443849";
|
||||
static anyPageId = "c7ce0794-0992-8105-8004-38f28044384a";
|
||||
|
||||
/**
|
||||
* WebSocket mock
|
||||
*
|
||||
* @type {MockWebSocketHelper}
|
||||
*/
|
||||
#ws = null;
|
||||
|
||||
constructor(page) {
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param {Page} page
|
||||
* @param {} [options]
|
||||
*/
|
||||
constructor(page, options) {
|
||||
super(page);
|
||||
this.pageName = page.getByTestId("page-name");
|
||||
|
||||
@@ -112,11 +233,14 @@ export class WorkspacePage extends BaseWebSocketPage {
|
||||
"tokens-context-menu-for-set",
|
||||
);
|
||||
this.contextMenuForShape = page.getByTestId("context-menu");
|
||||
if (options?.textEditor) {
|
||||
this.textEditor = new WorkspacePage.TextEditor(this);
|
||||
}
|
||||
}
|
||||
|
||||
async goToWorkspace({
|
||||
fileId = WorkspacePage.anyFileId,
|
||||
pageId = WorkspacePage.anyPageId,
|
||||
fileId = this.fileId ?? WorkspacePage.anyFileId,
|
||||
pageId = this.pageId ?? WorkspacePage.anyPageId,
|
||||
} = {}) {
|
||||
await this.page.goto(
|
||||
`/#/workspace?team-id=${WorkspacePage.anyTeamId}&file-id=${fileId}&page-id=${pageId}`,
|
||||
@@ -141,48 +265,59 @@ export class WorkspacePage extends BaseWebSocketPage {
|
||||
}
|
||||
|
||||
async setupEmptyFile() {
|
||||
await this.mockRPC(
|
||||
"get-profile",
|
||||
"logged-in-user/get-profile-logged-in.json",
|
||||
);
|
||||
await this.mockRPC(
|
||||
"get-team-users?file-id=*",
|
||||
"logged-in-user/get-team-users-single-user.json",
|
||||
);
|
||||
await this.mockRPC(
|
||||
"get-comment-threads?file-id=*",
|
||||
"workspace/get-comment-threads-empty.json",
|
||||
);
|
||||
await this.mockRPC(
|
||||
"get-project?id=*",
|
||||
"workspace/get-project-default.json",
|
||||
);
|
||||
await this.mockRPC("get-team?id=*", "workspace/get-team-default.json");
|
||||
await this.mockRPC(
|
||||
"get-profiles-for-file-comments?file-id=*",
|
||||
"workspace/get-profile-for-file-comments.json",
|
||||
);
|
||||
await this.mockRPC(/get\-file\?/, "workspace/get-file-blank.json");
|
||||
await this.mockRPC(
|
||||
"get-file-object-thumbnails?file-id=*",
|
||||
"workspace/get-file-object-thumbnails-blank.json",
|
||||
);
|
||||
await this.mockRPC(
|
||||
"get-font-variants?team-id=*",
|
||||
"workspace/get-font-variants-empty.json",
|
||||
);
|
||||
await this.mockRPC(
|
||||
"get-file-fragment?file-id=*",
|
||||
"workspace/get-file-fragment-blank.json",
|
||||
);
|
||||
await this.mockRPC(
|
||||
"get-file-libraries?file-id=*",
|
||||
"workspace/get-file-libraries-empty.json",
|
||||
);
|
||||
await this.mockRPCs({
|
||||
"get-profile": "logged-in-user/get-profile-logged-in.json",
|
||||
"get-team-users?file-id=*":
|
||||
"logged-in-user/get-team-users-single-user.json ",
|
||||
"get-comment-threads?file-id=*":
|
||||
"workspace/get-comment-threads-empty.json",
|
||||
"get-project?id=*": "workspace/get-project-default.json",
|
||||
"get-team?id=*": "workspace/get-team-default.json",
|
||||
"get-profiles-for-file-comments?file-id=*":
|
||||
"workspace/get-profile-for-file-comments.json",
|
||||
"get-file-object-thumbnails?file-id=*":
|
||||
"workspace/get-file-object-thumbnails-blank.json",
|
||||
"get-font-variants?team-id=*": "workspace/get-font-variants-empty.json",
|
||||
"get-file-fragment?file-id=*": "workspace/get-file-fragment-blank.json",
|
||||
"get-file-libraries?file-id=*": "workspace/get-file-libraries-empty.json",
|
||||
});
|
||||
|
||||
if (this.textEditor) {
|
||||
await this.mockRPC("update-file?id=*", "text-editor/update-file.json");
|
||||
}
|
||||
|
||||
// by default we mock the blank file.
|
||||
await this.mockGetFile("workspace/get-file-blank.json");
|
||||
}
|
||||
|
||||
async mockGetFile(jsonFile) {
|
||||
await this.mockRPC(/get\-file\?/, jsonFile);
|
||||
async mockGetFile(jsonFilename, options) {
|
||||
const page = this.page;
|
||||
const jsonPath = `playwright/data/${jsonFilename}`;
|
||||
const body = await readFile(jsonPath, "utf-8");
|
||||
const payload = JSON.parse(body);
|
||||
|
||||
const fileId = Transit.get(payload, "id");
|
||||
const pageId = Transit.get(payload, "data", "pages", 0);
|
||||
const teamId = Transit.get(payload, "team-id");
|
||||
|
||||
this.fileId = fileId ?? this.anyFileId;
|
||||
this.pageId = pageId ?? this.anyPageId;
|
||||
this.teamId = teamId ?? this.anyTeamId;
|
||||
|
||||
const path = /get\-file\?/;
|
||||
const url = typeof path === "string" ? `**/api/main/methods/${path}` : path;
|
||||
const interceptConfig = {
|
||||
status: 200,
|
||||
contentType: "application/transit+json",
|
||||
...options,
|
||||
};
|
||||
return page.route(url, (route) =>
|
||||
route.fulfill({
|
||||
...interceptConfig,
|
||||
body,
|
||||
}),
|
||||
);
|
||||
// await this.mockRPC(/get\-file\?/, jsonFile);
|
||||
}
|
||||
|
||||
async mockGetAsset(regex, asset) {
|
||||
@@ -190,22 +325,15 @@ export class WorkspacePage extends BaseWebSocketPage {
|
||||
}
|
||||
|
||||
async setupFileWithComments() {
|
||||
await this.mockRPC(
|
||||
"get-comment-threads?file-id=*",
|
||||
"workspace/get-comment-threads-unread.json",
|
||||
);
|
||||
await this.mockRPC(
|
||||
"get-file-fragment?file-id=*&fragment-id=*",
|
||||
"viewer/get-file-fragment-single-board.json",
|
||||
);
|
||||
await this.mockRPC(
|
||||
"get-comments?thread-id=*",
|
||||
"workspace/get-thread-comments.json",
|
||||
);
|
||||
await this.mockRPC(
|
||||
"update-comment-thread-status",
|
||||
"workspace/update-comment-thread-status.json",
|
||||
);
|
||||
await this.mockRPCs({
|
||||
"get-comment-threads?file-id=*":
|
||||
"workspace/get-comment-threads-unread.json",
|
||||
"get-file-fragment?file-id=*&fragment-id=*":
|
||||
"viewer/get-file-fragment-single-board.json",
|
||||
"get-comments?thread-id=*": "workspace/get-thread-comments.json",
|
||||
"update-comment-thread-status":
|
||||
"workspace/update-comment-thread-status.json",
|
||||
});
|
||||
}
|
||||
|
||||
async clickWithDragViewportAt(x, y, width, height) {
|
||||
@@ -223,6 +351,67 @@ export class WorkspacePage extends BaseWebSocketPage {
|
||||
await this.page.mouse.up();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks and moves from the coordinates x1,y1 to x2,y2
|
||||
*
|
||||
* @param {number} x1
|
||||
* @param {number} y1
|
||||
* @param {number} x2
|
||||
* @param {number} y2
|
||||
*/
|
||||
async clickAndMove(x1, y1, x2, y2) {
|
||||
await this.page.waitForTimeout(100);
|
||||
await this.viewport.hover({ position: { x: x1, y: y1 } });
|
||||
await this.page.mouse.down();
|
||||
await this.viewport.hover({ position: { x: x2, y: y2 } });
|
||||
await this.page.mouse.up();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Text Shape in the specified coordinates
|
||||
* with an initial text.
|
||||
*
|
||||
* @param {number} x1
|
||||
* @param {number} y1
|
||||
* @param {number} x2
|
||||
* @param {number} y2
|
||||
* @param {string} initialText
|
||||
* @param {*} [options]
|
||||
*/
|
||||
async createTextShape(x1, y1, x2, y2, initialText, options) {
|
||||
const timeToWait = options?.timeToWait ?? 100;
|
||||
await this.page.keyboard.press("T");
|
||||
await this.page.waitForTimeout(timeToWait);
|
||||
await this.clickAndMove(x1, y1, x2, y2);
|
||||
await this.page.waitForTimeout(timeToWait);
|
||||
if (initialText) {
|
||||
await this.page.keyboard.type(initialText);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the selected element into the clipboard.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async copy() {
|
||||
return this.page.keyboard.press("Control+C");
|
||||
}
|
||||
|
||||
/**
|
||||
* Pastes something from the clipboard.
|
||||
*
|
||||
* @param {"keyboard"|"context-menu"} [kind="keyboard"]
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async paste(kind = "keyboard") {
|
||||
if (kind === "context-menu") {
|
||||
await this.viewport.click({ button: "right" });
|
||||
return this.page.getByText("PasteCtrlV").click();
|
||||
}
|
||||
return this.page.keyboard.press("Control+V");
|
||||
}
|
||||
|
||||
async panOnViewportAt(x, y, width, height) {
|
||||
await this.page.waitForTimeout(100);
|
||||
await this.viewport.hover({ position: { x, y } });
|
||||
@@ -250,10 +439,15 @@ export class WorkspacePage extends BaseWebSocketPage {
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
async doubleClickLeafLayer(name, clickOptions = {}) {
|
||||
await this.clickLeafLayer(name, clickOptions);
|
||||
await this.clickLeafLayer(name, clickOptions);
|
||||
}
|
||||
|
||||
async clickToggableLayer(name, clickOptions = {}) {
|
||||
const layer = this.layers
|
||||
.getByTestId("layer-row")
|
||||
.filter({ hasText: name });
|
||||
.getByTestId("layer-row")
|
||||
.filter({ hasText: name });
|
||||
const button = layer.getByRole("button");
|
||||
|
||||
await button.waitFor();
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 23 KiB |
@@ -360,7 +360,7 @@ test("Renders a file with texts with paragraphs and breaking lines", async ({
|
||||
id: "a5f238bd-dd8a-8164-8007-1bc3481eaf05",
|
||||
pageId: "a5f238bd-dd8a-8164-8007-1bc3481eaf06",
|
||||
});
|
||||
await workspace.waitForFirstRender();
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
await expect(workspace.canvas).toHaveScreenshot();
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ const setupFile = async (workspacePage) => {
|
||||
fileId: "7b2da435-6186-815a-8007-0daa95d2f26d",
|
||||
pageId: "ce79274b-11ab-8088-8007-0487ad43f789",
|
||||
});
|
||||
await workspacePage.mockRPC(
|
||||
"update-file?id=*",
|
||||
"workspace/update-file-empty.json",
|
||||
);
|
||||
};
|
||||
|
||||
const shapeToLayerName = {
|
||||
|
||||
@@ -1,12 +1,317 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { Clipboard } from '../../helpers/Clipboard';
|
||||
import { WorkspacePage } from "../pages/WorkspacePage";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const timeToWait = 100;
|
||||
|
||||
test.beforeEach(async ({ page, context }) => {
|
||||
await Clipboard.enable(context, Clipboard.Permission.ONLY_WRITE);
|
||||
|
||||
await WorkspacePage.init(page);
|
||||
await WorkspacePage.mockConfigFlags(page, ["enable-feature-text-editor-v2"]);
|
||||
});
|
||||
|
||||
test.skip("BUG 11552 - Apply styles to the current caret", async ({ page }) => {
|
||||
test.afterEach(async ({ context}) => {
|
||||
context.clearPermissions();
|
||||
})
|
||||
|
||||
test("Create a new text shape", async ({ page }) => {
|
||||
const initialText = "Lorem ipsum";
|
||||
const workspace = new WorkspacePage(page, {
|
||||
textEditor: true
|
||||
});
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.goToWorkspace();
|
||||
await workspace.createTextShape(190, 150, 300, 200, initialText);
|
||||
|
||||
const textContent = await workspace.textEditor.waitForTextSpanContent();
|
||||
expect(textContent).toBe(initialText);
|
||||
|
||||
await workspace.textEditor.stopEditing();
|
||||
});
|
||||
|
||||
test("Create a new text shape from pasting text", async ({ page, context }) => {
|
||||
const textToPaste = "Lorem ipsum";
|
||||
const workspace = new WorkspacePage(page, {
|
||||
textEditor: true,
|
||||
});
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockRPC(
|
||||
"update-file?id=*",
|
||||
"text-editor/update-file.json",
|
||||
);
|
||||
await workspace.goToWorkspace();
|
||||
|
||||
await Clipboard.writeText(page, textToPaste);
|
||||
|
||||
await workspace.clickAt(190, 150);
|
||||
await workspace.paste("keyboard");
|
||||
|
||||
await page.waitForTimeout(timeToWait);
|
||||
|
||||
const textContent = await workspace.textEditor.waitForTextSpanContent();
|
||||
expect(textContent).toBe(textToPaste);
|
||||
|
||||
await workspace.textEditor.stopEditing();
|
||||
});
|
||||
|
||||
test("Create a new text shape from pasting text using context menu", async ({ page, context }) => {
|
||||
const textToPaste = "Lorem ipsum";
|
||||
const workspace = new WorkspacePage(page, {
|
||||
textEditor: true
|
||||
});
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.goToWorkspace();
|
||||
|
||||
await Clipboard.writeText(page, textToPaste);
|
||||
|
||||
await workspace.clickAt(190, 150);
|
||||
await workspace.paste("context-menu");
|
||||
|
||||
const textContent = await workspace.textEditor.waitForTextSpanContent();
|
||||
expect(textContent).toBe(textToPaste);
|
||||
|
||||
await workspace.textEditor.stopEditing();
|
||||
})
|
||||
|
||||
test("Update an already created text shape by appending text", async ({ page }) => {
|
||||
const workspace = new WorkspacePage(page, {
|
||||
textEditor: true
|
||||
});
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
|
||||
await workspace.goToWorkspace();
|
||||
await workspace.clickLeafLayer("Lorem ipsum");
|
||||
await workspace.textEditor.startEditing();
|
||||
await workspace.textEditor.moveFromEnd(0);
|
||||
await page.keyboard.type(" dolor sit amet");
|
||||
const textContent = await workspace.textEditor.waitForTextSpanContent();
|
||||
expect(textContent).toBe("Lorem ipsum dolor sit amet");
|
||||
await workspace.textEditor.stopEditing();
|
||||
});
|
||||
|
||||
test("Update an already created text shape by prepending text", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = new WorkspacePage(page, {
|
||||
textEditor: true
|
||||
});
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
|
||||
await workspace.goToWorkspace();
|
||||
await workspace.clickLeafLayer("Lorem ipsum");
|
||||
await workspace.textEditor.startEditing();
|
||||
await workspace.textEditor.moveFromStart(0);
|
||||
await page.keyboard.type("Dolor sit amet ");
|
||||
const textContent = await workspace.textEditor.waitForTextSpanContent();
|
||||
expect(textContent).toBe("Dolor sit amet Lorem ipsum");
|
||||
await workspace.textEditor.stopEditing();
|
||||
});
|
||||
|
||||
test("Update an already created text shape by inserting text in between", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = new WorkspacePage(page, {
|
||||
textEditor: true
|
||||
});
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
|
||||
await workspace.goToWorkspace();
|
||||
await workspace.clickLeafLayer("Lorem ipsum");
|
||||
await workspace.textEditor.startEditing();
|
||||
await workspace.textEditor.moveFromStart(5);
|
||||
await page.keyboard.type(" dolor sit amet");
|
||||
const textContent = await workspace.textEditor.waitForTextSpanContent();
|
||||
expect(textContent).toBe("Lorem dolor sit amet ipsum");
|
||||
await workspace.textEditor.stopEditing();
|
||||
});
|
||||
|
||||
test("Update a new text shape appending text by pasting text", async ({ page, context }) => {
|
||||
const textToPaste = " dolor sit amet";
|
||||
const workspace = new WorkspacePage(page, {
|
||||
textEditor: true
|
||||
});
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
|
||||
await workspace.goToWorkspace();
|
||||
|
||||
await Clipboard.writeText(page, textToPaste);
|
||||
|
||||
await workspace.clickLeafLayer("Lorem ipsum");
|
||||
await workspace.textEditor.startEditing();
|
||||
await workspace.textEditor.moveFromEnd();
|
||||
await workspace.paste("keyboard");
|
||||
const textContent = await workspace.textEditor.waitForTextSpanContent();
|
||||
expect(textContent).toBe("Lorem ipsum dolor sit amet");
|
||||
await workspace.textEditor.stopEditing();
|
||||
});
|
||||
|
||||
test("Update a new text shape prepending text by pasting text", async ({
|
||||
page, context
|
||||
}) => {
|
||||
const textToPaste = "Dolor sit amet ";
|
||||
const workspace = new WorkspacePage(page, {
|
||||
textEditor: true
|
||||
});
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
|
||||
await workspace.goToWorkspace();
|
||||
|
||||
await Clipboard.writeText(page, textToPaste);
|
||||
|
||||
await workspace.clickLeafLayer("Lorem ipsum");
|
||||
await workspace.textEditor.startEditing();
|
||||
await workspace.textEditor.moveFromStart();
|
||||
await workspace.paste("keyboard");
|
||||
const textContent = await workspace.textEditor.waitForTextSpanContent();
|
||||
expect(textContent).toBe("Dolor sit amet Lorem ipsum");
|
||||
await workspace.textEditor.stopEditing();
|
||||
});
|
||||
|
||||
test("Update a new text shape replacing (starting) text with pasted text", async ({
|
||||
page,
|
||||
}) => {
|
||||
const textToPaste = "Dolor sit amet";
|
||||
const workspace = new WorkspacePage(page, {
|
||||
textEditor: true
|
||||
});
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
|
||||
await workspace.goToWorkspace();
|
||||
await workspace.clickLeafLayer("Lorem ipsum");
|
||||
await workspace.textEditor.startEditing();
|
||||
await workspace.textEditor.selectFromStart(5);
|
||||
|
||||
await Clipboard.writeText(page, textToPaste);
|
||||
|
||||
await workspace.paste("keyboard");
|
||||
|
||||
const textContent = await workspace.textEditor.waitForTextSpanContent();
|
||||
expect(textContent).toBe("Dolor sit amet ipsum");
|
||||
|
||||
await workspace.textEditor.stopEditing();
|
||||
});
|
||||
|
||||
test("Update a new text shape replacing (ending) text with pasted text", async ({
|
||||
page,
|
||||
}) => {
|
||||
const textToPaste = "dolor sit amet";
|
||||
const workspace = new WorkspacePage(page, {
|
||||
textEditor: true
|
||||
});
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
|
||||
await workspace.goToWorkspace();
|
||||
await workspace.clickLeafLayer("Lorem ipsum");
|
||||
await workspace.textEditor.startEditing();
|
||||
await workspace.textEditor.selectFromEnd(5);
|
||||
|
||||
await Clipboard.writeText(page, textToPaste);
|
||||
|
||||
await workspace.paste("keyboard");
|
||||
|
||||
const textContent = await workspace.textEditor.waitForTextSpanContent();
|
||||
expect(textContent).toBe("Lorem dolor sit amet");
|
||||
|
||||
await workspace.textEditor.stopEditing();
|
||||
});
|
||||
|
||||
test("Update a new text shape replacing (in between) text with pasted text", async ({
|
||||
page,
|
||||
}) => {
|
||||
const textToPaste = "dolor sit amet";
|
||||
const workspace = new WorkspacePage(page, {
|
||||
textEditor: true
|
||||
});
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
|
||||
await workspace.goToWorkspace();
|
||||
await workspace.clickLeafLayer("Lorem ipsum");
|
||||
await workspace.textEditor.startEditing();
|
||||
await workspace.textEditor.selectFromStart(5, 3);
|
||||
|
||||
await Clipboard.writeText(page, textToPaste);
|
||||
|
||||
await workspace.paste("keyboard");
|
||||
|
||||
const textContent = await workspace.textEditor.waitForTextSpanContent();
|
||||
expect(textContent).toBe("Lordolor sit ametsum");
|
||||
|
||||
await workspace.textEditor.stopEditing();
|
||||
});
|
||||
|
||||
test("Update text font size selecting a part of it (starting)", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = new WorkspacePage(page, {
|
||||
textEditor: true
|
||||
});
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
|
||||
await workspace.mockRPC(
|
||||
"update-file?id=*",
|
||||
"text-editor/update-file.json",
|
||||
);
|
||||
await workspace.goToWorkspace();
|
||||
await workspace.clickLeafLayer("Lorem ipsum");
|
||||
await workspace.textEditor.startEditing();
|
||||
await workspace.textEditor.selectFromStart(5);
|
||||
await workspace.textEditor.changeFontSize(36);
|
||||
|
||||
const textContent1 = await workspace.textEditor.waitForTextSpanContent(1);
|
||||
expect(textContent1).toBe("Lorem");
|
||||
const textContent2 = await workspace.textEditor.waitForTextSpanContent(2);
|
||||
expect(textContent2).toBe(" ipsum");
|
||||
await workspace.textEditor.stopEditing();
|
||||
});
|
||||
|
||||
test.skip("Update text line height selecting a part of it (starting)", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = new WorkspacePage(page, {
|
||||
textEditor: true,
|
||||
});
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
|
||||
await workspace.mockRPC("update-file?id=*", "text-editor/update-file.json");
|
||||
await workspace.goToWorkspace();
|
||||
await workspace.clickLeafLayer("Lorem ipsum");
|
||||
await workspace.textEditor.startEditing();
|
||||
await workspace.textEditor.selectFromStart(5);
|
||||
await workspace.textEditor.changeLineHeight(1.4);
|
||||
|
||||
const lineHeight = await workspace.textEditor.waitForParagraphStyle(1, 'line-height');
|
||||
expect(lineHeight).toBe("1.4");
|
||||
|
||||
const textContent = await workspace.textEditor.waitForTextSpanContent();
|
||||
expect(textContent).toBe("Lorem ipsum");
|
||||
|
||||
await workspace.textEditor.stopEditing();
|
||||
});
|
||||
|
||||
test.skip("Update text letter spacing selecting a part of it (starting)", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = new WorkspacePage(page, {
|
||||
textEditor: true,
|
||||
});
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
|
||||
await workspace.mockRPC("update-file?id=*", "text-editor/update-file.json");
|
||||
await workspace.goToWorkspace();
|
||||
await workspace.clickLeafLayer("Lorem ipsum");
|
||||
await workspace.textEditor.startEditing();
|
||||
await workspace.textEditor.selectFromStart(5);
|
||||
await workspace.textEditor.changeLetterSpacing(10);
|
||||
|
||||
const textContent1 = await workspace.textEditor.waitForTextSpanContent(1);
|
||||
expect(textContent1).toBe("Lorem");
|
||||
const textContent2 = await workspace.textEditor.waitForTextSpanContent(2);
|
||||
expect(textContent2).toBe(" ipsum");
|
||||
await workspace.textEditor.stopEditing();
|
||||
});
|
||||
|
||||
test("BUG 11552 - Apply styles to the current caret", async ({ page }) => {
|
||||
const workspace = new WorkspacePage(page);
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("text-editor/get-file-11552.json");
|
||||
@@ -14,21 +319,16 @@ test.skip("BUG 11552 - Apply styles to the current caret", async ({ page }) => {
|
||||
"update-file?id=*",
|
||||
"text-editor/update-file-11552.json",
|
||||
);
|
||||
|
||||
await workspace.goToWorkspace({
|
||||
fileId: "238a17e0-75ff-8075-8006-934586ea2230",
|
||||
pageId: "238a17e0-75ff-8075-8006-934586ea2231",
|
||||
});
|
||||
await workspace.clickLeafLayer("Lorem ipsum");
|
||||
await workspace.clickLeafLayer("Lorem ipsum");
|
||||
await workspace.goToWorkspace();
|
||||
await workspace.doubleClickLeafLayer("Lorem ipsum");
|
||||
|
||||
const fontSizeInput = workspace.rightSidebar.getByRole("textbox", {
|
||||
name: "Font Size",
|
||||
});
|
||||
await expect(fontSizeInput).toBeVisible();
|
||||
|
||||
await workspace.page.keyboard.press("Enter");
|
||||
await workspace.page.keyboard.press("ArrowRight");
|
||||
await page.keyboard.press("Enter");
|
||||
await page.keyboard.press("ArrowRight");
|
||||
|
||||
await fontSizeInput.fill("36");
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -181,8 +181,8 @@ export async function watch(baseDir, predicate, callback) {
|
||||
});
|
||||
}
|
||||
|
||||
async function readManifestFile() {
|
||||
const manifestPath = "resources/public/js/manifest.json";
|
||||
async function readManifestFile(resource) {
|
||||
const manifestPath = "resources/public/" + resource;
|
||||
let content = await fs.readFile(manifestPath, { encoding: "utf8" });
|
||||
return JSON.parse(content);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ rm -rf target/dist;
|
||||
mkdir -p resources/public;
|
||||
mkdir -p target/dist;
|
||||
|
||||
yarn run build:app:main $EXTRA_PARAMS || exit 1
|
||||
yarn run build:app:main $EXTRA_PARAMS;
|
||||
|
||||
if [ "$INCLUDE_WASM" = "yes" ]; then
|
||||
yarn run build:wasm || exit 1;
|
||||
@@ -38,8 +38,6 @@ fi
|
||||
yarn run build:app:libs || exit 1;
|
||||
yarn run build:app:assets || exit 1;
|
||||
|
||||
sed -i "s/render-wasm.js/render-wasm.js?version=$CURRENT_VERSION/g" ./resources/public/js/worker/main.js;
|
||||
|
||||
rsync -avr resources/public/ target/dist/;
|
||||
|
||||
if [ "$INCLUDE_STORYBOOK" = "yes" ]; then
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
:source-map-detail-level :all}}}
|
||||
|
||||
:worker
|
||||
{:target :esm
|
||||
{:target :browser
|
||||
:output-dir "resources/public/js/worker/"
|
||||
:asset-path "/js/worker"
|
||||
:devtools {:browser-inject :main
|
||||
@@ -92,6 +92,7 @@
|
||||
{:main
|
||||
{:entries [app.worker]
|
||||
:web-worker true
|
||||
:prepend-js "importScripts('/js/worker/render.js');"
|
||||
:depends-on #{}}}
|
||||
|
||||
:js-options
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
public-uri))
|
||||
|
||||
(def worker-uri
|
||||
(obj/get global "penpotWorkerURI" "/js/worker.js"))
|
||||
(obj/get global "penpotWorkerURI" "/js/worker/main.js"))
|
||||
|
||||
(defn external-feature-flag
|
||||
[flag value]
|
||||
@@ -188,6 +188,11 @@
|
||||
(true? thumbnail?) (u/join (dm/str id "/thumbnail"))
|
||||
(false? thumbnail?) (u/join (dm/str id)))))))
|
||||
|
||||
(defn resolve-static-asset
|
||||
[path]
|
||||
(u/join public-uri path))
|
||||
(defn resolve-href
|
||||
[resource]
|
||||
(let [version (get version :full)
|
||||
href (-> public-uri
|
||||
(u/ensure-path-slash)
|
||||
(u/join resource)
|
||||
(get :path))]
|
||||
(str href "?version=" version)))
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
(map :page-id))
|
||||
|
||||
(defn- apply-changes-localy
|
||||
[{:keys [file-id redo-changes] :as commit} pending]
|
||||
[{:keys [file-id redo-changes ignore-wasm?] :as commit} pending]
|
||||
(ptk/reify ::apply-changes-localy
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
@@ -103,7 +103,7 @@
|
||||
pids (into #{} xf:map-page-id redo-changes)]
|
||||
(reduce #(ctst/update-object-indices %1 %2) fdata pids)))]
|
||||
|
||||
(if (features/active-feature? state "render-wasm/v1")
|
||||
(if (and (not ignore-wasm?) (features/active-feature? state "render-wasm/v1"))
|
||||
;; Update the wasm model
|
||||
(let [shape-changes (volatile! {})
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
(defn commit
|
||||
"Create a commit event instance"
|
||||
[{:keys [commit-id redo-changes undo-changes origin save-undo? features
|
||||
file-id file-revn file-vern undo-group tags stack-undo? source]}]
|
||||
file-id file-revn file-vern undo-group tags stack-undo? source ignore-wasm?]}]
|
||||
|
||||
(assert (cpc/check-changes redo-changes)
|
||||
"expect valid vector of changes for redo-changes")
|
||||
@@ -147,7 +147,8 @@
|
||||
:save-undo? save-undo?
|
||||
:undo-group undo-group
|
||||
:tags tags
|
||||
:stack-undo? stack-undo?}]
|
||||
:stack-undo? stack-undo?
|
||||
:ignore-wasm? ignore-wasm?}]
|
||||
|
||||
(ptk/reify ::commit
|
||||
cljs.core/IDeref
|
||||
|
||||
@@ -261,14 +261,19 @@
|
||||
|
||||
(defn- parse-sd-token-font-family-value
|
||||
[value]
|
||||
(let [missing-references (seq (some cto/find-token-value-references value))]
|
||||
(let [value (-> (js->clj value) (flatten))
|
||||
valid-font-family (or (string? value) (every? string? value))
|
||||
missing-references (seq (some cto/find-token-value-references value))]
|
||||
(cond
|
||||
(not valid-font-family)
|
||||
{:errors [(wte/error-with-value :error.style-dictionary/invalid-token-value-font-family value)]}
|
||||
|
||||
missing-references
|
||||
{:errors [(wte/error-with-value :error.style-dictionary/missing-reference missing-references)]
|
||||
:references missing-references}
|
||||
|
||||
:else
|
||||
{:value (-> (js->clj value) (flatten))})))
|
||||
{:value value})))
|
||||
|
||||
(defn parse-atomic-typography-value [token-type token-value]
|
||||
(case token-type
|
||||
|
||||
@@ -102,7 +102,8 @@
|
||||
{:origin it
|
||||
:redo-changes changes
|
||||
:undo-changes []
|
||||
:save-undo? false})))))))
|
||||
:save-undo? false
|
||||
:ignore-wasm? true})))))))
|
||||
|
||||
;; FIXME: would be nice to not execute this code twice per page in the
|
||||
;; same working session, maybe some local memoization can improve that
|
||||
@@ -119,4 +120,5 @@
|
||||
{:origin it
|
||||
:redo-changes changes
|
||||
:undo-changes []
|
||||
:save-undo? false})))))))
|
||||
:save-undo? false
|
||||
:ignore-wasm? true})))))))
|
||||
|
||||
@@ -649,7 +649,7 @@
|
||||
(propagate-structure-modifiers modif-tree (dsh/lookup-page-objects state))
|
||||
|
||||
ids
|
||||
(into [] xf:without-uuid-zero (keys transforms))
|
||||
(into (set (keys modif-tree)) xf:without-uuid-zero (keys transforms))
|
||||
|
||||
update-shape
|
||||
(fn [shape]
|
||||
|
||||
@@ -831,7 +831,8 @@
|
||||
(effect [_ state _]
|
||||
(when (features/active-feature? state "text-editor/v2")
|
||||
(let [instance (:workspace-editor state)
|
||||
attrs-to-override (some-> (editor.v2/getCurrentStyle instance) (styles/get-styles-from-style-declaration))
|
||||
attrs-to-override (some-> (editor.v2/getCurrentStyle instance)
|
||||
(styles/get-styles-from-style-declaration))
|
||||
overriden-attrs (merge attrs-to-override attrs)
|
||||
styles (styles/attrs->styles overriden-attrs)]
|
||||
(editor.v2/applyStylesToSelection instance styles))))))
|
||||
|
||||
@@ -88,6 +88,10 @@
|
||||
{:error/code :error.style-dictionary/invalid-token-value-font-weight
|
||||
:error/fn #(tr "workspace.tokens.invalid-font-weight-token-value" %)}
|
||||
|
||||
:error.style-dictionary/invalid-token-value-font-family
|
||||
{:error/code :error.style-dictionary/invalid-token-value-font-family
|
||||
:error/fn #(tr "workspace.tokens.invalid-font-family-token-value" %)}
|
||||
|
||||
:error.style-dictionary/invalid-token-value-typography
|
||||
{:error/code :error.style-dictionary/invalid-token-value-typography
|
||||
:error/fn #(tr "workspace.tokens.invalid-token-value-typography" %)}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
(log/set-level! :warn)
|
||||
|
||||
(def google-fonts
|
||||
(preload-gfonts "fonts/gfonts.2025.05.19.json"))
|
||||
(preload-gfonts "fonts/gfonts.2025.11.28.json"))
|
||||
|
||||
(def local-fonts
|
||||
[{:id "sourcesanspro"
|
||||
@@ -342,8 +342,8 @@
|
||||
(fn [result {:keys [font-id] :as node}]
|
||||
(let [current-font
|
||||
(if (some? font-id)
|
||||
(select-keys node [:font-id :font-variant-id])
|
||||
(select-keys txt/default-typography [:font-id :font-variant-id]))]
|
||||
(select-keys node [:font-id :font-variant-id :font-weight :font-style])
|
||||
(select-keys txt/default-typography [:font-id :font-variant-id :font-weight :font-style]))]
|
||||
(conj result current-font)))
|
||||
#{})))
|
||||
|
||||
|
||||
@@ -372,6 +372,9 @@
|
||||
(def workspace-modifiers
|
||||
(l/derived :workspace-modifiers st/state))
|
||||
|
||||
(def workspace-wasm-modifiers
|
||||
(l/derived :workspace-wasm-modifiers st/state))
|
||||
|
||||
(def ^:private workspace-modifiers-with-objects
|
||||
(l/derived
|
||||
(fn [state]
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
(def current-zoom (mf/create-context nil))
|
||||
|
||||
(def workspace-read-only? (mf/create-context nil))
|
||||
(def is-render? (mf/create-context false))
|
||||
(def is-component? (mf/create-context false))
|
||||
|
||||
(def sidebar
|
||||
|
||||
@@ -32,13 +32,19 @@
|
||||
min-width: var(--sp-l);
|
||||
}
|
||||
|
||||
// TODO: Review if we need other type of button, so we don't need important here
|
||||
.invisible-button {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 4px;
|
||||
opacity: var(--opacity-button);
|
||||
|
||||
background-color: var(--color-background-quaternary) !important;
|
||||
&:hover {
|
||||
background-color: var(--color-background-quaternary);
|
||||
--opacity-button: 1;
|
||||
}
|
||||
&:focus {
|
||||
background-color: var(--color-background-quaternary);
|
||||
--opacity-button: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
[:div {:class (stl/css :pill-dot)}])]]
|
||||
|
||||
(when-not ^boolean disabled
|
||||
[:> icon-button* {:variant "action"
|
||||
[:> icon-button* {:variant "ghost"
|
||||
:class (stl/css :invisible-button)
|
||||
:icon i/broken-link
|
||||
:ref token-detach-btn-ref
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
@use "ds/_sizes.scss" as *;
|
||||
@use "ds/typography.scss" as t;
|
||||
@use "ds/colors.scss" as *;
|
||||
@use "ds/mixins.scss" as *;
|
||||
|
||||
.token-field {
|
||||
--token-field-bg-color: var(--color-background-tertiary);
|
||||
@@ -16,9 +17,8 @@
|
||||
--token-field-outline-color: none;
|
||||
--token-field-height: var(--sp-xxxl);
|
||||
--token-field-margin: unset;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
width: inherit;
|
||||
column-gap: var(--sp-xs);
|
||||
align-items: center;
|
||||
position: relative;
|
||||
@@ -27,6 +27,7 @@
|
||||
border-radius: $br-8;
|
||||
padding: var(--sp-xs);
|
||||
outline: $b-1 solid var(--token-field-outline-color);
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
--token-field-bg-color: var(--color-background-quaternary);
|
||||
@@ -39,7 +40,7 @@
|
||||
}
|
||||
|
||||
.with-icon {
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
||||
.token-field-disabled {
|
||||
@@ -57,6 +58,8 @@
|
||||
--pill-bg-color: var(--color-background-tertiary);
|
||||
--pill-fg-color: var(--color-token-foreground);
|
||||
@include t.use-typography("code-font");
|
||||
@include textEllipsis;
|
||||
display: block;
|
||||
height: var(--sp-xxl);
|
||||
width: fit-content;
|
||||
background: var(--pill-bg-color);
|
||||
@@ -65,6 +68,7 @@
|
||||
color: var(--pill-fg-color);
|
||||
border-radius: $br-6;
|
||||
padding-inline: $sz-6;
|
||||
max-width: 100%;
|
||||
&:hover {
|
||||
--pill-bg-color: var(--color-token-background);
|
||||
--pill-fg-color: var(--color-foreground-primary);
|
||||
@@ -115,6 +119,9 @@
|
||||
}
|
||||
|
||||
.invisible-button {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
opacity: var(--opacity-button);
|
||||
|
||||
&:hover {
|
||||
|
||||
49
frontend/src/app/main/ui/ds/layers/layer_button.cljs
Normal file
49
frontend/src/app/main/ui/ds/layers/layer_button.cljs
Normal file
@@ -0,0 +1,49 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns app.main.ui.ds.layers.layer-button
|
||||
(:require-macros
|
||||
[app.main.style :as stl])
|
||||
(:require
|
||||
[app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(def ^:private schema:layer-button
|
||||
[:map
|
||||
[:label :string]
|
||||
[:description {:optional true} [:maybe :string]]
|
||||
[:class {:optional true} :string]
|
||||
[:expandable {:optional true} :boolean]
|
||||
[:expanded {:optional true} :boolean]
|
||||
[:icon {:optional true} :string]
|
||||
[:on-toggle-expand fn?]])
|
||||
|
||||
(mf/defc layer-button*
|
||||
{::mf/schema schema:layer-button}
|
||||
[{:keys [label description class is-expandable expanded icon on-toggle-expand children] :rest props}]
|
||||
(let [button-props (mf/spread-props props
|
||||
{:class [class (stl/css-case :layer-button true
|
||||
:layer-button--expandable is-expandable
|
||||
:layer-button--expanded expanded)]
|
||||
:type "button"
|
||||
:on-click on-toggle-expand})]
|
||||
[:div {:class (stl/css :layer-button-wrapper)}
|
||||
[:> "button" button-props
|
||||
[:div {:class (stl/css :layer-button-content)}
|
||||
(when is-expandable
|
||||
(if expanded
|
||||
[:> icon* {:icon-id i/arrow-down :class (stl/css :folder-node-icon)}]
|
||||
[:> icon* {:icon-id i/arrow-right :class (stl/css :folder-node-icon)}]))
|
||||
(when icon
|
||||
[:> icon* {:icon-id icon :class (stl/css :layer-button-icon)}])
|
||||
[:span {:class (stl/css :layer-button-name)}
|
||||
label]
|
||||
(when description
|
||||
[:span {:class (stl/css :layer-button-description)}
|
||||
description])
|
||||
[:span {:class (stl/css :layer-button-quantity)}]]]
|
||||
[:div {:class (stl/css :layer-button-actions)}
|
||||
children]]))
|
||||
56
frontend/src/app/main/ui/ds/layers/layer_button.scss
Normal file
56
frontend/src/app/main/ui/ds/layers/layer_button.scss
Normal file
@@ -0,0 +1,56 @@
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) KALEIDOS INC
|
||||
|
||||
@use "ds/_borders.scss" as *;
|
||||
@use "ds/_sizes.scss" as *;
|
||||
@use "ds/typography.scss" as *;
|
||||
@use "ds/colors.scss" as *;
|
||||
|
||||
.layer-button-wrapper {
|
||||
--layer-button-block-size: #{$sz-32};
|
||||
--layer-button-background: var(--color-background-primary);
|
||||
--layer-button-text: var(--color-foreground-secondary);
|
||||
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
block-size: var(--layer-button-block-size);
|
||||
|
||||
background: var(--layer-button-background);
|
||||
color: var(--layer-button-text);
|
||||
}
|
||||
|
||||
.layer-button {
|
||||
@include use-typography("body-small");
|
||||
|
||||
appearance: none;
|
||||
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.layer-button--expanded {
|
||||
& .layer-button-name {
|
||||
color: var(--color-foreground-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.layer-button-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-xs);
|
||||
}
|
||||
|
||||
.layer-button-description {
|
||||
padding: var(--sp-xs);
|
||||
background-color: var(--color-background-tertiary);
|
||||
border-radius: $br-6;
|
||||
}
|
||||
@@ -159,4 +159,6 @@ $arrow-side: 12px;
|
||||
block-size: fit-content;
|
||||
inline-size: fit-content;
|
||||
line-height: 0;
|
||||
display: grid;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@@ -223,24 +223,30 @@
|
||||
circ (* 2 Math/PI 12)
|
||||
pct (- circ (* circ (/ progress total)))
|
||||
|
||||
pwidth (if error?
|
||||
280
|
||||
(/ (* progress 280) total))
|
||||
color (cond
|
||||
error? clr/new-danger
|
||||
healthy? (if is-default-theme?
|
||||
clr/new-primary
|
||||
clr/new-primary-light)
|
||||
(not healthy?) clr/new-warning)
|
||||
pwidth
|
||||
(if error?
|
||||
280
|
||||
(/ (* progress 280) total))
|
||||
|
||||
background-clr (if is-default-theme?
|
||||
clr/background-quaternary
|
||||
clr/background-quaternary-light)
|
||||
title (cond
|
||||
error? (tr "workspace.options.exporting-object-error")
|
||||
complete? (tr "workspace.options.exporting-complete")
|
||||
healthy? (tr "workspace.options.exporting-object")
|
||||
(not healthy?) (tr "workspace.options.exporting-object-slow"))
|
||||
color
|
||||
(cond
|
||||
error? clr/new-danger
|
||||
healthy? (if is-default-theme?
|
||||
clr/new-primary
|
||||
clr/new-primary-light)
|
||||
(not healthy?) clr/new-warning)
|
||||
|
||||
background-clr
|
||||
(if is-default-theme?
|
||||
clr/background-quaternary
|
||||
clr/background-quaternary-light)
|
||||
|
||||
title
|
||||
(cond
|
||||
error? (tr "workspace.options.exporting-object-error")
|
||||
complete? (tr "workspace.options.exporting-complete")
|
||||
healthy? (tr "workspace.options.exporting-object")
|
||||
(not healthy?) (tr "workspace.options.exporting-object-slow"))
|
||||
|
||||
retry-last-export
|
||||
(mf/use-fn #(st/emit! (de/retry-last-export)))
|
||||
@@ -284,7 +290,7 @@
|
||||
:on-click retry-last-export}
|
||||
(tr "workspace.options.retry")]
|
||||
|
||||
[:p {:class (stl/css :progress)}
|
||||
[:span {:class (stl/css :progress)}
|
||||
(dm/str progress " / " total)])]
|
||||
|
||||
[:button {:class (stl/css :progress-close-button)
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
(ns app.main.ui.flex-controls.gap
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.files.helpers :as cfh]
|
||||
[app.common.geom.point :as gpt]
|
||||
[app.common.geom.shapes :as gsh]
|
||||
@@ -16,6 +17,8 @@
|
||||
[app.common.types.shape.layout :as ctl]
|
||||
[app.main.data.helpers :as dsh]
|
||||
[app.main.data.workspace.modifiers :as dwm]
|
||||
[app.main.data.workspace.transforms :as dwt]
|
||||
[app.main.features :as features]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.css-cursors :as cur]
|
||||
@@ -27,10 +30,11 @@
|
||||
(mf/defc gap-display
|
||||
[{:keys [frame-id zoom gap-type gap on-pointer-enter on-pointer-leave
|
||||
rect-data hover? selected? mouse-pos hover-value
|
||||
on-move-selected on-context-menu]}]
|
||||
on-move-selected on-context-menu on-change]}]
|
||||
(let [resizing (mf/use-var nil)
|
||||
start (mf/use-var nil)
|
||||
original-value (mf/use-var 0)
|
||||
last-pos (mf/use-var nil)
|
||||
negate? (:resize-negate? rect-data)
|
||||
axis (:resize-axis rect-data)
|
||||
|
||||
@@ -43,32 +47,55 @@
|
||||
(reset! start (dom/get-client-position event))
|
||||
(reset! original-value (:initial-value rect-data))))
|
||||
|
||||
on-lost-pointer-capture
|
||||
calc-modifiers
|
||||
(mf/use-fn
|
||||
(mf/deps frame-id gap-type gap)
|
||||
(fn [pos]
|
||||
(let [delta
|
||||
(-> (gpt/to-vec @start pos)
|
||||
(cond-> negate? gpt/negate)
|
||||
(get axis))
|
||||
val
|
||||
(int (max (+ @original-value (/ delta zoom)) 0))
|
||||
|
||||
layout-gap (assoc gap gap-type val)]
|
||||
[val
|
||||
(dwm/create-modif-tree
|
||||
[frame-id]
|
||||
(ctm/change-property (ctm/empty) :layout-gap layout-gap))])))
|
||||
|
||||
on-lost-pointer-capture
|
||||
(mf/use-fn
|
||||
(mf/deps calc-modifiers)
|
||||
(fn [event]
|
||||
(dom/release-pointer event)
|
||||
|
||||
(when (and (features/active-feature? @st/state "render-wasm/v1") (= @resizing gap-type))
|
||||
(let [[_ modifiers] (calc-modifiers @last-pos)]
|
||||
(st/emit! (dwm/apply-wasm-modifiers modifiers)
|
||||
(dwt/finish-transform))))
|
||||
|
||||
(reset! resizing nil)
|
||||
(reset! start nil)
|
||||
(reset! original-value 0)
|
||||
(st/emit! (dwm/apply-modifiers))))
|
||||
(when (not (features/active-feature? @st/state "render-wasm/v1"))
|
||||
(st/emit! (dwm/apply-modifiers)))))
|
||||
|
||||
on-pointer-move
|
||||
(mf/use-fn
|
||||
(mf/deps frame-id gap-type gap)
|
||||
(mf/deps calc-modifiers on-change)
|
||||
(fn [event]
|
||||
(let [pos (dom/get-client-position event)]
|
||||
(reset! last-pos pos)
|
||||
(reset! mouse-pos (point->viewport pos))
|
||||
(when (= @resizing gap-type)
|
||||
(let [delta (-> (gpt/to-vec @start pos)
|
||||
(cond-> negate? gpt/negate)
|
||||
(get axis))
|
||||
val (int (max (+ @original-value (/ delta zoom)) 0))
|
||||
layout-gap (assoc gap gap-type val)
|
||||
modifiers (dwm/create-modif-tree [frame-id] (ctm/change-property (ctm/empty) :layout-gap layout-gap))]
|
||||
|
||||
(let [[val modifiers] (calc-modifiers pos)]
|
||||
(reset! hover-value val)
|
||||
(st/emit! (dwm/set-modifiers modifiers)))))))]
|
||||
(if (features/active-feature? @st/state "render-wasm/v1")
|
||||
(st/emit! (dwm/set-wasm-modifiers modifiers))
|
||||
(st/emit! (dwm/set-modifiers modifiers)))
|
||||
(when on-change
|
||||
(on-change modifiers)))))))]
|
||||
|
||||
[:g.gap-rect
|
||||
[:rect.info-area
|
||||
@@ -120,10 +147,17 @@
|
||||
pill-width (/ fcc/flex-display-pill-width zoom)
|
||||
pill-height (/ fcc/flex-display-pill-height zoom)
|
||||
workspace-modifiers (mf/deref refs/workspace-modifiers)
|
||||
workspace-wasm-modifiers (mf/deref refs/workspace-wasm-modifiers)
|
||||
|
||||
gap-selected (mf/deref refs/workspace-gap-selected)
|
||||
hover (mf/use-state nil)
|
||||
hover-value (mf/use-state 0)
|
||||
mouse-pos (mf/use-state nil)
|
||||
current-modifiers (mf/use-state nil)
|
||||
|
||||
frame
|
||||
(ctm/apply-structure-modifiers frame (dm/get-in @current-modifiers [frame-id :modifiers]))
|
||||
|
||||
padding (:layout-padding frame)
|
||||
gap (:layout-gap frame)
|
||||
{:keys [width height x1 y1]} (:selrect frame)
|
||||
@@ -132,6 +166,12 @@
|
||||
(reset! hover-value val))
|
||||
|
||||
on-pointer-leave #(reset! hover nil)
|
||||
|
||||
on-change
|
||||
(mf/use-fn
|
||||
(fn [modifiers]
|
||||
(reset! current-modifiers modifiers)))
|
||||
|
||||
negate {:column-gap (if flip-x true false)
|
||||
:row-gap (if flip-y true false)}
|
||||
|
||||
@@ -143,8 +183,16 @@
|
||||
(= :column-reverse saved-dir))
|
||||
(drop-last children)
|
||||
(rest children))
|
||||
children-to-display (->> children-to-display
|
||||
(map #(gsh/transform-shape % (get-in workspace-modifiers [(:id %) :modifiers]))))
|
||||
children-to-display
|
||||
(if (features/active-feature? @st/state "render-wasm/v1")
|
||||
(let [modifiers (into {} workspace-wasm-modifiers)]
|
||||
(->> children-to-display
|
||||
;;(map #(gsh/transform-shape % (get-in workspace-modifiers [(:id %) :modifiers])))
|
||||
(map (fn [shape]
|
||||
(gsh/apply-transform shape (get modifiers (:id shape)))))))
|
||||
|
||||
(->> children-to-display
|
||||
(map #(gsh/transform-shape % (get-in workspace-modifiers [(:id %) :modifiers])))))
|
||||
|
||||
wrap-blocks
|
||||
(let [block-children (->> children
|
||||
@@ -272,20 +320,22 @@
|
||||
[:g.gaps {:pointer-events "visible"}
|
||||
(for [[index display-item] (d/enumerate (concat display-blocks display-children))]
|
||||
(let [gap-type (:gap-type display-item)]
|
||||
[:& gap-display {:key (str frame-id index)
|
||||
:frame-id frame-id
|
||||
:zoom zoom
|
||||
:gap-type gap-type
|
||||
:gap gap
|
||||
:on-pointer-enter (partial on-pointer-enter gap-type (get gap gap-type))
|
||||
:on-pointer-leave on-pointer-leave
|
||||
:on-move-selected on-move-selected
|
||||
:on-context-menu on-context-menu
|
||||
:rect-data display-item
|
||||
:hover? (= @hover gap-type)
|
||||
:selected? (= gap-selected gap-type)
|
||||
:mouse-pos mouse-pos
|
||||
:hover-value hover-value}]))
|
||||
[:& gap-display
|
||||
{:key (str frame-id index)
|
||||
:frame-id frame-id
|
||||
:zoom zoom
|
||||
:gap-type gap-type
|
||||
:gap gap
|
||||
:on-pointer-enter (partial on-pointer-enter gap-type (get gap gap-type))
|
||||
:on-pointer-leave on-pointer-leave
|
||||
:on-move-selected on-move-selected
|
||||
:on-context-menu on-context-menu
|
||||
:on-change on-change
|
||||
:rect-data display-item
|
||||
:hover? (= @hover gap-type)
|
||||
:selected? (= gap-selected gap-type)
|
||||
:mouse-pos mouse-pos
|
||||
:hover-value hover-value}]))
|
||||
|
||||
(when @hover
|
||||
[:& fcc/flex-display-pill
|
||||
|
||||
@@ -6,9 +6,12 @@
|
||||
|
||||
(ns app.main.ui.flex-controls.margin
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.geom.point :as gpt]
|
||||
[app.common.types.modifiers :as ctm]
|
||||
[app.main.data.workspace.modifiers :as dwm]
|
||||
[app.main.data.workspace.transforms :as dwt]
|
||||
[app.main.features :as features]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.css-cursors :as cur]
|
||||
@@ -17,11 +20,14 @@
|
||||
[app.util.dom :as dom]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(mf/defc margin-display [{:keys [shape-id zoom hover-all? hover-v? hover-h? margin-num margin on-pointer-enter on-pointer-leave
|
||||
rect-data hover? selected? mouse-pos hover-value]}]
|
||||
(mf/defc margin-display
|
||||
[{:keys [shape-id zoom hover-all? hover-v? hover-h? margin-num margin
|
||||
on-pointer-enter on-pointer-leave on-change
|
||||
rect-data hover? selected? mouse-pos hover-value]}]
|
||||
(let [resizing? (mf/use-var false)
|
||||
start (mf/use-var nil)
|
||||
original-value (mf/use-var 0)
|
||||
last-pos (mf/use-var nil)
|
||||
negate? (true? (:resize-negate? rect-data))
|
||||
axis (:resize-axis rect-data)
|
||||
|
||||
@@ -34,39 +40,69 @@
|
||||
(reset! start (dom/get-client-position event))
|
||||
(reset! original-value (:initial-value rect-data))))
|
||||
|
||||
calc-modifiers
|
||||
(mf/use-fn
|
||||
(mf/deps shape-id margin-num margin hover-all? hover-v? hover-h?)
|
||||
(fn [pos]
|
||||
(let [delta
|
||||
(-> (gpt/to-vec @start pos)
|
||||
(cond-> negate? gpt/negate)
|
||||
(get axis))
|
||||
|
||||
val
|
||||
(int (max (+ @original-value (/ delta zoom)) 0))
|
||||
|
||||
layout-item-margin
|
||||
(cond
|
||||
hover-all? (assoc margin :m1 val :m2 val :m3 val :m4 val)
|
||||
hover-v? (assoc margin :m1 val :m3 val)
|
||||
hover-h? (assoc margin :m2 val :m4 val)
|
||||
:else (assoc margin margin-num val))
|
||||
|
||||
layout-item-margin-type
|
||||
(if (= (:m1 margin) (:m2 margin) (:m3 margin) (:m4 margin)) :simple :multiple)]
|
||||
|
||||
[val
|
||||
(dwm/create-modif-tree
|
||||
[shape-id]
|
||||
(-> (ctm/empty)
|
||||
(ctm/change-property :layout-item-margin layout-item-margin)
|
||||
(ctm/change-property :layout-item-margin-type layout-item-margin-type)))])))
|
||||
|
||||
on-lost-pointer-capture
|
||||
(mf/use-fn
|
||||
(mf/deps shape-id margin-num margin)
|
||||
(mf/deps calc-modifiers)
|
||||
(fn [event]
|
||||
(dom/release-pointer event)
|
||||
|
||||
(when (features/active-feature? @st/state "render-wasm/v1")
|
||||
(let [[_ modifiers] (calc-modifiers @last-pos)]
|
||||
(st/emit! (dwm/apply-wasm-modifiers modifiers)
|
||||
(dwt/finish-transform))))
|
||||
|
||||
(reset! resizing? false)
|
||||
(reset! start nil)
|
||||
(reset! original-value 0)
|
||||
(st/emit! (dwm/apply-modifiers))))
|
||||
|
||||
(when (not (features/active-feature? @st/state "render-wasm/v1"))
|
||||
(st/emit! (dwm/apply-modifiers)))))
|
||||
|
||||
on-pointer-move
|
||||
(mf/use-fn
|
||||
(mf/deps shape-id margin-num margin hover-all? hover-v? hover-h?)
|
||||
(mf/deps calc-modifiers on-change)
|
||||
(fn [event]
|
||||
(let [pos (dom/get-client-position event)]
|
||||
(reset! mouse-pos (point->viewport pos))
|
||||
(reset! last-pos pos)
|
||||
(when @resizing?
|
||||
(let [delta (-> (gpt/to-vec @start pos)
|
||||
(cond-> negate? gpt/negate)
|
||||
(get axis))
|
||||
val (int (max (+ @original-value (/ delta zoom)) 0))
|
||||
layout-item-margin (cond
|
||||
hover-all? (assoc margin :m1 val :m2 val :m3 val :m4 val)
|
||||
hover-v? (assoc margin :m1 val :m3 val)
|
||||
hover-h? (assoc margin :m2 val :m4 val)
|
||||
:else (assoc margin margin-num val))
|
||||
layout-item-margin-type (if (= (:m1 margin) (:m2 margin) (:m3 margin) (:m4 margin)) :simple :multiple)
|
||||
modifiers (dwm/create-modif-tree [shape-id]
|
||||
(-> (ctm/empty)
|
||||
(ctm/change-property :layout-item-margin layout-item-margin)
|
||||
(ctm/change-property :layout-item-margin-type layout-item-margin-type)))]
|
||||
(let [[val modifiers] (calc-modifiers pos)]
|
||||
(reset! hover-value val)
|
||||
(st/emit! (dwm/set-modifiers modifiers)))))))]
|
||||
(if (features/active-feature? @st/state "render-wasm/v1")
|
||||
(st/emit! (dwm/set-wasm-modifiers modifiers))
|
||||
(st/emit! (dwm/set-modifiers modifiers)))
|
||||
|
||||
(when on-change
|
||||
(on-change modifiers)))))))]
|
||||
|
||||
[:rect.margin-rect
|
||||
{:x (:x rect-data)
|
||||
@@ -89,6 +125,11 @@
|
||||
pill-width (/ fcc/flex-display-pill-width zoom)
|
||||
pill-height (/ fcc/flex-display-pill-height zoom)
|
||||
margins-selected (mf/deref refs/workspace-margins-selected)
|
||||
current-modifiers (mf/use-state nil)
|
||||
|
||||
shape
|
||||
(ctm/apply-structure-modifiers shape (dm/get-in @current-modifiers [shape-id :modifiers]))
|
||||
|
||||
hover-value (mf/use-state 0)
|
||||
mouse-pos (mf/use-state nil)
|
||||
hover (mf/use-state nil)
|
||||
@@ -97,50 +138,67 @@
|
||||
hover-h? (and (or (= @hover :m2) (= @hover :m4)) shift?)
|
||||
margin (:layout-item-margin shape)
|
||||
{:keys [width height x1 x2 y1 y2]} (:selrect shape)
|
||||
on-pointer-enter (fn [hover-type val]
|
||||
(reset! hover hover-type)
|
||||
(reset! hover-value val))
|
||||
on-pointer-leave #(reset! hover nil)
|
||||
hover? #(or hover-all?
|
||||
(and (or (= % :m1) (= % :m3)) hover-v?)
|
||||
(and (or (= % :m2) (= % :m4)) hover-h?)
|
||||
(= @hover %))
|
||||
margin-display-data {:m1 {:key (str shape-id "-m1")
|
||||
:x x1
|
||||
:y (if (:flip-y frame) y2 (- y1 (:m1 margin)))
|
||||
:width width
|
||||
:height (:m1 margin)
|
||||
:initial-value (:m1 margin)
|
||||
:resize-type :top
|
||||
:resize-axis :y
|
||||
:resize-negate? (:flip-y frame)}
|
||||
:m2 {:key (str shape-id "-m2")
|
||||
:x (if (:flip-x frame) (- x1 (:m2 margin)) x2)
|
||||
:y y1
|
||||
:width (:m2 margin)
|
||||
:height height
|
||||
:initial-value (:m2 margin)
|
||||
:resize-type :left
|
||||
:resize-axis :x
|
||||
:resize-negate? (:flip-x frame)}
|
||||
:m3 {:key (str shape-id "-m3")
|
||||
:x x1
|
||||
:y (if (:flip-y frame) (- y1 (:m3 margin)) y2)
|
||||
:width width
|
||||
:height (:m3 margin)
|
||||
:initial-value (:m3 margin)
|
||||
:resize-type :top
|
||||
:resize-axis :y
|
||||
:resize-negate? (:flip-y frame)}
|
||||
:m4 {:key (str shape-id "-m4")
|
||||
:x (if (:flip-x frame) x2 (- x1 (:m4 margin)))
|
||||
:y y1
|
||||
:width (:m4 margin)
|
||||
:height height
|
||||
:initial-value (:m4 margin)
|
||||
:resize-type :left
|
||||
:resize-axis :x
|
||||
:resize-negate? (:flip-x frame)}}]
|
||||
|
||||
on-pointer-enter
|
||||
(mf/use-fn
|
||||
(fn [hover-type val]
|
||||
(reset! hover hover-type)
|
||||
(reset! hover-value val)))
|
||||
|
||||
on-pointer-leave
|
||||
(mf/use-fn
|
||||
(fn []
|
||||
(reset! hover nil)))
|
||||
|
||||
on-change
|
||||
(mf/use-fn
|
||||
(fn [modifiers]
|
||||
(reset! current-modifiers modifiers)))
|
||||
|
||||
hover?
|
||||
(fn [value]
|
||||
(or hover-all?
|
||||
(and (or (= value :m1) (= value :m3)) hover-v?)
|
||||
(and (or (= value :m2) (= value :m4)) hover-h?)
|
||||
(= @hover value)))
|
||||
|
||||
margin-display-data
|
||||
{:m1 {:key (str shape-id "-m1")
|
||||
:x x1
|
||||
:y (if (:flip-y frame) y2 (- y1 (:m1 margin)))
|
||||
:width width
|
||||
:height (:m1 margin)
|
||||
:initial-value (:m1 margin)
|
||||
:resize-type :top
|
||||
:resize-axis :y
|
||||
:resize-negate? (:flip-y frame)}
|
||||
:m2 {:key (str shape-id "-m2")
|
||||
:x (if (:flip-x frame) (- x1 (:m2 margin)) x2)
|
||||
:y y1
|
||||
:width (:m2 margin)
|
||||
:height height
|
||||
:initial-value (:m2 margin)
|
||||
:resize-type :left
|
||||
:resize-axis :x
|
||||
:resize-negate? (:flip-x frame)}
|
||||
:m3 {:key (str shape-id "-m3")
|
||||
:x x1
|
||||
:y (if (:flip-y frame) (- y1 (:m3 margin)) y2)
|
||||
:width width
|
||||
:height (:m3 margin)
|
||||
:initial-value (:m3 margin)
|
||||
:resize-type :top
|
||||
:resize-axis :y
|
||||
:resize-negate? (:flip-y frame)}
|
||||
:m4 {:key (str shape-id "-m4")
|
||||
:x (if (:flip-x frame) x2 (- x1 (:m4 margin)))
|
||||
:y y1
|
||||
:width (:m4 margin)
|
||||
:height height
|
||||
:initial-value (:m4 margin)
|
||||
:resize-type :left
|
||||
:resize-axis :x
|
||||
:resize-negate? (:flip-x frame)}}]
|
||||
|
||||
[:g.margins {:pointer-events "visible"}
|
||||
(for [[margin-num rect-data] margin-display-data]
|
||||
@@ -155,6 +213,7 @@
|
||||
:margin margin
|
||||
:on-pointer-enter (partial on-pointer-enter margin-num (get margin margin-num))
|
||||
:on-pointer-leave on-pointer-leave
|
||||
:on-change on-change
|
||||
:rect-data rect-data
|
||||
:hover? (hover? margin-num)
|
||||
:selected? (get margins-selected margin-num)
|
||||
|
||||
@@ -6,9 +6,12 @@
|
||||
|
||||
(ns app.main.ui.flex-controls.padding
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.geom.point :as gpt]
|
||||
[app.common.types.modifiers :as ctm]
|
||||
[app.main.data.workspace.modifiers :as dwm]
|
||||
[app.main.data.workspace.transforms :as dwt]
|
||||
[app.main.features :as features]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.css-cursors :as cur]
|
||||
@@ -18,11 +21,13 @@
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(mf/defc padding-display
|
||||
[{:keys [frame-id zoom hover-all? hover-v? hover-h? padding-num padding on-pointer-enter on-pointer-leave
|
||||
rect-data hover? selected? mouse-pos hover-value on-move-selected on-context-menu]}]
|
||||
[{:keys [frame-id zoom hover-all? hover-v? hover-h? padding-num padding on-pointer-enter
|
||||
on-pointer-leave rect-data hover? selected? mouse-pos hover-value on-move-selected
|
||||
on-context-menu on-change]}]
|
||||
(let [resizing? (mf/use-var false)
|
||||
start (mf/use-var nil)
|
||||
original-value (mf/use-var 0)
|
||||
last-pos (mf/use-var nil)
|
||||
negate? (true? (:resize-negate? rect-data))
|
||||
axis (:resize-axis rect-data)
|
||||
|
||||
@@ -35,41 +40,69 @@
|
||||
(reset! start (dom/get-client-position event))
|
||||
(reset! original-value (:initial-value rect-data))))
|
||||
|
||||
calc-modifiers
|
||||
(mf/use-fn
|
||||
(mf/deps frame-id padding-num padding hover-all? hover-v? hover-h?)
|
||||
(fn [pos]
|
||||
(let [delta
|
||||
(-> (gpt/to-vec @start pos)
|
||||
(cond-> negate? gpt/negate)
|
||||
(get axis))
|
||||
|
||||
val
|
||||
(int (max (+ @original-value (/ delta zoom)) 0))
|
||||
|
||||
layout-padding
|
||||
(cond
|
||||
hover-all? (assoc padding :p1 val :p2 val :p3 val :p4 val)
|
||||
hover-v? (assoc padding :p1 val :p3 val)
|
||||
hover-h? (assoc padding :p2 val :p4 val)
|
||||
:else (assoc padding padding-num val))
|
||||
|
||||
|
||||
layout-padding-type
|
||||
(if (= (:p1 padding) (:p2 padding) (:p3 padding) (:p4 padding)) :simple :multiple)]
|
||||
[val
|
||||
(dwm/create-modif-tree
|
||||
[frame-id]
|
||||
(-> (ctm/empty)
|
||||
(ctm/change-property :layout-padding layout-padding)
|
||||
(ctm/change-property :layout-padding-type layout-padding-type)))])))
|
||||
|
||||
on-lost-pointer-capture
|
||||
(mf/use-fn
|
||||
(mf/deps frame-id padding-num padding)
|
||||
(mf/deps calc-modifiers)
|
||||
(fn [event]
|
||||
(dom/release-pointer event)
|
||||
|
||||
(when (features/active-feature? @st/state "render-wasm/v1")
|
||||
(let [[_ modifiers] (calc-modifiers @last-pos)]
|
||||
(st/emit! (dwm/apply-wasm-modifiers modifiers)
|
||||
(dwt/finish-transform))))
|
||||
|
||||
(reset! resizing? false)
|
||||
(reset! start nil)
|
||||
(reset! original-value 0)
|
||||
(st/emit! (dwm/apply-modifiers))))
|
||||
|
||||
(when (not (features/active-feature? @st/state "render-wasm/v1"))
|
||||
(st/emit! (dwm/apply-modifiers)))))
|
||||
|
||||
on-pointer-move
|
||||
(mf/use-fn
|
||||
(mf/deps frame-id padding-num padding hover-all? hover-v? hover-h?)
|
||||
(mf/deps calc-modifiers on-change)
|
||||
(fn [event]
|
||||
(let [pos (dom/get-client-position event)]
|
||||
(reset! mouse-pos (point->viewport pos))
|
||||
(reset! last-pos pos)
|
||||
(when @resizing?
|
||||
(let [delta (-> (gpt/to-vec @start pos)
|
||||
(cond-> negate? gpt/negate)
|
||||
(get axis))
|
||||
val (int (max (+ @original-value (/ delta zoom)) 0))
|
||||
layout-padding (cond
|
||||
hover-all? (assoc padding :p1 val :p2 val :p3 val :p4 val)
|
||||
hover-v? (assoc padding :p1 val :p3 val)
|
||||
hover-h? (assoc padding :p2 val :p4 val)
|
||||
:else (assoc padding padding-num val))
|
||||
|
||||
|
||||
layout-padding-type (if (= (:p1 padding) (:p2 padding) (:p3 padding) (:p4 padding)) :simple :multiple)
|
||||
modifiers (dwm/create-modif-tree [frame-id]
|
||||
(-> (ctm/empty)
|
||||
(ctm/change-property :layout-padding layout-padding)
|
||||
(ctm/change-property :layout-padding-type layout-padding-type)))]
|
||||
(let [[val modifiers] (calc-modifiers pos)]
|
||||
(reset! hover-value val)
|
||||
(st/emit! (dwm/set-modifiers modifiers)))))))]
|
||||
(if (features/active-feature? @st/state "render-wasm/v1")
|
||||
(st/emit! (dwm/set-wasm-modifiers modifiers))
|
||||
(st/emit! (dwm/set-modifiers modifiers)))
|
||||
|
||||
(when on-change
|
||||
(on-change modifiers)))))))]
|
||||
|
||||
[:g.padding-rect
|
||||
[:rect.info-area
|
||||
@@ -105,77 +138,108 @@
|
||||
:on-lost-pointer-capture on-lost-pointer-capture
|
||||
:on-pointer-move on-pointer-move
|
||||
:on-context-menu on-context-menu
|
||||
:class (when (or hover? selected?)
|
||||
(if (= (:resize-axis rect-data) :x) (cur/get-dynamic "resize-ew" 0) (cur/get-dynamic "resize-ew" 90)))
|
||||
:style {:fill (if (or hover? selected?) fcc/distance-color "none")
|
||||
:opacity (if selected? 0 1)}}])]))
|
||||
:class
|
||||
(when (or hover? selected?)
|
||||
(if (= (:resize-axis rect-data) :x)
|
||||
(cur/get-dynamic "resize-ew" 0)
|
||||
(cur/get-dynamic "resize-ew" 90)))
|
||||
|
||||
:style
|
||||
{:fill (if (or hover? selected?) fcc/distance-color "none")
|
||||
:opacity (if selected? 0 1)}}])]))
|
||||
|
||||
(mf/defc padding-rects
|
||||
[{:keys [frame zoom alt? shift? on-move-selected on-context-menu]}]
|
||||
(let [frame-id (:id frame)
|
||||
paddings-selected (mf/deref refs/workspace-paddings-selected)
|
||||
current-modifiers (mf/use-state nil)
|
||||
|
||||
frame
|
||||
(ctm/apply-structure-modifiers frame (dm/get-in @current-modifiers [frame-id :modifiers]))
|
||||
|
||||
hover-value (mf/use-state 0)
|
||||
mouse-pos (mf/use-state nil)
|
||||
hover (mf/use-state nil)
|
||||
|
||||
hover-all? (and (not (nil? @hover)) alt?)
|
||||
hover-v? (and (or (= @hover :p1) (= @hover :p3)) shift?)
|
||||
hover-h? (and (or (= @hover :p2) (= @hover :p4)) shift?)
|
||||
padding (:layout-padding frame)
|
||||
{:keys [width height x1 x2 y1 y2]} (:selrect frame)
|
||||
on-pointer-enter (fn [hover-type val]
|
||||
(reset! hover hover-type)
|
||||
(reset! hover-value val))
|
||||
on-pointer-leave #(reset! hover nil)
|
||||
pill-width (/ fcc/flex-display-pill-width zoom)
|
||||
pill-height (/ fcc/flex-display-pill-height zoom)
|
||||
hover? #(or hover-all?
|
||||
(and (or (= % :p1) (= % :p3)) hover-v?)
|
||||
(and (or (= % :p2) (= % :p4)) hover-h?)
|
||||
(= @hover %))
|
||||
negate {:p1 (if (:flip-y frame) true false)
|
||||
:p2 (if (:flip-x frame) true false)
|
||||
:p3 (if (:flip-y frame) true false)
|
||||
:p4 (if (:flip-x frame) true false)}
|
||||
negate (cond-> negate
|
||||
(not= :auto (:layout-item-h-sizing frame)) (assoc :p2 (not (:p2 negate)))
|
||||
(not= :auto (:layout-item-v-sizing frame)) (assoc :p3 (not (:p3 negate))))
|
||||
|
||||
padding-rect-data {:p1 {:key (str frame-id "-p1")
|
||||
:x x1
|
||||
:y (if (:flip-y frame) (- y2 (:p1 padding)) y1)
|
||||
:width width
|
||||
:height (:p1 padding)
|
||||
:initial-value (:p1 padding)
|
||||
:resize-type (if (:flip-y frame) :bottom :top)
|
||||
:resize-axis :y
|
||||
:resize-negate? (:p1 negate)}
|
||||
:p2 {:key (str frame-id "-p2")
|
||||
:x (if (:flip-x frame) x1 (- x2 (:p2 padding)))
|
||||
:y y1
|
||||
:width (:p2 padding)
|
||||
:height height
|
||||
:initial-value (:p2 padding)
|
||||
:resize-type :left
|
||||
:resize-axis :x
|
||||
:resize-negate? (:p2 negate)}
|
||||
:p3 {:key (str frame-id "-p3")
|
||||
:x x1
|
||||
:y (if (:flip-y frame) y1 (- y2 (:p3 padding)))
|
||||
:width width
|
||||
:height (:p3 padding)
|
||||
:initial-value (:p3 padding)
|
||||
:resize-type :bottom
|
||||
:resize-axis :y
|
||||
:resize-negate? (:p3 negate)}
|
||||
:p4 {:key (str frame-id "-p4")
|
||||
:x (if (:flip-x frame) (- x2 (:p4 padding)) x1)
|
||||
:y y1
|
||||
:width (:p4 padding)
|
||||
:height height
|
||||
:initial-value (:p4 padding)
|
||||
:resize-type (if (:flip-x frame) :right :left)
|
||||
:resize-axis :x
|
||||
:resize-negate? (:p4 negate)}}]
|
||||
negate
|
||||
{:p1 (if (:flip-y frame) true false)
|
||||
:p2 (if (:flip-x frame) true false)
|
||||
:p3 (if (:flip-y frame) true false)
|
||||
:p4 (if (:flip-x frame) true false)}
|
||||
|
||||
negate
|
||||
(cond-> negate
|
||||
(not= :auto (:layout-item-h-sizing frame)) (assoc :p2 (not (:p2 negate)))
|
||||
(not= :auto (:layout-item-v-sizing frame)) (assoc :p3 (not (:p3 negate))))
|
||||
|
||||
padding-rect-data
|
||||
{:p1 {:key (str frame-id "-p1")
|
||||
:x x1
|
||||
:y (if (:flip-y frame) (- y2 (:p1 padding)) y1)
|
||||
:width width
|
||||
:height (:p1 padding)
|
||||
:initial-value (:p1 padding)
|
||||
:resize-type (if (:flip-y frame) :bottom :top)
|
||||
:resize-axis :y
|
||||
:resize-negate? (:p1 negate)}
|
||||
:p2 {:key (str frame-id "-p2")
|
||||
:x (if (:flip-x frame) x1 (- x2 (:p2 padding)))
|
||||
:y y1
|
||||
:width (:p2 padding)
|
||||
:height height
|
||||
:initial-value (:p2 padding)
|
||||
:resize-type :left
|
||||
:resize-axis :x
|
||||
:resize-negate? (:p2 negate)}
|
||||
:p3 {:key (str frame-id "-p3")
|
||||
:x x1
|
||||
:y (if (:flip-y frame) y1 (- y2 (:p3 padding)))
|
||||
:width width
|
||||
:height (:p3 padding)
|
||||
:initial-value (:p3 padding)
|
||||
:resize-type :bottom
|
||||
:resize-axis :y
|
||||
:resize-negate? (:p3 negate)}
|
||||
:p4 {:key (str frame-id "-p4")
|
||||
:x (if (:flip-x frame) (- x2 (:p4 padding)) x1)
|
||||
:y y1
|
||||
:width (:p4 padding)
|
||||
:height height
|
||||
:initial-value (:p4 padding)
|
||||
:resize-type (if (:flip-x frame) :right :left)
|
||||
:resize-axis :x
|
||||
:resize-negate? (:p4 negate)}}
|
||||
|
||||
on-pointer-enter
|
||||
(mf/use-fn
|
||||
(fn [hover-type val]
|
||||
(reset! hover hover-type)
|
||||
(reset! hover-value val)))
|
||||
|
||||
on-pointer-leave
|
||||
(mf/use-fn
|
||||
(fn []
|
||||
(reset! hover nil)))
|
||||
|
||||
on-change
|
||||
(mf/use-fn
|
||||
(fn [modifiers]
|
||||
(reset! current-modifiers modifiers)))
|
||||
|
||||
hover?
|
||||
(fn [value]
|
||||
(or hover-all?
|
||||
(and (or (= value :p1) (= value :p3)) hover-v?)
|
||||
(and (or (= value :p2) (= value :p4)) hover-h?)
|
||||
(= @hover value)))]
|
||||
|
||||
[:g.paddings {:pointer-events "visible"}
|
||||
(for [[padding-num rect-data] padding-rect-data]
|
||||
@@ -194,9 +258,11 @@
|
||||
:on-pointer-leave on-pointer-leave
|
||||
:on-move-selected on-move-selected
|
||||
:on-context-menu on-context-menu
|
||||
:on-change on-change
|
||||
:hover? (hover? padding-num)
|
||||
:selected? (get paddings-selected padding-num)
|
||||
:rect-data rect-data}])
|
||||
|
||||
(when @hover
|
||||
[:& fcc/flex-display-pill
|
||||
{:height pill-height
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
[:button {:class (stl/css :cta-button :bottom-link)
|
||||
:on-click cta-link-trial} cta-text-trial])])
|
||||
|
||||
(defn schema:seats-form [min-editors]
|
||||
(defn- make-management-form-schema [min-editors]
|
||||
[:map {:title "SeatsForm"}
|
||||
[:min-members [::sm/number {:min min-editors
|
||||
:max 9999}]]
|
||||
@@ -87,7 +87,6 @@
|
||||
{::mf/register modal/components
|
||||
::mf/register-as :management-dialog}
|
||||
[{:keys [subscription-type current-subscription editors subscribe-to-trial]}]
|
||||
|
||||
(let [unlimited-modal-step*
|
||||
(mf/use-state 1)
|
||||
|
||||
@@ -112,9 +111,12 @@
|
||||
{:min-members min-editors
|
||||
:redirect-to-payment-details false})
|
||||
|
||||
schema
|
||||
(mf/with-memo [min-editors]
|
||||
(make-management-form-schema min-editors))
|
||||
|
||||
form
|
||||
(fm/use-form :schema (schema:seats-form min-editors)
|
||||
:initial initial)
|
||||
(fm/use-form :schema schema :initial initial)
|
||||
|
||||
submit-in-progress
|
||||
(mf/use-ref false)
|
||||
@@ -334,11 +336,15 @@
|
||||
[:> raw-svg* {:id (if (= "light" (:theme profile)) "logo-subscription-light" "logo-subscription")}]]
|
||||
|
||||
[:div {:class (stl/css :modal-end)}
|
||||
[:div {:class (stl/css :modal-title)} (tr "subscription.settings.sucess.dialog.title" subscription-name)]
|
||||
[:div {:class (stl/css :modal-title)}
|
||||
(tr "subscription.settings.sucess.dialog.title" subscription-name)]
|
||||
(when (not= subscription-name "professional")
|
||||
[:p {:class (stl/css :modal-text-large)} (tr "subscription.settings.success.dialog.thanks" subscription-name)])
|
||||
[:p {:class (stl/css :modal-text-large)} (tr "subscription.settings.success.dialog.description")]
|
||||
[:p {:class (stl/css :modal-text-large)} (tr "subscription.settings.sucess.dialog.footer")]
|
||||
[:p {:class (stl/css :modal-text-large)}
|
||||
(tr "subscription.settings.success.dialog.thanks" subscription-name)])
|
||||
[:p {:class (stl/css :modal-text-large)}
|
||||
(tr "subscription.settings.success.dialog.description")]
|
||||
[:p {:class (stl/css :modal-text-large)}
|
||||
(tr "subscription.settings.sucess.dialog.footer")]
|
||||
|
||||
[:div {:class (stl/css :success-action-buttons)}
|
||||
[:input
|
||||
@@ -418,7 +424,11 @@
|
||||
(mf/with-effect []
|
||||
(dom/set-html-title (tr "subscription.labels")))
|
||||
|
||||
(mf/with-effect [authenticated? show-subscription-success-modal? show-trial-subscription-modal? success-modal-is-trial? subscription]
|
||||
(mf/with-effect [authenticated?
|
||||
show-subscription-success-modal?
|
||||
show-trial-subscription-modal?
|
||||
success-modal-is-trial?
|
||||
subscription]
|
||||
(when ^boolean authenticated?
|
||||
(cond
|
||||
^boolean show-trial-subscription-modal?
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
{::mf/wrap-props false}
|
||||
[props]
|
||||
(let [{:keys [position-data content] :as shape} (obj/get props "shape")
|
||||
is-render? (mf/use-ctx ctx/is-render?)
|
||||
is-component? (mf/use-ctx ctx/is-component?)]
|
||||
|
||||
(mf/with-memo [content]
|
||||
@@ -41,5 +42,5 @@
|
||||
;; Only use this for component preview, otherwise the dashboard thumbnails
|
||||
;; will give a tainted canvas error because the `foreignObject` cannot be
|
||||
;; rendered.
|
||||
(and (nil? position-data) is-component?)
|
||||
(and (nil? position-data) (or is-component? is-render?))
|
||||
[:> fo/text-shape props])))
|
||||
|
||||
@@ -12,18 +12,20 @@
|
||||
[app.main.features :as features]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.store :as st]
|
||||
[app.render-wasm.api :as wasm.api]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(mf/defc text-edition-outline
|
||||
[{:keys [shape zoom modifiers]}]
|
||||
(if (features/active-feature? @st/state "render-wasm/v1")
|
||||
(let [selrect-transform (mf/deref refs/workspace-selrect)
|
||||
[{:keys [x y width height]} transform] (dsh/get-selrect selrect-transform shape)]
|
||||
(let [{:keys [width height]} (wasm.api/get-text-dimensions (:id shape))
|
||||
selrect-transform (mf/deref refs/workspace-selrect)
|
||||
[selrect transform] (dsh/get-selrect selrect-transform shape)]
|
||||
[:rect.main.viewport-selrect
|
||||
{:x x
|
||||
:y y
|
||||
:width width
|
||||
:height height
|
||||
{:x (:x selrect)
|
||||
:y (:y selrect)
|
||||
:width (max width (:width selrect))
|
||||
:height (max height (:height selrect))
|
||||
:transform transform
|
||||
:style {:stroke "var(--color-accent-tertiary)"
|
||||
:stroke-width (/ 1 zoom)
|
||||
|
||||
@@ -320,10 +320,12 @@
|
||||
|
||||
[{:keys [x y width height]} transform]
|
||||
(if render-wasm?
|
||||
(let [{:keys [height]} (wasm.api/get-text-dimensions shape-id)
|
||||
(let [{:keys [width height]} (wasm.api/get-text-dimensions shape-id)
|
||||
selrect-transform (mf/deref refs/workspace-selrect)
|
||||
[selrect transform] (dsh/get-selrect selrect-transform shape)
|
||||
selrect-height (:height selrect)
|
||||
selrect-width (:width selrect)
|
||||
max-width (max width selrect-width)
|
||||
max-height (max height selrect-height)
|
||||
valign (-> shape :content :vertical-align)
|
||||
y (:y selrect)
|
||||
@@ -331,9 +333,9 @@
|
||||
(case valign
|
||||
"bottom" (- y (- height selrect-height))
|
||||
"center" (- y (/ (- height selrect-height) 2))
|
||||
"top" y)
|
||||
y)
|
||||
y)]
|
||||
[(assoc selrect :y y :width (:width selrect) :height max-height) transform])
|
||||
[(assoc selrect :y y :width max-width :height max-height) transform])
|
||||
|
||||
(let [bounds (gst/shape->rect shape)
|
||||
x (mth/min (dm/get-prop bounds :x)
|
||||
@@ -352,7 +354,7 @@
|
||||
(obj/merge!
|
||||
#js {"--editor-container-width" (dm/str width "px")
|
||||
"--editor-container-height" (dm/str height "px")
|
||||
"--fallback-families" (dm/str (str/join ", " fallback-families))})
|
||||
"--fallback-families" (if (seq fallback-families) (dm/str (str/join ", " fallback-families)) "sourcesanspro")})
|
||||
|
||||
(not render-wasm?)
|
||||
(obj/merge!
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.types.shape.radius :as ctsr]
|
||||
[app.common.types.token :as tk]
|
||||
[app.main.data.workspace.shapes :as dwsh]
|
||||
[app.main.data.workspace.tokens.application :as dwta]
|
||||
[app.main.features :as features]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.components.numeric-input :as deprecated-input]
|
||||
[app.main.ui.context :as muc]
|
||||
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
|
||||
[app.main.ui.ds.controls.numeric-input :refer [numeric-input*]]
|
||||
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
|
||||
[app.main.ui.hooks :as hooks]
|
||||
[app.util.i18n :as i18n :refer [tr]]
|
||||
@@ -21,11 +26,15 @@
|
||||
(defn- check-border-radius-menu-props
|
||||
[old-props new-props]
|
||||
(let [old-values (unchecked-get old-props "values")
|
||||
new-values (unchecked-get new-props "values")]
|
||||
new-values (unchecked-get new-props "values")
|
||||
old-applied-tokens (unchecked-get old-props "applied-tokens")
|
||||
new-applied-tokens (unchecked-get new-props "applied-tokens")]
|
||||
(and (identical? (unchecked-get old-props "class")
|
||||
(unchecked-get new-props "class"))
|
||||
(identical? (unchecked-get old-props "ids")
|
||||
(unchecked-get new-props "ids"))
|
||||
(identical? old-applied-tokens
|
||||
new-applied-tokens)
|
||||
(identical? (get old-values :r1)
|
||||
(get new-values :r1))
|
||||
(identical? (get old-values :r2)
|
||||
@@ -35,13 +44,64 @@
|
||||
(identical? (get old-values :r4)
|
||||
(get new-values :r4)))))
|
||||
|
||||
(mf/defc numeric-input-wrapper*
|
||||
{::mf/private true}
|
||||
[{:keys [values name applied-tokens align on-detach radius] :rest props}]
|
||||
(let [tokens (mf/use-ctx muc/active-tokens-by-type)
|
||||
tokens (mf/with-memo [tokens name]
|
||||
(delay
|
||||
(-> (deref tokens)
|
||||
(select-keys (get tk/tokens-by-input name))
|
||||
(not-empty))))
|
||||
on-detach-attr
|
||||
(mf/use-fn
|
||||
(mf/deps on-detach name)
|
||||
#(on-detach % name))
|
||||
|
||||
r1-value (get applied-tokens :r1)
|
||||
r2-value (get applied-tokens :r2)
|
||||
r3-value (get applied-tokens :r3)
|
||||
r4-value (get applied-tokens :r4)
|
||||
all-equal? (= r1-value r2-value r3-value r4-value)
|
||||
|
||||
applied-token (if (= :all radius)
|
||||
(if all-equal?
|
||||
r1-value
|
||||
:mixed)
|
||||
:mixed)
|
||||
|
||||
|
||||
props (mf/spread-props props
|
||||
{:placeholder (if (or (= :multiple (:applied-tokens values))
|
||||
(= :multiple (get values name)))
|
||||
(tr "settings.multiple") "--")
|
||||
:class (stl/css :numeric-input-measures)
|
||||
:applied-token applied-token
|
||||
:tokens (if (delay? tokens) @tokens tokens)
|
||||
:align align
|
||||
:on-detach on-detach-attr
|
||||
:value (get values name)})]
|
||||
[:> numeric-input* props]))
|
||||
|
||||
(mf/defc border-radius-menu*
|
||||
{::mf/wrap [#(mf/memo' % check-border-radius-menu-props)]}
|
||||
[{:keys [class ids values]}]
|
||||
(let [all-equal? (all-equal? values)
|
||||
[{:keys [class ids values applied-tokens]}]
|
||||
(let [token-numeric-inputs
|
||||
(features/use-feature "tokens/numeric-input")
|
||||
|
||||
all-equal? (all-equal? values)
|
||||
radius-expanded* (mf/use-state false)
|
||||
radius-expanded (deref radius-expanded*)
|
||||
|
||||
;; DETACH
|
||||
on-detach-token
|
||||
(mf/use-fn
|
||||
(mf/deps ids)
|
||||
(fn [token attr]
|
||||
(st/emit! (dwta/unapply-token {:token (first token)
|
||||
:attributes #{attr}
|
||||
:shape-ids ids}))))
|
||||
|
||||
change-radius
|
||||
(mf/use-fn
|
||||
(mf/deps ids)
|
||||
@@ -94,23 +154,39 @@
|
||||
|
||||
[:div {:class (dm/str class " " (stl/css :radius))}
|
||||
(if (not radius-expanded)
|
||||
[:div {:class (stl/css :radius-1)
|
||||
:title (tr "workspace.options.radius")}
|
||||
[:> icon* {:icon-id i/corner-radius
|
||||
:size "s"
|
||||
:class (stl/css :icon)}]
|
||||
[:> deprecated-input/numeric-input*
|
||||
{:placeholder (cond
|
||||
(not all-equal?)
|
||||
"Mixed"
|
||||
(= :multiple (:r1 values))
|
||||
(tr "settings.multiple")
|
||||
:else
|
||||
"--")
|
||||
:min 0
|
||||
:nillable true
|
||||
:on-change on-single-radius-change
|
||||
:value (if all-equal? (:r1 values) nil)}]]
|
||||
(if token-numeric-inputs
|
||||
[:div {:class (stl/css :radius-1)
|
||||
:title (tr "workspace.options.radius")}
|
||||
[:> numeric-input-wrapper*
|
||||
{:on-change on-single-radius-change
|
||||
:on-detach on-detach-token
|
||||
:icon i/corner-radius
|
||||
:min 0
|
||||
:name :border-radius
|
||||
:nillable true
|
||||
:property (tr "workspace.options.width")
|
||||
:applied-tokens applied-tokens
|
||||
:radius :all
|
||||
:values (if all-equal? (:r1 values) nil)}]]
|
||||
|
||||
[:div {:class (stl/css :radius-1)
|
||||
:title (tr "workspace.options.radius")}
|
||||
[:> icon* {:icon-id i/corner-radius
|
||||
:size "s"
|
||||
:class (stl/css :icon)}]
|
||||
|
||||
[:* [:> deprecated-input/numeric-input*
|
||||
{:placeholder (cond
|
||||
(not all-equal?)
|
||||
"Mixed"
|
||||
(= :multiple (:r1 values))
|
||||
(tr "settings.multiple")
|
||||
:else
|
||||
"--")
|
||||
:min 0
|
||||
:nillable true
|
||||
:on-change on-single-radius-change
|
||||
:value (if all-equal? (:r1 values) nil)}]]])
|
||||
|
||||
[:div {:class (stl/css :radius-4)}
|
||||
[:div {:class (stl/css :small-input)}
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
(nil? (get values name)))
|
||||
(tr "settings.multiple")
|
||||
"--")
|
||||
:class (stl/css :numeric-input-measures)
|
||||
:class (stl/css :numeric-input-layout)
|
||||
:applied-token (get applied-tokens name)
|
||||
:tokens tokens
|
||||
:align align
|
||||
|
||||
@@ -433,6 +433,6 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.numeric-input-measures {
|
||||
.numeric-input-layout {
|
||||
--dropdown-width: var(--7-columns-dropdown-width);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
[app.main.ui.components.numeric-input :as deprecated-input]
|
||||
[app.main.ui.components.radio-buttons :refer [radio-button radio-buttons]]
|
||||
[app.main.ui.components.title-bar :refer [title-bar*]]
|
||||
[app.main.ui.ds.foundations.assets.icon :as i]
|
||||
[app.main.ui.icons :as deprecated-icon]
|
||||
[app.main.ui.workspace.sidebar.options.menus.layout-container :refer [get-layout-flex-icon]]
|
||||
[app.util.dom :as dom]
|
||||
@@ -234,20 +235,20 @@
|
||||
|
||||
[:& radio-button
|
||||
{:value "fix"
|
||||
:icon deprecated-icon/fixed-width
|
||||
:icon i/fixed-width
|
||||
:title "Fix width"
|
||||
:id "behaviour-h-fix"}]
|
||||
|
||||
(when has-fill
|
||||
[:& radio-button
|
||||
{:value "fill"
|
||||
:icon deprecated-icon/fill-content
|
||||
:icon i/fill-content
|
||||
:title "Width 100%"
|
||||
:id "behaviour-h-fill"}])
|
||||
(when is-auto
|
||||
[:& radio-button
|
||||
{:value "auto"
|
||||
:icon deprecated-icon/hug-content
|
||||
:icon i/hug-content
|
||||
:title "Fit content (Horizontal)"
|
||||
:id "behaviour-h-auto"}])]])
|
||||
|
||||
@@ -268,7 +269,7 @@
|
||||
|
||||
[:& radio-button
|
||||
{:value "fix"
|
||||
:icon deprecated-icon/fixed-width
|
||||
:icon i/fixed-width
|
||||
:icon-class (stl/css :rotated)
|
||||
:title "Fix height"
|
||||
:id "behaviour-v-fix"}]
|
||||
@@ -276,14 +277,14 @@
|
||||
(when has-fill
|
||||
[:& radio-button
|
||||
{:value "fill"
|
||||
:icon deprecated-icon/fill-content
|
||||
:icon i/fill-content
|
||||
:icon-class (stl/css :rotated)
|
||||
:title "Height 100%"
|
||||
:id "behaviour-v-fill"}])
|
||||
(when is-auto
|
||||
[:& radio-button
|
||||
{:value "auto"
|
||||
:icon deprecated-icon/hug-content
|
||||
:icon i/hug-content
|
||||
:icon-class (stl/css :rotated)
|
||||
:title "Fit content (Vertical)"
|
||||
:id "behaviour-v-auto"}])]])
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
@use "refactor/common-refactor.scss" as deprecated;
|
||||
@use "../../../sidebar/common/sidebar.scss" as sidebar;
|
||||
@use "ds/_utils.scss" as *;
|
||||
|
||||
.element-set {
|
||||
display: grid;
|
||||
@@ -188,7 +189,6 @@
|
||||
@extend .button-icon;
|
||||
}
|
||||
|
||||
// TODO: Add a proper variable to this sizing
|
||||
.numeric-input-measures {
|
||||
--dropdown-width: var(--7-columns-dropdown-width);
|
||||
}
|
||||
|
||||
@@ -379,6 +379,7 @@
|
||||
:step 0.1
|
||||
:default-value "1.2"
|
||||
:class (stl/css :line-height-input)
|
||||
:aria-label (tr "inspect.attributes.typography.line-height")
|
||||
:value (attr->string line-height)
|
||||
:placeholder (if (= :multiple line-height) (tr "settings.multiple") "--")
|
||||
:nillable (= :multiple line-height)
|
||||
@@ -397,6 +398,7 @@
|
||||
:step 0.1
|
||||
:default-value "0"
|
||||
:class (stl/css :letter-spacing-input)
|
||||
:aria-label (tr "inspect.attributes.typography.letter-spacing")
|
||||
:value (attr->string letter-spacing)
|
||||
:placeholder (if (= :multiple letter-spacing) (tr "settings.multiple") "--")
|
||||
:on-change #(handle-change % :letter-spacing)
|
||||
|
||||
@@ -44,6 +44,39 @@
|
||||
[(seq (array/sort! empty))
|
||||
(seq (array/sort! filled))]))))
|
||||
|
||||
(mf/defc selected-set-info*
|
||||
{::mf/private true}
|
||||
[{:keys [tokens-lib selected-token-set-id]}]
|
||||
(let [selected-token-set
|
||||
(mf/with-memo [tokens-lib]
|
||||
(when selected-token-set-id
|
||||
(some-> tokens-lib (ctob/get-set selected-token-set-id))))
|
||||
|
||||
active-token-sets-names
|
||||
(mf/with-memo [tokens-lib]
|
||||
(some-> tokens-lib (ctob/get-active-themes-set-names)))
|
||||
|
||||
token-set-active?
|
||||
(mf/use-fn
|
||||
(mf/deps active-token-sets-names)
|
||||
(fn [name]
|
||||
(contains? active-token-sets-names name)))]
|
||||
[:div {:class (stl/css :sets-header-container)}
|
||||
[:> text* {:as "span"
|
||||
:typography "headline-small"
|
||||
:class (stl/css :sets-header)}
|
||||
(tr "workspace.tokens.tokens-section-title" (ctob/get-name selected-token-set))]
|
||||
[:div {:class (stl/css :sets-header-status) :title (tr "workspace.tokens.inactive-set-description")}
|
||||
;; NOTE: when no set in tokens-lib, the selected-token-set-id
|
||||
;; will be `nil`, so for properly hide the inactive message we
|
||||
;; check that at least `selected-token-set-id` has a value
|
||||
(when (and (some? selected-token-set-id)
|
||||
(not (token-set-active? (ctob/get-name selected-token-set))))
|
||||
[:*
|
||||
[:> icon* {:class (stl/css :sets-header-status-icon) :icon-id i/eye-off}]
|
||||
[:> text* {:as "span" :typography "body-small" :class (stl/css :sets-header-status-text)}
|
||||
(tr "workspace.tokens.inactive-set")]])]]))
|
||||
|
||||
(mf/defc tokens-section*
|
||||
{::mf/private true}
|
||||
[{:keys [tokens-lib active-tokens resolved-active-tokens]}]
|
||||
@@ -65,9 +98,7 @@
|
||||
selected-token-set-id
|
||||
(mf/deref refs/selected-token-set-id)
|
||||
|
||||
selected-token-set
|
||||
(when selected-token-set-id
|
||||
(some-> tokens-lib (ctob/get-set selected-token-set-id)))
|
||||
|
||||
|
||||
;; If we have not selected any set explicitly we just
|
||||
;; select the first one from the list of sets
|
||||
@@ -92,15 +123,9 @@
|
||||
tokens)]
|
||||
(ctob/group-by-type tokens)))
|
||||
|
||||
active-token-sets-names
|
||||
(mf/with-memo [tokens-lib]
|
||||
(some-> tokens-lib (ctob/get-active-themes-set-names)))
|
||||
|
||||
token-set-active?
|
||||
(mf/use-fn
|
||||
(mf/deps active-token-sets-names)
|
||||
(fn [name]
|
||||
(contains? active-token-sets-names name)))
|
||||
|
||||
|
||||
|
||||
[empty-group filled-group]
|
||||
(mf/with-memo [tokens-by-type]
|
||||
@@ -118,34 +143,27 @@
|
||||
|
||||
[:*
|
||||
[:& token-context-menu]
|
||||
[:div {:class (stl/css :sets-header-container)}
|
||||
[:> text* {:as "span" :typography "headline-small" :class (stl/css :sets-header)} (tr "workspace.tokens.tokens-section-title" (ctob/get-name selected-token-set))]
|
||||
[:div {:class (stl/css :sets-header-status) :title (tr "workspace.tokens.inactive-set-description")}
|
||||
;; NOTE: when no set in tokens-lib, the selected-token-set-id
|
||||
;; will be `nil`, so for properly hide the inactive message we
|
||||
;; check that at least `selected-token-set-id` has a value
|
||||
(when (and (some? selected-token-set-id)
|
||||
(not (token-set-active? (ctob/get-name selected-token-set))))
|
||||
[:*
|
||||
[:> icon* {:class (stl/css :sets-header-status-icon) :icon-id i/eye-off}]
|
||||
[:> text* {:as "span" :typography "body-small" :class (stl/css :sets-header-status-text)}
|
||||
(tr "workspace.tokens.inactive-set")]])]]
|
||||
|
||||
[:& selected-set-info* {:tokens-lib tokens-lib
|
||||
:selected-token-set-id selected-token-set-id}]
|
||||
|
||||
(for [type filled-group]
|
||||
(let [tokens (get tokens-by-type type)]
|
||||
[:> token-group* {:key (name type)
|
||||
:is-open (get open-status type false)
|
||||
:tokens tokens
|
||||
:is-expanded (get open-status type false)
|
||||
:type type
|
||||
:selected-ids selected
|
||||
:selected-shapes selected-shapes
|
||||
:is-selected-inside-layout is-selected-inside-layout
|
||||
:active-theme-tokens resolved-active-tokens
|
||||
:tokens tokens}]))
|
||||
:tokens-lib tokens-lib
|
||||
:selected-token-set-id selected-token-set-id}]))
|
||||
|
||||
(for [type empty-group]
|
||||
[:> token-group* {:key (name type)
|
||||
:tokens []
|
||||
:type type
|
||||
:selected-shapes selected-shapes
|
||||
:is-selected-inside-layout :is-selected-inside-layout
|
||||
:active-theme-tokens resolved-active-tokens
|
||||
:tokens []}])]))
|
||||
:is-selected-inside-layout is-selected-inside-layout
|
||||
:active-theme-tokens resolved-active-tokens}])]))
|
||||
|
||||
@@ -43,8 +43,8 @@
|
||||
;; 2) Indexed Color Input
|
||||
;; - Used when the token’s value stores an array of items (e.g. inside
|
||||
;; shadows, where each shadow layer has its own :color field).
|
||||
;; - The input writes to a nested subfield:
|
||||
;; [:value <subfield> <index> :color]
|
||||
;; - The input writes to a nested value-subfield:
|
||||
;; [:value <value-subfield> <index> :color]
|
||||
;; - Only that specific color entry is validated.
|
||||
;; - Other properties (offsets, blur, inset, etc.) remain untouched.
|
||||
;;
|
||||
@@ -436,19 +436,21 @@
|
||||
|
||||
(some? error)
|
||||
(let [error' (:message error)]
|
||||
(swap! form assoc-in [:extra-errors :value value-subfield index input-name] {:message error'})
|
||||
(swap! form assoc-in [:data :value value-subfield index :color-result] "")
|
||||
(reset! hint* {:message error' :type "error"}))
|
||||
(do
|
||||
(swap! form assoc-in [:extra-errors :value value-subfield index input-name] {:message error'})
|
||||
(swap! form assoc-in [:data :value value-subfield index :color-result] "")
|
||||
(reset! hint* {:message error' :type "error"})))
|
||||
|
||||
:else
|
||||
(let [message (tr "workspace.tokens.resolved-value" (dwtf/format-token-value value))
|
||||
input-value (get-in @form [:data :value value-subfield index input-name] "")]
|
||||
(swap! form update :errors dissoc :value)
|
||||
(swap! form update :extra-errors dissoc :value)
|
||||
(swap! form assoc-in [:data :value value-subfield index :color-result] (dwtf/format-token-value value))
|
||||
(if (= input-value (str value))
|
||||
(reset! hint* {})
|
||||
(reset! hint* {:message message :type "hint"})))))))]
|
||||
(do
|
||||
(swap! form update :errors dissoc :value)
|
||||
(swap! form update :extra-errors dissoc :value)
|
||||
(swap! form assoc-in [:data :value value-subfield index :color-result] (dwtf/format-token-value value))
|
||||
(if (= input-value (str value))
|
||||
(reset! hint* {})
|
||||
(reset! hint* {:message message :type "hint"}))))))))]
|
||||
(fn []
|
||||
(rx/dispose! subs))))
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
;; 2) Composite Font Picker
|
||||
;; - Used inside typography tokens, where `:value` is a map (e.g. contains
|
||||
;; :font-family, :font-weight, :letter-spacing, etc.).
|
||||
;; - The input writes to the specific subfield `[:value :font-family]`.
|
||||
;; - The input writes to the specific value-subfield `[:value :font-family]`.
|
||||
;; - Only this field is validated and updated—other typography fields remain
|
||||
;; untouched.
|
||||
;;
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
;; 2) COMPOSITE INPUTS
|
||||
;; ----------------------------------------------------------
|
||||
;; Used when the token contains a set of *named fields* inside :value.
|
||||
;; The UI must write into a specific subfield inside the :value map.
|
||||
;; The UI must write into a specific value-subfield inside the :value map.
|
||||
;;
|
||||
;; Example: typography tokens
|
||||
;; {:value {:font-family "Inter"
|
||||
@@ -64,7 +64,7 @@
|
||||
;; * Validation rules apply per-field.
|
||||
;;
|
||||
;; In practice:
|
||||
;; - The component knows which subfield to update.
|
||||
;; - The component knows which value-subfield to update.
|
||||
;; - The form accumulates multiple fields into a single map under :value.
|
||||
;;
|
||||
;;
|
||||
|
||||
@@ -13,10 +13,10 @@
|
||||
;; --- Select Input (Indexed) --------------------------------------------------
|
||||
;;
|
||||
;; This input type is part of the indexed system, used for fields that exist
|
||||
;; inside an array of maps stored in a subfield of :value.
|
||||
;; inside an array of maps stored in a value-subfield of :value.
|
||||
;;
|
||||
;; - Writes to a nested location:
|
||||
;; [:value <subfield> <index> <field>]
|
||||
;; [:value <value-subfield> <index> <field>]
|
||||
;; - Each item in the array has its own select input, independent of others.
|
||||
;; - Validation ensures the selected value is valid for that field.
|
||||
;; - Changing one item does not affect the other items in the array.
|
||||
|
||||
@@ -230,16 +230,29 @@
|
||||
{:level :warning :appearance :ghost} (tr "workspace.tokens.warning-name-change")]])]
|
||||
|
||||
[:div {:class (stl/css :input-row)}
|
||||
[:> input-component
|
||||
{:placeholder (or input-value-placeholder (tr "workspace.tokens.token-value-enter"))
|
||||
:label (tr "workspace.tokens.token-value")
|
||||
:name :value
|
||||
:form form
|
||||
:token token
|
||||
:tokens tokens
|
||||
:tab active-tab
|
||||
:subfield value-subfield
|
||||
:toggle on-toggle-tab}]]
|
||||
(case type
|
||||
:indexed
|
||||
[:> input-component
|
||||
{:token token
|
||||
:tokens tokens
|
||||
:tab active-tab
|
||||
:value-subfield value-subfield
|
||||
:handle-toggle on-toggle-tab}]
|
||||
|
||||
:composite
|
||||
[:> input-component
|
||||
{:token token
|
||||
:tokens tokens
|
||||
:tab active-tab
|
||||
:handle-toggle on-toggle-tab}]
|
||||
|
||||
[:> input-component
|
||||
{:placeholder (or input-value-placeholder
|
||||
(tr "workspace.tokens.token-value-enter"))
|
||||
:label (tr "workspace.tokens.token-value")
|
||||
:name :value
|
||||
:token token
|
||||
:tokens tokens}])]
|
||||
|
||||
[:div {:class (stl/css :input-row)}
|
||||
[:> fc/form-input* {:id "token-description"
|
||||
|
||||
@@ -208,19 +208,20 @@
|
||||
:tokens tokens}]])
|
||||
|
||||
(mf/defc tabs-wrapper*
|
||||
[{:keys [token tokens form tab toggle subfield] :rest props}]
|
||||
(let [on-add-shadow-block
|
||||
[{:keys [token tokens tab handle-toggle value-subfield] :rest props}]
|
||||
(let [form (mf/use-ctx forms/context)
|
||||
on-add-shadow-block
|
||||
(mf/use-fn
|
||||
(mf/deps subfield)
|
||||
(mf/deps value-subfield)
|
||||
(fn []
|
||||
(swap! form update-in [:data :value subfield] conj default-token-shadow)))
|
||||
(swap! form update-in [:data :value value-subfield] conj default-token-shadow)))
|
||||
|
||||
remove-shadow-block
|
||||
(mf/use-fn
|
||||
(mf/deps subfield)
|
||||
(mf/deps value-subfield)
|
||||
(fn [index event]
|
||||
(dom/prevent-default event)
|
||||
(swap! form update-in [:data :value subfield] #(d/remove-at-index % index))))]
|
||||
(swap! form update-in [:data :value value-subfield] #(d/remove-at-index % index))))]
|
||||
|
||||
[:*
|
||||
[:div {:class (stl/css :title-bar)}
|
||||
@@ -232,7 +233,7 @@
|
||||
:icon i/add}]
|
||||
[:& radio-buttons {:class (stl/css :listing-options)
|
||||
:selected (d/name tab)
|
||||
:on-change toggle
|
||||
:on-change handle-toggle
|
||||
:name "reference-composite-tab"}
|
||||
[:& radio-button {:icon i/layers
|
||||
:value "composite"
|
||||
@@ -247,7 +248,7 @@
|
||||
[:> composite-form* {:token token
|
||||
:tokens tokens
|
||||
:remove-shadow-block remove-shadow-block
|
||||
:value-subfield subfield}]
|
||||
:value-subfield value-subfield}]
|
||||
|
||||
[:> reference-form* {:token token
|
||||
:tokens tokens}])]))
|
||||
|
||||
@@ -181,13 +181,13 @@
|
||||
:tokens tokens}]])
|
||||
|
||||
(mf/defc tabs-wrapper*
|
||||
[{:keys [token tokens tab toggle] :rest props}]
|
||||
[{:keys [token tokens tab handle-toggle] :rest props}]
|
||||
[:*
|
||||
[:div {:class (stl/css :title-bar)}
|
||||
[:div {:class (stl/css :title)} (tr "labels.typography")]
|
||||
[:& radio-buttons {:class (stl/css :listing-options)
|
||||
:selected (d/name tab)
|
||||
:on-change toggle
|
||||
:on-change handle-toggle
|
||||
:name "reference-composite-tab"}
|
||||
[:& radio-button {:icon i/layers
|
||||
:value "composite"
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
(ns app.main.ui.workspace.tokens.management.group
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.types.tokens-lib :as ctob]
|
||||
[app.main.data.modal :as modal]
|
||||
[app.main.data.workspace.tokens.application :as dwta]
|
||||
[app.main.data.workspace.tokens.library-edit :as dwtl]
|
||||
@@ -16,51 +19,70 @@
|
||||
[app.main.ui.context :as ctx]
|
||||
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
|
||||
[app.main.ui.ds.foundations.assets.icon :as i]
|
||||
[app.main.ui.workspace.sidebar.assets.common :as cmm]
|
||||
[app.main.ui.workspace.tokens.management.token-pill :refer [token-pill*]]
|
||||
[app.main.ui.ds.layers.layer-button :refer [layer-button*]]
|
||||
[app.main.ui.workspace.tokens.management.token-tree :refer [token-tree*]]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.i18n :refer [tr]]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
|
||||
(defn token-section-icon
|
||||
[type]
|
||||
(case type
|
||||
:border-radius "corner-radius"
|
||||
:color "drop"
|
||||
:boolean "boolean-difference"
|
||||
:font-family "text-font-family"
|
||||
:font-size "text-font-size"
|
||||
:letter-spacing "text-letterspacing"
|
||||
:text-case "text-mixed"
|
||||
:text-decoration "text-underlined"
|
||||
:font-weight "text-font-weight"
|
||||
:typography "text-typography"
|
||||
:opacity "percentage"
|
||||
:number "number"
|
||||
:rotation "rotation"
|
||||
:spacing "padding-extended"
|
||||
:string "text-mixed"
|
||||
:stroke-width "stroke-size"
|
||||
:dimensions "expand"
|
||||
:sizing "expand"
|
||||
:shadow "drop-shadow"
|
||||
:border-radius i/corner-radius
|
||||
:color i/drop
|
||||
:boolean i/boolean-difference
|
||||
:font-family i/text-font-family
|
||||
:font-size i/text-font-size
|
||||
:letter-spacing i/text-letterspacing
|
||||
:text-case i/text-mixed
|
||||
:text-decoration i/text-underlined
|
||||
:font-weight i/text-font-weight
|
||||
:typography i/text-typography
|
||||
:opacity i/percentage
|
||||
:number i/number
|
||||
:rotation i/rotation
|
||||
:spacing i/padding-extended
|
||||
:string i/text-mixed
|
||||
:stroke-width i/stroke-size
|
||||
:dimensions i/expand
|
||||
:sizing i/expand
|
||||
:shadow i/drop-shadow
|
||||
"add"))
|
||||
|
||||
(def ^:private schema:token-group
|
||||
[:map
|
||||
[:type :keyword]
|
||||
[:tokens :any]
|
||||
[:selected-shapes :any]
|
||||
[:is-selected-inside-layout {:optional true} [:maybe :boolean]]
|
||||
[:active-theme-tokens {:optional true} :any]
|
||||
[:selected-token-set-id {:optional true} :any]
|
||||
[:tokens-lib {:optional true} :any]
|
||||
[:on-token-pill-click {:optional true} fn?]
|
||||
[:on-context-menu {:optional true} fn?]])
|
||||
|
||||
(mf/defc token-group*
|
||||
{::mf/private true}
|
||||
[{:keys [type tokens selected-shapes is-selected-inside-layout active-theme-tokens is-open selected-ids]}]
|
||||
{::mf/schema schema:token-group}
|
||||
[{:keys [type tokens selected-shapes is-selected-inside-layout active-theme-tokens selected-token-set-id tokens-lib is-expanded selected-ids]}]
|
||||
(let [{:keys [modal title]}
|
||||
(get dwta/token-properties type)
|
||||
editing-ref (mf/deref refs/workspace-editor-state)
|
||||
not-editing? (empty? editing-ref)
|
||||
|
||||
is-expanded (d/nilv is-expanded false)
|
||||
|
||||
can-edit?
|
||||
(mf/use-ctx ctx/can-edit?)
|
||||
|
||||
is-selected-inside-layout (d/nilv is-selected-inside-layout false)
|
||||
|
||||
tokens
|
||||
(mf/with-memo [tokens]
|
||||
(vec (sort-by :name tokens)))
|
||||
|
||||
expandable? (d/nilv (seq tokens) false)
|
||||
|
||||
on-context-menu
|
||||
(mf/use-fn
|
||||
(fn [event token]
|
||||
@@ -73,8 +95,8 @@
|
||||
|
||||
on-toggle-open-click
|
||||
(mf/use-fn
|
||||
(mf/deps is-open type)
|
||||
#(st/emit! (dwtl/set-token-type-section-open type (not is-open))))
|
||||
(mf/deps is-expanded type)
|
||||
#(st/emit! (dwtl/set-token-type-section-open type (not is-expanded))))
|
||||
|
||||
on-popover-open-click
|
||||
(mf/use-fn
|
||||
@@ -96,33 +118,36 @@
|
||||
(mf/use-fn
|
||||
(mf/deps not-editing? selected-ids)
|
||||
(fn [event token]
|
||||
(dom/stop-propagation event)
|
||||
(when (and not-editing? (seq selected-shapes) (not= (:type token) :number))
|
||||
(st/emit! (dwta/toggle-token {:token token
|
||||
:shape-ids selected-ids})))))]
|
||||
(let [token (ctob/get-token tokens-lib selected-token-set-id (:id token))]
|
||||
(dom/stop-propagation event)
|
||||
(when (and not-editing? (seq selected-shapes) (not= (:type token) :number))
|
||||
(st/emit! (dwta/toggle-token {:token token
|
||||
:shape-ids selected-ids}))))))]
|
||||
|
||||
[:div {:on-click on-toggle-open-click :class (stl/css :token-section-wrapper)}
|
||||
[:> cmm/asset-section* {:icon (token-section-icon type)
|
||||
:title title
|
||||
:section :tokens
|
||||
:assets-count (count tokens)
|
||||
:is-open is-open}
|
||||
[:> cmm/asset-section-block* {:role :title-button}
|
||||
(when can-edit?
|
||||
[:> icon-button* {:on-click on-popover-open-click
|
||||
:variant "ghost"
|
||||
:icon i/add
|
||||
:id (str "add-token-button-" title)
|
||||
:aria-label (tr "workspace.tokens.add-token" title)}])]
|
||||
(when is-open
|
||||
[:> cmm/asset-section-block* {:role :content}
|
||||
[:div {:class (stl/css :token-pills-wrapper)}
|
||||
(for [token tokens]
|
||||
[:> token-pill*
|
||||
{:key (:name token)
|
||||
:token token
|
||||
:selected-shapes selected-shapes
|
||||
:is-selected-inside-layout is-selected-inside-layout
|
||||
:active-theme-tokens active-theme-tokens
|
||||
:on-click on-token-pill-click
|
||||
:on-context-menu on-context-menu}])]])]]))
|
||||
[:div {:class (stl/css :token-section-wrapper)
|
||||
:data-testid (dm/str "section-" (name type))}
|
||||
[:> layer-button* {:label title
|
||||
:expanded is-expanded
|
||||
:description (when expandable? (dm/str (count tokens)))
|
||||
:is-expandable expandable?
|
||||
:aria-expanded is-expanded
|
||||
:aria-controls (dm/str "token-tree-" (name type))
|
||||
:on-toggle-expand on-toggle-open-click
|
||||
:icon (token-section-icon type)}
|
||||
(when can-edit?
|
||||
[:> icon-button* {:id (str "add-token-button-" title)
|
||||
:icon "add"
|
||||
:aria-label (tr "workspace.tokens.add-token" title)
|
||||
:variant "ghost"
|
||||
:on-click on-popover-open-click
|
||||
:class (stl/css :token-section-icon)}])]
|
||||
(when is-expanded
|
||||
[:> token-tree* {:tokens tokens
|
||||
:id (dm/str "token-tree-" (name type))
|
||||
:tokens-lib tokens-lib
|
||||
:selected-shapes selected-shapes
|
||||
:active-theme-tokens active-theme-tokens
|
||||
:selected-token-set-id selected-token-set-id
|
||||
:is-selected-inside-layout is-selected-inside-layout
|
||||
:on-token-pill-click on-token-pill-click
|
||||
:on-context-menu on-context-menu}])]))
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) KALEIDOS INC
|
||||
|
||||
.token-pills-wrapper {
|
||||
display: flex;
|
||||
gap: var(--sp-xs);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@@ -307,10 +307,9 @@
|
||||
:class (stl/css :token-pill-icon)}])
|
||||
|
||||
(if contains-path?
|
||||
(let [[first-part last-part] (cpn/split-by-last-period name)]
|
||||
(let [[_ last-part] (cpn/split-by-last-period name)]
|
||||
[:span {:class (stl/css :divided-name-wrapper)
|
||||
:aria-label name}
|
||||
[:span {:class (stl/css :first-name-wrapper)} first-part]
|
||||
[:span {:class (stl/css :last-name-wrapper)} last-part]])
|
||||
[:span {:class (stl/css :name-wrapper)
|
||||
:aria-label name}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns app.main.ui.workspace.tokens.management.token-tree
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.path-names :as cpn]
|
||||
[app.common.types.tokens-lib :as ctob]
|
||||
[app.main.ui.ds.layers.layer-button :refer [layer-button*]]
|
||||
[app.main.ui.workspace.tokens.management.token-pill :refer [token-pill*]]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(def ^:private schema:folder-node
|
||||
[:map
|
||||
[:node :any]
|
||||
[:selected-shapes :any]
|
||||
[:is-selected-inside-layout {:optional true} :boolean]
|
||||
[:active-theme-tokens {:optional true} :any]
|
||||
[:selected-token-set-id {:optional true} :any]
|
||||
[:tokens-lib {:optional true} :any]
|
||||
[:on-token-pill-click {:optional true} fn?]
|
||||
[:on-context-menu {:optional true} fn?]])
|
||||
|
||||
(mf/defc folder-node*
|
||||
{::mf/schema schema:folder-node}
|
||||
[{:keys [node selected-shapes is-selected-inside-layout active-theme-tokens selected-token-set-id tokens-lib on-token-pill-click on-context-menu]}]
|
||||
(let [expanded* (mf/use-state false)
|
||||
expanded (deref expanded*)
|
||||
swap-folder-expanded #(swap! expanded* not)]
|
||||
[:li {:class (stl/css :folder-node)}
|
||||
[:> layer-button* {:label (:name node)
|
||||
:expanded expanded
|
||||
:aria-expanded expanded
|
||||
:aria-controls (str "folder-children-" (:path node))
|
||||
:is-expandable (not (:leaf node))
|
||||
:on-toggle-expand swap-folder-expanded}]
|
||||
(when expanded
|
||||
(let [children-fn (:children-fn node)]
|
||||
[:div {:class (stl/css :folder-children-wrapper)
|
||||
:id (str "folder-children-" (:path node))}
|
||||
(when children-fn
|
||||
(let [children (children-fn)]
|
||||
(for [child children]
|
||||
(if (not (:leaf child))
|
||||
[:ul {:class (stl/css :node-parent)}
|
||||
[:> folder-node* {:key (:path child)
|
||||
:node child
|
||||
:selected-shapes selected-shapes
|
||||
:is-selected-inside-layout is-selected-inside-layout
|
||||
:active-theme-tokens active-theme-tokens
|
||||
:on-token-pill-click on-token-pill-click
|
||||
:on-context-menu on-context-menu
|
||||
:tokens-lib tokens-lib
|
||||
:selected-token-set-id selected-token-set-id}]]
|
||||
(let [id (:id (:leaf child))
|
||||
token (ctob/get-token tokens-lib selected-token-set-id id)]
|
||||
[:> token-pill*
|
||||
{:key id
|
||||
:token token
|
||||
:selected-shapes selected-shapes
|
||||
:is-selected-inside-layout is-selected-inside-layout
|
||||
:active-theme-tokens active-theme-tokens
|
||||
:on-click on-token-pill-click
|
||||
:on-context-menu on-context-menu}])))))]))]))
|
||||
|
||||
(def ^:private schema:token-tree
|
||||
[:map
|
||||
[:tokens :any]
|
||||
[:selected-shapes :any]
|
||||
[:is-selected-inside-layout {:optional true} :boolean]
|
||||
[:active-theme-tokens {:optional true} :any]
|
||||
[:selected-token-set-id {:optional true} :any]
|
||||
[:tokens-lib {:optional true} :any]
|
||||
[:on-token-pill-click {:optional true} fn?]
|
||||
[:on-context-menu {:optional true} fn?]])
|
||||
|
||||
(mf/defc token-tree*
|
||||
{::mf/schema schema:token-tree}
|
||||
[{:keys [tokens selected-shapes is-selected-inside-layout active-theme-tokens tokens-lib selected-token-set-id on-token-pill-click on-context-menu]}]
|
||||
(let [separator "."
|
||||
tree (mf/use-memo
|
||||
(mf/deps tokens)
|
||||
(fn []
|
||||
(cpn/build-tree-root tokens separator)))]
|
||||
[:div {:class (stl/css :token-tree-wrapper)}
|
||||
(for [node tree]
|
||||
[:ul {:class (stl/css :node-parent)
|
||||
:key (:path node)
|
||||
:style {:--node-depth (inc (:depth node))}}
|
||||
(if (:leaf node)
|
||||
(let [token (ctob/get-token tokens-lib selected-token-set-id (get-in node [:leaf :id]))]
|
||||
[:> token-pill*
|
||||
{:token token
|
||||
:selected-shapes selected-shapes
|
||||
:is-selected-inside-layout is-selected-inside-layout
|
||||
:active-theme-tokens active-theme-tokens
|
||||
:on-click on-token-pill-click
|
||||
:on-context-menu on-context-menu}])
|
||||
;; Render segment folder
|
||||
[:> folder-node* {:node node
|
||||
:selected-shapes selected-shapes
|
||||
:is-selected-inside-layout is-selected-inside-layout
|
||||
:active-theme-tokens active-theme-tokens
|
||||
:on-token-pill-click on-token-pill-click
|
||||
:on-context-menu on-context-menu
|
||||
:tokens-lib tokens-lib
|
||||
:selected-token-set-id selected-token-set-id}])])]))
|
||||
@@ -0,0 +1,39 @@
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) KALEIDOS INC
|
||||
|
||||
@use "ds/_borders.scss" as *;
|
||||
|
||||
.token-tree-wrapper {
|
||||
padding-block-end: var(--sp-s);
|
||||
}
|
||||
|
||||
.node-parent {
|
||||
--node-spacing: var(--sp-l);
|
||||
--node-depth: 0;
|
||||
|
||||
margin-block-end: 0;
|
||||
padding-inline-start: calc(var(--node-spacing) * var(--node-depth));
|
||||
}
|
||||
|
||||
.folder-children-wrapper:has(> button) {
|
||||
margin-inline-start: var(--sp-s);
|
||||
padding-inline-start: var(--sp-s);
|
||||
border-inline-start: $b-2 solid var(--color-background-quaternary);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
column-gap: var(--sp-xs);
|
||||
|
||||
& .node-parent {
|
||||
flex: 1 0 100%;
|
||||
|
||||
&:last-of-type {
|
||||
margin-block-end: var(--sp-s);
|
||||
}
|
||||
}
|
||||
& .token-pill {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
padding: deprecated.$s-8 deprecated.$s-16;
|
||||
border-radius: deprecated.$s-8;
|
||||
border: deprecated.$s-2 solid var(--panel-border-color);
|
||||
z-index: deprecated.$z-index-3;
|
||||
z-index: deprecated.$z-index-1;
|
||||
background-color: var(--color-background-primary);
|
||||
transition:
|
||||
top 0.3s,
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
[app.main.data.workspace.grid-layout.editor :as dwge]
|
||||
[app.main.data.workspace.modifiers :as dwm]
|
||||
[app.main.data.workspace.shape-layout :as dwsl]
|
||||
[app.main.data.workspace.transforms :as dwt]
|
||||
[app.main.features :as features]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.store :as st]
|
||||
@@ -257,7 +258,8 @@
|
||||
(let [modifiers (calculate-drag-modifiers position)
|
||||
modif-tree (dwm/create-modif-tree [(:id shape)] modifiers)]
|
||||
(when on-clear-modifiers (on-clear-modifiers modifiers))
|
||||
(st/emit! (dwm/apply-wasm-modifiers modif-tree)))
|
||||
(st/emit! (dwm/apply-wasm-modifiers modif-tree)
|
||||
(dwt/finish-transform)))
|
||||
(st/emit! (dwm/apply-modifiers)))))
|
||||
|
||||
{:keys [handle-pointer-down handle-lost-pointer-capture handle-pointer-move]}
|
||||
@@ -506,7 +508,8 @@
|
||||
(let [modifiers (calculate-modifiers position)
|
||||
modif-tree (dwm/create-modif-tree [(:id shape)] modifiers)]
|
||||
(when on-clear-modifiers (on-clear-modifiers))
|
||||
(st/emit! (dwm/apply-wasm-modifiers modif-tree)))
|
||||
(st/emit! (dwm/apply-wasm-modifiers modif-tree)
|
||||
(dwt/finish-transform)))
|
||||
(st/emit! (dwm/apply-modifiers)))
|
||||
(reset! start-size-before nil)
|
||||
(reset! start-size-after nil)))]
|
||||
|
||||
@@ -54,15 +54,11 @@
|
||||
[app.util.debug :as dbg]
|
||||
[app.util.text-editor :as ted]
|
||||
[beicon.v2.core :as rx]
|
||||
[okulary.core :as l]
|
||||
[promesa.core :as p]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
;; --- Viewport
|
||||
|
||||
(def workspace-wasm-modifiers
|
||||
(l/derived :workspace-wasm-modifiers st/state))
|
||||
|
||||
(defn apply-modifiers-to-selected
|
||||
[selected objects modifiers]
|
||||
(->> modifiers
|
||||
@@ -98,7 +94,7 @@
|
||||
;; DEREFS
|
||||
drawing (mf/deref refs/workspace-drawing)
|
||||
focus (mf/deref refs/workspace-focus-selected)
|
||||
wasm-modifiers (mf/deref workspace-wasm-modifiers)
|
||||
wasm-modifiers (mf/deref refs/workspace-wasm-modifiers)
|
||||
|
||||
workspace-editor-state (mf/deref refs/workspace-editor-state)
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
[app.main.render :as render]
|
||||
[app.main.repo :as repo]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.context :as ctx]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.globals :as glob]
|
||||
[beicon.v2.core :as rx]
|
||||
@@ -76,11 +77,12 @@
|
||||
(mth/ceil height) "px")}))))
|
||||
|
||||
(when objects
|
||||
[:& render/object-svg
|
||||
{:objects objects
|
||||
:object-id object-id
|
||||
:embed embed
|
||||
:skip-children skip-children}])))
|
||||
[:& (mf/provider ctx/is-render?) {:value true}
|
||||
[:& render/object-svg
|
||||
{:objects objects
|
||||
:object-id object-id
|
||||
:embed embed
|
||||
:skip-children skip-children}]])))
|
||||
|
||||
(mf/defc objects-svg
|
||||
{::mf/wrap-props false}
|
||||
@@ -88,12 +90,13 @@
|
||||
(when-let [objects (mf/deref ref:objects)]
|
||||
(for [object-id object-ids]
|
||||
(let [objects (render/adapt-objects-for-shape objects object-id)]
|
||||
[:& render/object-svg
|
||||
{:objects objects
|
||||
:key (str object-id)
|
||||
:object-id object-id
|
||||
:embed embed
|
||||
:skip-children skip-children}]))))
|
||||
[:& (mf/provider ctx/is-render?) {:value true}
|
||||
[:& render/object-svg
|
||||
{:objects objects
|
||||
:key (str object-id)
|
||||
:object-id object-id
|
||||
:embed embed
|
||||
:skip-children skip-children}]]))))
|
||||
|
||||
(defn- fetch-objects-bundle
|
||||
[& {:keys [file-id page-id share-id object-id] :as options}]
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
[app.common.types.shape.layout :as ctl]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.main.fonts :as fonts]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.render :as render]
|
||||
[app.main.store :as st]
|
||||
@@ -61,6 +60,9 @@
|
||||
|
||||
(def ^:const MAX_BUFFER_CHUNK_SIZE (* 256 1024))
|
||||
|
||||
(def ^:const DEBOUNCE_DELAY_MS 100)
|
||||
(def ^:const THROTTLE_DELAY_MS 10)
|
||||
|
||||
(def dpr
|
||||
(if use-dpr? (if (exists? js/window) js/window.devicePixelRatio 1.0) 1.0))
|
||||
|
||||
@@ -829,7 +831,7 @@
|
||||
|
||||
(set-shape-vertical-align (get content :vertical-align))
|
||||
|
||||
(let [fonts (fonts/get-content-fonts content)
|
||||
(let [fonts (f/get-content-fonts content)
|
||||
fallback-fonts (fonts-from-text-content content true)
|
||||
all-fonts (concat fonts fallback-fonts)
|
||||
result (f/store-fonts shape-id all-fonts)]
|
||||
@@ -875,10 +877,10 @@
|
||||
(letfn [(do-render [ts]
|
||||
(h/call wasm/internal-module "_set_view_end")
|
||||
(render ts))]
|
||||
(fns/debounce do-render 100)))
|
||||
(fns/debounce do-render DEBOUNCE_DELAY_MS)))
|
||||
|
||||
(def render-pan
|
||||
(fns/throttle render 10))
|
||||
(fns/throttle render THROTTLE_DELAY_MS))
|
||||
|
||||
(defn set-view-box
|
||||
[prev-zoom zoom vbox]
|
||||
@@ -1044,6 +1046,7 @@
|
||||
(process-pending shapes thumbnails full noop-fn
|
||||
(fn []
|
||||
(when render-callback (render-callback))
|
||||
(render-finish)
|
||||
(ug/dispatch! (ug/event "penpot:wasm:set-objects")))))))
|
||||
|
||||
(defn clear-focus-mode
|
||||
@@ -1247,9 +1250,15 @@
|
||||
|
||||
(defn clear-canvas
|
||||
[]
|
||||
;; TODO: perform corresponding cleaning
|
||||
(set! wasm/context-initialized? false)
|
||||
(h/call wasm/internal-module "_clean_up"))
|
||||
(try
|
||||
;; TODO: perform corresponding cleaning
|
||||
(set! wasm/context-initialized? false)
|
||||
(h/call wasm/internal-module "_clean_up")
|
||||
|
||||
;; If this calls panics we don't want to crash. This happens sometimes
|
||||
;; with hot-reload in develop
|
||||
(catch :default error
|
||||
(.error js/console error))))
|
||||
|
||||
(defn show-grid
|
||||
[id]
|
||||
@@ -1292,12 +1301,7 @@
|
||||
(mem/free)
|
||||
content))
|
||||
|
||||
(defn- calculate-bool*
|
||||
[bool-type]
|
||||
(-> (h/call wasm/internal-module "_calculate_bool" (sr/translate-bool-type bool-type))
|
||||
(mem/->offset-32)))
|
||||
|
||||
(defn calculate-bool
|
||||
(defn calculate-bool*
|
||||
[bool-type ids]
|
||||
(let [size (mem/get-alloc-size ids UUID-U8-SIZE)
|
||||
heap (mem/get-heap-u32)
|
||||
@@ -1308,7 +1312,10 @@
|
||||
offset
|
||||
(rseq ids))
|
||||
|
||||
(let [offset (calculate-bool* bool-type)
|
||||
(let [offset
|
||||
(-> (h/call wasm/internal-module "_calculate_bool" (sr/translate-bool-type bool-type))
|
||||
(mem/->offset-32))
|
||||
|
||||
length (aget heap offset)
|
||||
data (mem/slice heap
|
||||
(+ offset 1)
|
||||
@@ -1317,51 +1324,40 @@
|
||||
(mem/free)
|
||||
content)))
|
||||
|
||||
(defn calculate-bool
|
||||
[shape objects]
|
||||
|
||||
;; We need to be able to calculate the boolean data but we cannot
|
||||
;; depend on the serialization flow.
|
||||
;; start_temp_object / end_temp_object create a new shapes_pool
|
||||
;; temporary and then we serialize the objects needed to calculate the
|
||||
;; boolean object.
|
||||
;; After the content is returned we discard that temporary context
|
||||
(h/call wasm/internal-module "_start_temp_objects")
|
||||
|
||||
(let [bool-type (get shape :bool-type)
|
||||
ids (get shape :shapes)
|
||||
all-children
|
||||
(->> ids
|
||||
(mapcat #(cfh/get-children-with-self objects %)))]
|
||||
(h/call wasm/internal-module "_init_shapes_pool" (count all-children))
|
||||
(run! (partial set-object objects) all-children)
|
||||
|
||||
(let [content (-> (calculate-bool* bool-type ids)
|
||||
(path.impl/path-data))]
|
||||
(h/call wasm/internal-module "_end_temp_objects")
|
||||
content)))
|
||||
|
||||
(defn init-wasm-module
|
||||
[module]
|
||||
(let [default-fn (unchecked-get module "default")
|
||||
serializers
|
||||
#js
|
||||
{:blur-type (unchecked-get module "RawBlurType")
|
||||
:blend-mode (unchecked-get module "RawBlendMode")
|
||||
:bool-type (unchecked-get module "RawBoolType")
|
||||
:font-style (unchecked-get module "RawFontStyle")
|
||||
:flex-direction (unchecked-get module "RawFlexDirection")
|
||||
:grid-direction (unchecked-get module "RawGridDirection")
|
||||
:grow-type (unchecked-get module "RawGrowType")
|
||||
:align-items (unchecked-get module "RawAlignItems")
|
||||
:align-self (unchecked-get module "RawAlignSelf")
|
||||
:align-content (unchecked-get module "RawAlignContent")
|
||||
:justify-items (unchecked-get module "RawJustifyItems")
|
||||
:justify-content (unchecked-get module "RawJustifyContent")
|
||||
:justify-self (unchecked-get module "RawJustifySelf")
|
||||
:wrap-type (unchecked-get module "RawWrapType")
|
||||
:grid-track-type (unchecked-get module "RawGridTrackType")
|
||||
:shadow-style (unchecked-get module "RawShadowStyle")
|
||||
:stroke-style (unchecked-get module "RawStrokeStyle")
|
||||
:stroke-cap (unchecked-get module "RawStrokeCap")
|
||||
:shape-type (unchecked-get module "RawShapeType")
|
||||
:constraint-h (unchecked-get module "RawConstraintH")
|
||||
:constraint-v (unchecked-get module "RawConstraintV")
|
||||
:sizing (unchecked-get module "RawSizing")
|
||||
:vertical-align (unchecked-get module "RawVerticalAlign")
|
||||
:fill-data (unchecked-get module "RawFillData")
|
||||
:text-align (unchecked-get module "RawTextAlign")
|
||||
:text-direction (unchecked-get module "RawTextDirection")
|
||||
:text-decoration (unchecked-get module "RawTextDecoration")
|
||||
:text-transform (unchecked-get module "RawTextTransform")
|
||||
:segment-data (unchecked-get module "RawSegmentData")
|
||||
:stroke-linecap (unchecked-get module "RawStrokeLineCap")
|
||||
:stroke-linejoin (unchecked-get module "RawStrokeLineJoin")
|
||||
:fill-rule (unchecked-get module "RawFillRule")}]
|
||||
(set! wasm/serializers serializers)
|
||||
(default-fn)))
|
||||
href (cf/resolve-href "js/render-wasm.wasm")]
|
||||
(default-fn #js {:locateFile (constantly href)})))
|
||||
|
||||
(defonce module
|
||||
(delay
|
||||
(if (exists? js/dynamicImport)
|
||||
(let [uri (cf/resolve-static-asset "js/render-wasm.js")]
|
||||
(let [uri (cf/resolve-href "js/render-wasm.js")]
|
||||
(->> (mod/import uri)
|
||||
(p/mcat init-wasm-module)
|
||||
(p/fmap
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.logging :as log]
|
||||
[app.common.types.text :as txt]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.main.fonts :as fonts]
|
||||
@@ -49,10 +50,13 @@
|
||||
:builtin))
|
||||
|
||||
(defn- font-db-data
|
||||
[font-id font-variant-id]
|
||||
[font-id font-variant-id font-weight-fallback font-style-fallback]
|
||||
(let [font (fonts/get-font-data font-id)
|
||||
closest-variant (fonts/find-closest-variant font font-weight-fallback font-style-fallback)
|
||||
variant (fonts/get-variant font font-variant-id)]
|
||||
variant))
|
||||
(if (or (nil? closest-variant) (= closest-variant variant))
|
||||
variant
|
||||
closest-variant)))
|
||||
|
||||
(defn- font-id->uuid [font-id]
|
||||
(case (font-backend font-id)
|
||||
@@ -63,22 +67,22 @@
|
||||
:builtin
|
||||
uuid/zero))
|
||||
|
||||
(defn ^:private font-id->asset-id [font-id font-variant-id]
|
||||
|
||||
(defn ^:private font-id->asset-id [font-id font-variant-id font-weight font-style]
|
||||
(case (font-backend font-id)
|
||||
:google
|
||||
font-id
|
||||
:custom
|
||||
(let [font-uuid (custom-font-id->uuid font-id)
|
||||
matching-font (d/seek (fn [[_ font]]
|
||||
(let [variant-id (or (:font-variant-id font) (dm/str (:font-style font) "-" (:font-weight font)))]
|
||||
(and (= (:font-id font) font-uuid)
|
||||
(or (nil? font-variant-id)
|
||||
(= variant-id font-variant-id)))))
|
||||
(seq @fonts))]
|
||||
matching-font (some (fn [[_ font]]
|
||||
(and (= (:font-id font) font-uuid)
|
||||
(= (str (:font-weight font)) (str font-weight))
|
||||
font))
|
||||
(seq @fonts))]
|
||||
(when matching-font
|
||||
(:ttf-file-id (second matching-font))))
|
||||
(:ttf-file-id matching-font)))
|
||||
:builtin
|
||||
(let [variant (font-db-data font-id font-variant-id)]
|
||||
(let [variant (font-db-data font-id font-variant-id font-weight font-style)]
|
||||
(:ttf-url variant))))
|
||||
|
||||
(defn update-text-layout
|
||||
@@ -100,6 +104,7 @@
|
||||
ptr (h/call wasm/internal-module "_alloc_bytes" size)
|
||||
heap (gobj/get ^js wasm/internal-module "HEAPU8")
|
||||
mem (js/Uint8Array. (.-buffer heap) ptr size)]
|
||||
|
||||
(.set mem (js/Uint8Array. font-array-buffer))
|
||||
(h/call wasm/internal-module "_store_font"
|
||||
(aget shape-id-buffer 0)
|
||||
@@ -134,17 +139,17 @@
|
||||
(rx/empty))))})
|
||||
|
||||
(defn- google-font-ttf-url
|
||||
[font-id font-variant-id]
|
||||
(let [variant (font-db-data font-id font-variant-id)]
|
||||
[font-id font-variant-id font-weight font-style]
|
||||
(let [variant (font-db-data font-id font-variant-id font-weight font-style)]
|
||||
(if-let [ttf-url (:ttf-url variant)]
|
||||
(str/replace ttf-url "https://fonts.gstatic.com/s/" (u/join cf/public-uri "/internal/gfonts/font/"))
|
||||
nil)))
|
||||
|
||||
(defn- font-id->ttf-url
|
||||
[font-id asset-id font-variant-id]
|
||||
[font-id asset-id font-variant-id font-weight font-style]
|
||||
(case (font-backend font-id)
|
||||
:google
|
||||
(google-font-ttf-url font-id font-variant-id)
|
||||
(google-font-ttf-url font-id font-variant-id font-weight font-style)
|
||||
:custom
|
||||
(dm/str (u/join cf/public-uri "assets/by-id/" asset-id))
|
||||
:builtin
|
||||
@@ -153,7 +158,7 @@
|
||||
(defn- store-font-id
|
||||
[shape-id font-data asset-id emoji? fallback?]
|
||||
(when asset-id
|
||||
(let [uri (font-id->ttf-url (:font-id font-data) asset-id (:font-variant-id font-data))
|
||||
(let [uri (font-id->ttf-url (:font-id font-data) asset-id (:font-variant-id font-data) (:weight font-data) (:style-name font-data))
|
||||
id-buffer (uuid/get-u32 (:wasm-id font-data))
|
||||
font-data (assoc font-data :family-id-buffer id-buffer)
|
||||
font-stored? (not= 0 (h/call wasm/internal-module "_is_font_uploaded"
|
||||
@@ -187,6 +192,30 @@
|
||||
(catch :default _e
|
||||
uuid/zero)))
|
||||
|
||||
(defn normalize-span-font
|
||||
[span paragraph]
|
||||
(let [font-id (:font-id span)
|
||||
font-variant-id (:font-variant-id span)
|
||||
font-weight-fallback (or (:font-weight span) (:font-weight paragraph))
|
||||
font-style-fallback (or (:font-style span) (:font-style paragraph))
|
||||
font-data (font-db-data font-id font-variant-id font-weight-fallback font-style-fallback)]
|
||||
(-> span
|
||||
(assoc :font-variant-id (or (:id font-data) (:id font-data) font-variant-id)
|
||||
:font-weight (or (:weight font-data) font-weight-fallback)
|
||||
:font-style (or (:style font-data) font-style-fallback)))))
|
||||
|
||||
(defn normalize-paragraph-font
|
||||
[paragraph]
|
||||
(let [font-id (:font-id paragraph)
|
||||
font-variant-id (:font-variant-id paragraph)
|
||||
font-weight-fallback (:font-weight paragraph)
|
||||
font-style-fallback (:font-style paragraph)
|
||||
font-data (font-db-data font-id font-variant-id font-weight-fallback font-style-fallback)]
|
||||
(-> paragraph
|
||||
(assoc :font-variant-id (or (:id font-data) (:id font-data) font-variant-id)
|
||||
:font-weight (or (:weight font-data) font-weight-fallback)
|
||||
:font-style (or (:style font-data) font-style-fallback)))))
|
||||
|
||||
(defn serialize-font-size
|
||||
[font-size]
|
||||
(cond
|
||||
@@ -244,26 +273,41 @@
|
||||
(string? letter-spacing)
|
||||
(or (d/parse-double letter-spacing) default-letter-spacing)))
|
||||
|
||||
|
||||
(defn normalize-font-variant
|
||||
[font-variant-id]
|
||||
(if (or (nil? font-variant-id) (str/blank? font-variant-id))
|
||||
"regular"
|
||||
font-variant-id))
|
||||
|
||||
(defn store-font
|
||||
[shape-id font]
|
||||
(let [font-id (get font :font-id)
|
||||
font-variant-id (get font :font-variant-id)
|
||||
normalized-variant-id (when font-variant-id
|
||||
(-> font-variant-id
|
||||
(str/lower)
|
||||
(str/replace #"\s+" "")))
|
||||
font-weight-fallback (or (get font :font-weight) 400)
|
||||
font-style-fallback (or (get font :font-style) "normal")
|
||||
emoji? (get font :is-emoji false)
|
||||
fallback? (get font :is-fallback false)
|
||||
font-data (font-db-data font-id normalized-variant-id font-weight-fallback font-style-fallback)
|
||||
wasm-id (font-id->uuid font-id)
|
||||
raw-weight (or (:weight (font-db-data font-id font-variant-id)) 400)
|
||||
raw-weight (or (:weight font-data) font-weight-fallback)
|
||||
weight (serialize-font-weight raw-weight)
|
||||
style (serialize-font-style (cond
|
||||
(str/includes? font-variant-id "italic") "italic"
|
||||
(str/includes? raw-weight "italic") "italic"
|
||||
:else "normal"))
|
||||
asset-id (font-id->asset-id font-id font-variant-id)
|
||||
style (cond
|
||||
(str/includes? (or normalized-variant-id "") "italic") "italic"
|
||||
(str/includes? raw-weight "italic") "italic"
|
||||
:else font-style-fallback)
|
||||
variant-id (or (:id font-data) normalized-variant-id)
|
||||
asset-id (font-id->asset-id font-id variant-id raw-weight style)
|
||||
font-data {:wasm-id wasm-id
|
||||
:font-id font-id
|
||||
:font-variant-id font-variant-id
|
||||
:style style
|
||||
:font-variant-id variant-id
|
||||
:style (serialize-font-style style)
|
||||
:style-name style
|
||||
:weight weight}]
|
||||
|
||||
(store-font-id shape-id font-data asset-id emoji? fallback?)))
|
||||
|
||||
;; FIXME: This is a temporary function to load the fallback fonts for the editor.
|
||||
@@ -273,6 +317,29 @@
|
||||
(doseq [font fonts]
|
||||
(fonts/ensure-loaded! (:font-id font) (:font-variant-id font))))
|
||||
|
||||
|
||||
(defn get-content-fonts
|
||||
"Extends from app.main.fonts/get-content-fonts. Extracts the fonts used by the content of a text shape, resolving the correct font variant info."
|
||||
[content]
|
||||
(let [paragraph-set (first (get content :children))
|
||||
paragraphs (get paragraph-set :children)]
|
||||
(->> paragraphs
|
||||
(mapcat #(get % :children))
|
||||
(filter txt/is-text-node?)
|
||||
(reduce
|
||||
(fn [result {:keys [font-id font-variant-id font-weight font-style] :as node}]
|
||||
(let [resolved-font-id (or font-id (:font-id txt/default-typography))
|
||||
resolved-variant-id (or font-variant-id (:font-variant-id txt/default-typography))
|
||||
font-weight-fallback (or font-weight (:font-weight txt/default-typography) 400)
|
||||
font-style-fallback (or font-style (:font-style txt/default-typography) "normal")
|
||||
font-data (font-db-data resolved-font-id resolved-variant-id font-weight-fallback font-style-fallback)
|
||||
font-ref {:font-id resolved-font-id
|
||||
:font-variant-id (or (:id font-data) (:name font-data) resolved-variant-id)
|
||||
:font-weight (or (:weight font-data) font-weight-fallback)
|
||||
:font-style (or (:style font-data) font-style-fallback)}]
|
||||
(conj result font-ref)))
|
||||
#{}))))
|
||||
|
||||
(defn store-fonts
|
||||
[shape-id fonts]
|
||||
(keep (fn [font] (store-font shape-id font)) fonts))
|
||||
|
||||
242
frontend/src/app/render_wasm/api/shared.js
Normal file
242
frontend/src/app/render_wasm/api/shared.js
Normal file
@@ -0,0 +1,242 @@
|
||||
export const GrowType = {
|
||||
"fixed": 0,
|
||||
"auto-width": 1,
|
||||
"auto-height": 2,
|
||||
};
|
||||
|
||||
export const RawBlendMode = {
|
||||
"normal": 3,
|
||||
"screen": 14,
|
||||
"overlay": 15,
|
||||
"darken": 16,
|
||||
"lighten": 17,
|
||||
"color-dodge": 18,
|
||||
"color-burn": 19,
|
||||
"hard-light": 20,
|
||||
"soft-light": 21,
|
||||
"difference": 22,
|
||||
"exclusion": 23,
|
||||
"multiply": 24,
|
||||
"hue": 25,
|
||||
"saturation": 26,
|
||||
"color": 27,
|
||||
"luminosity": 28,
|
||||
};
|
||||
|
||||
export const RawBlurType = {
|
||||
"layer-blur": 0,
|
||||
};
|
||||
|
||||
export const RawFillData = {
|
||||
"solid": 0,
|
||||
"linear": 1,
|
||||
"radial": 2,
|
||||
"image": 3,
|
||||
};
|
||||
|
||||
export const RawFontStyle = {
|
||||
"normal": 0,
|
||||
"italic": 1,
|
||||
};
|
||||
|
||||
export const RawAlignItems = {
|
||||
"start": 0,
|
||||
"end": 1,
|
||||
"center": 2,
|
||||
"stretch": 3,
|
||||
};
|
||||
|
||||
export const RawAlignContent = {
|
||||
"start": 0,
|
||||
"end": 1,
|
||||
"center": 2,
|
||||
"space-between": 3,
|
||||
"space-around": 4,
|
||||
"space-evenly": 5,
|
||||
"stretch": 6,
|
||||
};
|
||||
|
||||
export const RawJustifyItems = {
|
||||
"start": 0,
|
||||
"end": 1,
|
||||
"center": 2,
|
||||
"stretch": 3,
|
||||
};
|
||||
|
||||
export const RawJustifyContent = {
|
||||
"start": 0,
|
||||
"end": 1,
|
||||
"center": 2,
|
||||
"space-between": 3,
|
||||
"space-around": 4,
|
||||
"space-evenly": 5,
|
||||
"stretch": 6,
|
||||
};
|
||||
|
||||
export const RawJustifySelf = {
|
||||
"none": 0,
|
||||
"auto": 1,
|
||||
"start": 2,
|
||||
"end": 3,
|
||||
"center": 4,
|
||||
"stretch": 5,
|
||||
};
|
||||
|
||||
export const RawAlignSelf = {
|
||||
"none": 0,
|
||||
"auto": 1,
|
||||
"start": 2,
|
||||
"end": 3,
|
||||
"center": 4,
|
||||
"stretch": 5,
|
||||
};
|
||||
|
||||
export const RawVerticalAlign = {
|
||||
"top": 0,
|
||||
"center": 1,
|
||||
"bottom": 2,
|
||||
};
|
||||
|
||||
export const RawConstraintH = {
|
||||
"left": 0,
|
||||
"right": 1,
|
||||
"leftright": 2,
|
||||
"center": 3,
|
||||
"scale": 4,
|
||||
};
|
||||
|
||||
export const RawConstraintV = {
|
||||
"top": 0,
|
||||
"bottom": 1,
|
||||
"topbottom": 2,
|
||||
"center": 3,
|
||||
"scale": 4,
|
||||
};
|
||||
|
||||
export const RawFlexDirection = {
|
||||
"row": 0,
|
||||
"row-reverse": 1,
|
||||
"column": 2,
|
||||
"column-reverse": 3,
|
||||
};
|
||||
|
||||
export const RawWrapType = {
|
||||
"wrap": 0,
|
||||
"nowrap": 1,
|
||||
};
|
||||
|
||||
export const RawGridDirection = {
|
||||
"row": 0,
|
||||
"column": 1,
|
||||
};
|
||||
|
||||
export const RawGridTrackType = {
|
||||
"percent": 0,
|
||||
"flex": 1,
|
||||
"auto": 2,
|
||||
"fixed": 3,
|
||||
};
|
||||
|
||||
export const RawSizing = {
|
||||
"fill": 0,
|
||||
"fix": 1,
|
||||
"auto": 2,
|
||||
};
|
||||
|
||||
export const RawBoolType = {
|
||||
"union": 0,
|
||||
"difference": 1,
|
||||
"intersection": 2,
|
||||
"exclusion": 3,
|
||||
};
|
||||
|
||||
export const RawSegmentData = {
|
||||
"move-to": 1,
|
||||
"line-to": 2,
|
||||
"curve-to": 3,
|
||||
"close": 4,
|
||||
};
|
||||
|
||||
export const RawShadowStyle = {
|
||||
"drop-shadow": 0,
|
||||
"inner-shadow": 1,
|
||||
};
|
||||
|
||||
export const RawShapeType = {
|
||||
"frame": 0,
|
||||
"group": 1,
|
||||
"bool": 2,
|
||||
"rect": 3,
|
||||
"path": 4,
|
||||
"text": 5,
|
||||
"circle": 6,
|
||||
"svg-raw": 7,
|
||||
};
|
||||
|
||||
export const RawStrokeStyle = {
|
||||
"solid": 0,
|
||||
"dotted": 1,
|
||||
"dashed": 2,
|
||||
"mixed": 3,
|
||||
};
|
||||
|
||||
export const RawStrokeCap = {
|
||||
"none": 0,
|
||||
"line-arrow": 1,
|
||||
"triangle-arrow": 2,
|
||||
"square-marker": 3,
|
||||
"circle-marker": 4,
|
||||
"diamond-marker": 5,
|
||||
"round": 6,
|
||||
"square": 7,
|
||||
};
|
||||
|
||||
export const RawFillRule = {
|
||||
"nonzero": 0,
|
||||
"evenodd": 1,
|
||||
};
|
||||
|
||||
export const RawStrokeLineCap = {
|
||||
"butt": 0,
|
||||
"round": 1,
|
||||
"square": 2,
|
||||
};
|
||||
|
||||
export const RawStrokeLineJoin = {
|
||||
"miter": 0,
|
||||
"round": 1,
|
||||
"bevel": 2,
|
||||
};
|
||||
|
||||
export const RawTextAlign = {
|
||||
"left": 0,
|
||||
"center": 1,
|
||||
"right": 2,
|
||||
"justify": 3,
|
||||
};
|
||||
|
||||
export const RawTextDirection = {
|
||||
"ltr": 0,
|
||||
"rtl": 1,
|
||||
};
|
||||
|
||||
export const RawTextDecoration = {
|
||||
"none": 0,
|
||||
"underline": 1,
|
||||
"line-through": 2,
|
||||
"overline": 3,
|
||||
};
|
||||
|
||||
export const RawTextTransform = {
|
||||
"none": 0,
|
||||
"uppercase": 1,
|
||||
"lowercase": 2,
|
||||
"capitalize": 3,
|
||||
};
|
||||
|
||||
export const RawGrowType = {
|
||||
"fixed": 0,
|
||||
"auto-width": 1,
|
||||
"auto-height": 2,
|
||||
};
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
font-size (f/serialize-font-size font-size)
|
||||
|
||||
line-height (f/serialize-line-height (get span :line-height) paragraph-line-height)
|
||||
letter-spacing (f/serialize-letter-spacing (get paragraph :letter-spacing))
|
||||
letter-spacing (f/serialize-letter-spacing (get span :letter-spacing))
|
||||
|
||||
font-weight (get span :font-weight paragraph-font-weight)
|
||||
font-weight (f/serialize-font-weight font-weight)
|
||||
@@ -142,7 +142,9 @@
|
||||
;; buffer has the following format:
|
||||
;; [<num-spans> <paragraph_attributes> <spans_attributes> <text>]
|
||||
[spans paragraph text]
|
||||
(let [num-spans (count spans)
|
||||
(let [normalized-paragraph (f/normalize-paragraph-font paragraph)
|
||||
normalized-spans (map #(f/normalize-span-font % normalized-paragraph) spans)
|
||||
num-spans (count normalized-spans)
|
||||
fills-size (* types.fills.impl/FILL-U8-SIZE MAX-TEXT-FILLS)
|
||||
metadata-size (+ PARAGRAPH-ATTR-U8-SIZE
|
||||
(* num-spans (+ SPAN-ATTR-U8-SIZE fills-size)))
|
||||
@@ -157,8 +159,8 @@
|
||||
|
||||
(-> offset
|
||||
(mem/write-u32 dview num-spans)
|
||||
(write-paragraph dview paragraph)
|
||||
(write-spans dview spans paragraph)
|
||||
(write-paragraph dview normalized-paragraph)
|
||||
(write-spans dview normalized-spans normalized-paragraph)
|
||||
(mem/write-buffer heapu8 text-buffer))
|
||||
|
||||
(h/call wasm/internal-module "_set_shape_text_content")))
|
||||
|
||||
@@ -291,7 +291,8 @@
|
||||
(api/set-grid-layout-data shape)
|
||||
|
||||
(ctl/flex-layout? shape)
|
||||
(api/set-flex-layout shape)))
|
||||
(api/set-flex-layout shape))
|
||||
(api/set-layout-child shape))
|
||||
|
||||
;; Property not in WASM
|
||||
nil))))
|
||||
@@ -322,7 +323,7 @@
|
||||
(rx/subs! #(api/request-render "set-wasm-attrs"))))
|
||||
|
||||
;; `conj` empty set initialization
|
||||
(def conj* (fnil conj #{}))
|
||||
(def conj* (fnil conj (d/ordered-set)))
|
||||
|
||||
(defn- impl-assoc
|
||||
[self k v]
|
||||
|
||||
@@ -4,9 +4,43 @@
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns app.render-wasm.wasm)
|
||||
(ns app.render-wasm.wasm
|
||||
(:require ["./api/shared.js" :as shared]))
|
||||
|
||||
(defonce internal-frame-id nil)
|
||||
(defonce internal-module #js {})
|
||||
(defonce serializers #js {})
|
||||
(defonce serializers
|
||||
#js {:blur-type shared/RawBlurType
|
||||
:blend-mode shared/RawBlendMode
|
||||
:bool-type shared/RawBoolType
|
||||
:font-style shared/RawFontStyle
|
||||
:flex-direction shared/RawFlexDirection
|
||||
:grid-direction shared/RawGridDirection
|
||||
:grow-type shared/RawGrowType
|
||||
:align-items shared/RawAlignItems
|
||||
:align-self shared/RawAlignSelf
|
||||
:align-content shared/RawAlignContent
|
||||
:justify-items shared/RawJustifyItems
|
||||
:justify-content shared/RawJustifyContent
|
||||
:justify-self shared/RawJustifySelf
|
||||
:wrap-type shared/RawWrapType
|
||||
:grid-track-type shared/RawGridTrackType
|
||||
:shadow-style shared/RawShadowStyle
|
||||
:stroke-style shared/RawStrokeStyle
|
||||
:stroke-cap shared/RawStrokeCap
|
||||
:shape-type shared/RawShapeType
|
||||
:constraint-h shared/RawConstraintH
|
||||
:constraint-v shared/RawConstraintV
|
||||
:sizing shared/RawSizing
|
||||
:vertical-align shared/RawVerticalAlign
|
||||
:fill-data shared/RawFillData
|
||||
:text-align shared/RawTextAlign
|
||||
:text-direction shared/RawTextDirection
|
||||
:text-decoration shared/RawTextDecoration
|
||||
:text-transform shared/RawTextTransform
|
||||
:segment-data shared/RawSegmentData
|
||||
:stroke-linecap shared/RawStrokeLineCap
|
||||
:stroke-linejoin shared/RawStrokeLineJoin
|
||||
:fill-rule shared/RawFillRule})
|
||||
|
||||
(defonce context-initialized? false)
|
||||
|
||||
@@ -48,6 +48,9 @@
|
||||
"This function strips units from attr values and un-scapes font-family"
|
||||
[k v]
|
||||
(cond
|
||||
(= v "mixed")
|
||||
:multiple
|
||||
|
||||
(and (or (= k :font-size)
|
||||
(= k :letter-spacing))
|
||||
(= (str/slice v -2) "px"))
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
(defn init
|
||||
"Return a initialized webworker instance."
|
||||
[path on-error]
|
||||
(let [instance (js/Worker. path #js {:type "module"})
|
||||
(let [instance (js/Worker. path)
|
||||
bus (rx/subject)
|
||||
worker (Worker. instance (rx/to-observable bus))
|
||||
|
||||
|
||||
@@ -257,7 +257,7 @@
|
||||
(filter (if clip-children?
|
||||
(comp overlaps-parent? :clip-parents)
|
||||
(constantly true)))
|
||||
(map :id))
|
||||
(keep :id))
|
||||
result)))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
[app.render-wasm.api :as wasm.api]
|
||||
[app.render-wasm.wasm :as wasm]
|
||||
[app.util.http :as http]
|
||||
[app.util.modules :as mod]
|
||||
[app.worker.impl :as impl]
|
||||
[beicon.v2.core :as rx]
|
||||
[okulary.core :as l]
|
||||
@@ -29,6 +28,8 @@
|
||||
|
||||
(log/set-level! :trace)
|
||||
|
||||
(def ^:private ^:const thumbnail-aspect-ratio (/ 2 3))
|
||||
|
||||
(defn- handle-response
|
||||
[{:keys [body status] :as response}]
|
||||
(cond
|
||||
@@ -64,6 +65,10 @@
|
||||
(rx/map http/conditional-decode-transit)
|
||||
(rx/mapcat handle-response))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; SVG RENDERING (LEGACY RENDER)
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn- render-thumbnail
|
||||
[{:keys [page file-id revn] :as params}]
|
||||
(try
|
||||
@@ -98,15 +103,13 @@
|
||||
(->> (request-data-for-thumbnail file-id revn true)
|
||||
(rx/map render-thumbnail)))
|
||||
|
||||
(def init-wasm
|
||||
(delay
|
||||
(let [uri (cf/resolve-static-asset "js/render-wasm.js")]
|
||||
(-> (mod/import (str uri))
|
||||
(p/then #(wasm.api/init-wasm-module %))
|
||||
(p/then #(set! wasm/internal-module %))))))
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; WASM RENDERING
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(mf/defc svg-wrapper
|
||||
[{:keys [data-uri background width height]}]
|
||||
(mf/defc svg-wrapper*
|
||||
{::mf/private true}
|
||||
[{:keys [uri background width height]}]
|
||||
[:svg {:version "1.1"
|
||||
:xmlns "http://www.w3.org/2000/svg"
|
||||
:xmlnsXlink "http://www.w3.org/1999/xlink"
|
||||
@@ -116,85 +119,97 @@
|
||||
:background background}
|
||||
:fill "none"
|
||||
:viewBox (dm/str "0 0 " width " " height)}
|
||||
[:image {:xlinkHref data-uri
|
||||
[:image {:xlinkHref uri
|
||||
:width width
|
||||
:height height}]])
|
||||
|
||||
(defn blob->uri
|
||||
(defn- blob->uri
|
||||
[blob]
|
||||
(.readAsDataURL (js/FileReaderSync.) blob))
|
||||
|
||||
(def thumbnail-aspect-ratio (/ 2 3))
|
||||
(defn- render-canvas-blob
|
||||
[canvas width height background]
|
||||
(->> (.convertToBlob ^js canvas)
|
||||
(p/fmap (fn [blob]
|
||||
(rds/renderToStaticMarkup
|
||||
(mf/element svg-wrapper*
|
||||
#js {:uri (blob->uri blob)
|
||||
:width width
|
||||
:height height
|
||||
:background background}))))))
|
||||
|
||||
(defn render-canvas-blob
|
||||
[canvas width height background-color]
|
||||
(-> (.convertToBlob canvas)
|
||||
(p/then
|
||||
(fn [blob]
|
||||
(rds/renderToStaticMarkup
|
||||
(mf/element
|
||||
svg-wrapper
|
||||
#js {:data-uri (blob->uri blob)
|
||||
:width width
|
||||
:height height
|
||||
:background background-color}))))))
|
||||
(defonce ^:private wasm-module
|
||||
(delay
|
||||
(let [module (unchecked-get js/globalThis "WasmModule")
|
||||
init-fn (unchecked-get module "default")
|
||||
href (cf/resolve-href "js/render-wasm.wasm")]
|
||||
(->> (init-fn #js {:locateFile (constantly href)})
|
||||
(p/fnly (fn [module cause]
|
||||
(if cause
|
||||
(js/console.error cause)
|
||||
(set! wasm/internal-module module))))))))
|
||||
|
||||
(defn process-wasm-thumbnail
|
||||
(defn- render-thumbnail-with-wasm
|
||||
[{:keys [id file-id revn width] :as message}]
|
||||
(->> (rx/from @init-wasm)
|
||||
(->> (rx/from @wasm-module)
|
||||
(rx/mapcat #(request-data-for-thumbnail file-id revn false))
|
||||
(rx/mapcat
|
||||
(fn [{:keys [page] :as file}]
|
||||
(rx/create
|
||||
(fn [subs]
|
||||
(let [background-color (or (:background page) cc/canvas)
|
||||
height (* width thumbnail-aspect-ratio)
|
||||
canvas (js/OffscreenCanvas. width height)
|
||||
init? (wasm.api/init-canvas-context canvas)]
|
||||
(let [bgcolor (or (:background page) cc/canvas)
|
||||
height (* width thumbnail-aspect-ratio)
|
||||
canvas (js/OffscreenCanvas. width height)
|
||||
init? (wasm.api/init-canvas-context canvas)]
|
||||
(if init?
|
||||
(let [objects (:objects page)
|
||||
frame (some->> page :thumbnail-frame-id (get objects))
|
||||
vbox (if frame
|
||||
(-> (gsb/get-object-bounds objects frame)
|
||||
(grc/fix-aspect-ratio thumbnail-aspect-ratio))
|
||||
(render/calculate-dimensions objects thumbnail-aspect-ratio))
|
||||
zoom (/ width (:width vbox))]
|
||||
frame (some->> page :thumbnail-frame-id (get objects))
|
||||
vbox (if frame
|
||||
(-> (gsb/get-object-bounds objects frame)
|
||||
(grc/fix-aspect-ratio thumbnail-aspect-ratio))
|
||||
(render/calculate-dimensions objects thumbnail-aspect-ratio))
|
||||
zoom (/ width (:width vbox))]
|
||||
|
||||
(wasm.api/initialize-viewport
|
||||
objects zoom vbox background-color
|
||||
objects zoom vbox bgcolor
|
||||
(fn []
|
||||
(if frame
|
||||
(wasm.api/render-sync-shape (:id frame))
|
||||
(wasm.api/render-sync))
|
||||
|
||||
(-> (render-canvas-blob canvas width height background-color)
|
||||
(p/then #(rx/push! subs {:id id :data % :file-id file-id :revn revn}))
|
||||
(p/catch #(rx/error! subs %))
|
||||
(p/finally #(rx/end! subs))))))
|
||||
|
||||
(->> (render-canvas-blob canvas width height bgcolor)
|
||||
(p/fnly (fn [data cause]
|
||||
(if cause
|
||||
(rx/error! subs cause)
|
||||
(rx/push! subs
|
||||
{:id id
|
||||
:data data
|
||||
:file-id file-id
|
||||
:revn revn}))
|
||||
(rx/end! subs)))))))
|
||||
(rx/end! subs))
|
||||
|
||||
nil)))))))
|
||||
|
||||
(defonce thumbs-subject (rx/subject))
|
||||
(defonce ^:private
|
||||
thumbnails-queue
|
||||
(rx/subject))
|
||||
|
||||
(defonce thumbs-stream
|
||||
(->> thumbs-subject
|
||||
(rx/mapcat process-wasm-thumbnail)
|
||||
(defonce ^:private
|
||||
thumbnails-stream
|
||||
(->> thumbnails-queue
|
||||
(rx/mapcat render-thumbnail-with-wasm)
|
||||
(rx/share)))
|
||||
|
||||
(defmethod impl/handler :thumbnails/generate-for-file-wasm
|
||||
[message _]
|
||||
(rx/create
|
||||
(fn [subs]
|
||||
(let [id (uuid/next)
|
||||
sid
|
||||
(->> thumbs-stream
|
||||
(rx/filter #(= id (:id %)))
|
||||
(rx/subs!
|
||||
#(do
|
||||
(rx/push! subs %)
|
||||
(rx/end! subs))))]
|
||||
(rx/push! thumbs-subject (assoc message :id id))
|
||||
|
||||
(let [id (uuid/next)
|
||||
sid (->> thumbnails-stream
|
||||
(rx/filter #(= id (:id %)))
|
||||
(rx/subs!
|
||||
(fn [result]
|
||||
(rx/push! subs result)
|
||||
(rx/end! subs))))]
|
||||
(rx/push! thumbnails-queue (assoc message :id id))
|
||||
#(rx/dispose! sid)))))
|
||||
|
||||
@@ -26,6 +26,7 @@ import LayoutType from "./layout/LayoutType.js";
|
||||
* @typedef {Object} TextEditorOptions
|
||||
* @property {CSSStyleDeclaration|Object.<string,*>} [styleDefaults]
|
||||
* @property {SelectionControllerDebug} [debug]
|
||||
* @property {boolean} [shouldUpdatePositionOnScroll=false]
|
||||
* @property {boolean} [allowHTMLPaste=false]
|
||||
*/
|
||||
|
||||
@@ -92,6 +93,21 @@ export class TextEditor extends EventTarget {
|
||||
*/
|
||||
#canvas = null;
|
||||
|
||||
/**
|
||||
* Text editor options.
|
||||
*
|
||||
* @type {TextEditorOptions}
|
||||
*/
|
||||
#options = {};
|
||||
|
||||
/**
|
||||
* A boolean indicating that this instance was
|
||||
* disposed or not.
|
||||
*
|
||||
* @type {boolean}
|
||||
*/
|
||||
#isDisposed = false;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
@@ -101,9 +117,9 @@ export class TextEditor extends EventTarget {
|
||||
*/
|
||||
constructor(element, canvas, options) {
|
||||
super();
|
||||
if (!(element instanceof HTMLElement))
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
throw new TypeError("Invalid text editor element");
|
||||
|
||||
}
|
||||
this.#element = element;
|
||||
this.#canvas = canvas;
|
||||
this.#events = {
|
||||
@@ -119,6 +135,7 @@ export class TextEditor extends EventTarget {
|
||||
keydown: this.#onKeyDown,
|
||||
};
|
||||
this.#styleDefaults = options?.styleDefaults;
|
||||
this.#options = options;
|
||||
this.#setup(options);
|
||||
}
|
||||
|
||||
@@ -150,14 +167,18 @@ export class TextEditor extends EventTarget {
|
||||
|
||||
/**
|
||||
* Setups the root element.
|
||||
*
|
||||
* @param {TextEditorOptions} options
|
||||
*/
|
||||
#setupRoot() {
|
||||
#setupRoot(options) {
|
||||
this.#root = createEmptyRoot(this.#styleDefaults);
|
||||
this.#element.appendChild(this.#root);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setups event listeners.
|
||||
*
|
||||
* @param {TextEditorOptions} options
|
||||
*/
|
||||
#setupListeners(options) {
|
||||
this.#changeController.addEventListener("change", this.#onChange);
|
||||
@@ -174,18 +195,61 @@ export class TextEditor extends EventTarget {
|
||||
}
|
||||
|
||||
/**
|
||||
* Setups the elements, the properties and the
|
||||
* initial content.
|
||||
* Disposes everything.
|
||||
*/
|
||||
#setup(options) {
|
||||
this.#setupElementProperties(options);
|
||||
this.#setupRoot(options);
|
||||
dispose() {
|
||||
if (this.#isDisposed) {
|
||||
return this;
|
||||
}
|
||||
this.#isDisposed = true;
|
||||
|
||||
// Dispose change controller.
|
||||
this.#changeController.removeEventListener("change", this.#onChange);
|
||||
this.#changeController.dispose();
|
||||
this.#changeController = null;
|
||||
|
||||
// Disposes selection controller.
|
||||
this.#selectionController.removeEventListener(
|
||||
"stylechange",
|
||||
this.#onStyleChange,
|
||||
);
|
||||
this.#selectionController.dispose();
|
||||
this.#selectionController = null;
|
||||
|
||||
// Disposes the rest of event listeners.
|
||||
removeEventListeners(this.#element, this.#events);
|
||||
if (this.#options.shouldUpdatePositionOnScroll) {
|
||||
window.removeEventListener("scroll", this.#onScroll);
|
||||
}
|
||||
|
||||
// Disposes references to DOM elements.
|
||||
this.#element = null;
|
||||
this.#root = null;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setups controllers.
|
||||
*
|
||||
* @param {TextEditorOptions} options
|
||||
*/
|
||||
#setupControllers(options) {
|
||||
this.#changeController = new ChangeController(this);
|
||||
this.#selectionController = new SelectionController(
|
||||
this,
|
||||
document.getSelection(),
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setups the elements, the properties and the
|
||||
* initial content.
|
||||
*/
|
||||
#setup(options) {
|
||||
this.#setupElementProperties(options);
|
||||
this.#setupRoot(options);
|
||||
this.#setupControllers(options);
|
||||
this.#setupListeners(options);
|
||||
}
|
||||
|
||||
@@ -242,7 +306,9 @@ export class TextEditor extends EventTarget {
|
||||
* @param {CustomEvent} e
|
||||
* @returns {void}
|
||||
*/
|
||||
#onChange = (e) => this.dispatchEvent(new e.constructor(e.type, e));
|
||||
#onChange = (e) => {
|
||||
this.dispatchEvent(new e.constructor(e.type, e));
|
||||
};
|
||||
|
||||
/**
|
||||
* Dispatchs a `stylechange` event.
|
||||
@@ -421,6 +487,15 @@ export class TextEditor extends EventTarget {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates that the TextEditor was disposed.
|
||||
*
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isDisposed() {
|
||||
return this.#isDisposed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Root element that contains all the paragraphs.
|
||||
*
|
||||
@@ -478,6 +553,15 @@ export class TextEditor extends EventTarget {
|
||||
return this.#selectionController.currentStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Text editor options
|
||||
*
|
||||
* @type {TextEditorOptions}
|
||||
*/
|
||||
get options() {
|
||||
return this.#options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus the element
|
||||
*/
|
||||
@@ -540,7 +624,8 @@ export class TextEditor extends EventTarget {
|
||||
* Applies the current styles to the selection or
|
||||
* the current DOM node at the caret.
|
||||
*
|
||||
* @param {*} styles
|
||||
* @param {Object.<string, *>} styles
|
||||
* @returns {TextEditor}
|
||||
*/
|
||||
applyStylesToSelection(styles) {
|
||||
this.#selectionController.startMutation();
|
||||
@@ -553,6 +638,8 @@ export class TextEditor extends EventTarget {
|
||||
|
||||
/**
|
||||
* Selects all content.
|
||||
*
|
||||
* @returns {TextEditor}
|
||||
*/
|
||||
selectAll() {
|
||||
this.#selectionController.selectAll();
|
||||
@@ -562,30 +649,12 @@ export class TextEditor extends EventTarget {
|
||||
/**
|
||||
* Moves cursor to end.
|
||||
*
|
||||
* @returns
|
||||
* @returns {TextEditor}
|
||||
*/
|
||||
cursorToEnd() {
|
||||
this.#selectionController.cursorToEnd();
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes everything.
|
||||
*/
|
||||
dispose() {
|
||||
this.#changeController.removeEventListener("change", this.#onChange);
|
||||
this.#changeController.dispose();
|
||||
this.#changeController = null;
|
||||
this.#selectionController.removeEventListener(
|
||||
"stylechange",
|
||||
this.#onStyleChange,
|
||||
);
|
||||
this.#selectionController.dispose();
|
||||
this.#selectionController = null;
|
||||
removeEventListeners(this.#element, this.#events);
|
||||
this.#element = null;
|
||||
this.#root = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -615,47 +684,98 @@ export function createRootFromString(string) {
|
||||
return root;
|
||||
}
|
||||
|
||||
export function isEditor(instance) {
|
||||
/**
|
||||
* Returns true if the passed object is a TextEditor
|
||||
* instance.
|
||||
*
|
||||
* @param {*} instance
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isTextEditor(instance) {
|
||||
return instance instanceof TextEditor;
|
||||
}
|
||||
|
||||
/* Convenience function based API for Text Editor */
|
||||
/**
|
||||
* Returns the root element of a TextEditor
|
||||
* instance.
|
||||
*
|
||||
* @param {TextEditor} instance
|
||||
* @returns {HTMLDivElement}
|
||||
*/
|
||||
export function getRoot(instance) {
|
||||
if (isEditor(instance)) {
|
||||
if (isTextEditor(instance)) {
|
||||
return instance.root;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the root of the text editor.
|
||||
*
|
||||
* @param {TextEditor} instance
|
||||
* @param {HTMLDivElement} root
|
||||
* @returns {TextEditor}
|
||||
*/
|
||||
export function setRoot(instance, root) {
|
||||
if (isEditor(instance)) {
|
||||
if (isTextEditor(instance)) {
|
||||
instance.root = root;
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new TextEditor instance.
|
||||
*
|
||||
* @param {HTMLDivElement} element
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {TextEditorOptions} options
|
||||
* @returns {TextEditor}
|
||||
*/
|
||||
export function create(element, canvas, options) {
|
||||
return new TextEditor(element, canvas, { ...options });
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current style of the TextEditor instance.
|
||||
*
|
||||
* @param {TextEditor} instance
|
||||
* @returns {CSSStyleDeclaration|undefined}
|
||||
*/
|
||||
export function getCurrentStyle(instance) {
|
||||
if (isEditor(instance)) {
|
||||
if (isTextEditor(instance)) {
|
||||
return instance.currentStyle;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the specified styles to the TextEditor
|
||||
* passed.
|
||||
*
|
||||
* @param {TextEditor} instance
|
||||
* @param {Object.<string, *>} styles
|
||||
* @returns {TextEditor|null}
|
||||
*/
|
||||
export function applyStylesToSelection(instance, styles) {
|
||||
if (isEditor(instance)) {
|
||||
if (isTextEditor(instance)) {
|
||||
return instance.applyStylesToSelection(styles);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes the current instance resources by nullifying
|
||||
* every property.
|
||||
*
|
||||
* @param {TextEditor} instance
|
||||
* @returns {TextEditor|null}
|
||||
*/
|
||||
export function dispose(instance) {
|
||||
if (isEditor(instance)) {
|
||||
instance.dispose();
|
||||
if (isTextEditor(instance)) {
|
||||
return instance.dispose();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default TextEditor;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
mapContentFragmentFromHTML,
|
||||
mapContentFragmentFromString,
|
||||
} from "../content/dom/Content.js";
|
||||
import { TextEditor } from "../TextEditor.js";
|
||||
|
||||
/**
|
||||
* Returns a DocumentFragment from text/html.
|
||||
@@ -38,19 +39,26 @@ function getPlainFragmentFromClipboardData(selectionController, clipboardData) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a DocumentFragment (or null) if it contains
|
||||
* a compatible clipboardData type.
|
||||
* Returns a document fragment of html data.
|
||||
*
|
||||
* @param {DataTransfer} clipboardData
|
||||
* @returns {DocumentFragment|null}
|
||||
* @returns {DocumentFragment}
|
||||
*/
|
||||
function getFragmentFromClipboardData(selectionController, clipboardData) {
|
||||
function getFormattedOrPlainFragmentFromClipboardData(
|
||||
selectionController,
|
||||
clipboardData,
|
||||
) {
|
||||
if (clipboardData.types.includes("text/html")) {
|
||||
return getFormattedFragmentFromClipboardData(selectionController, clipboardData)
|
||||
return getFormattedFragmentFromClipboardData(
|
||||
selectionController,
|
||||
clipboardData,
|
||||
);
|
||||
} else if (clipboardData.types.includes("text/plain")) {
|
||||
return getPlainFragmentFromClipboardData(selectionController, clipboardData)
|
||||
return getPlainFragmentFromClipboardData(
|
||||
selectionController,
|
||||
clipboardData,
|
||||
);
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,18 +79,37 @@ export function paste(event, editor, selectionController) {
|
||||
|
||||
let fragment = null;
|
||||
if (editor?.options?.allowHTMLPaste) {
|
||||
fragment = getFragmentFromClipboardData(selectionController, event.clipboardData);
|
||||
fragment = getFormattedOrPlainFragmentFromClipboardData(event.clipboardData);
|
||||
} else {
|
||||
fragment = getPlainFragmentFromClipboardData(selectionController, event.clipboardData);
|
||||
}
|
||||
|
||||
if (!fragment) {
|
||||
// NOOP
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectionController.isCollapsed) {
|
||||
selectionController.insertPaste(fragment);
|
||||
const hasOnlyOneParagraph = fragment.children.length === 1;
|
||||
const hasOnlyOneTextSpan = fragment.firstElementChild.children.length === 1;
|
||||
const forceTextSpan = fragment.firstElementChild.dataset.textSpan === "force";
|
||||
if (hasOnlyOneParagraph
|
||||
&& hasOnlyOneTextSpan
|
||||
&& forceTextSpan) {
|
||||
selectionController.insertIntoFocus(fragment.textContent);
|
||||
} else {
|
||||
selectionController.insertPaste(fragment);
|
||||
}
|
||||
} else {
|
||||
selectionController.replaceWithPaste(fragment);
|
||||
const hasOnlyOneParagraph = fragment.children.length === 1;
|
||||
const hasOnlyOneTextSpan = fragment.firstElementChild.children.length === 1;
|
||||
const forceTextSpan = fragment.firstElementChild.dataset.textSpan === "force";
|
||||
if (hasOnlyOneParagraph
|
||||
&& hasOnlyOneTextSpan
|
||||
&& forceTextSpan) {
|
||||
selectionController.replaceText(fragment.textContent);
|
||||
} else {
|
||||
selectionController.replaceWithPaste(fragment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,14 +230,19 @@ export function mapContentFragmentFromString(string, styleDefaults) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const line of lines) {
|
||||
if (line === "") {
|
||||
fragment.appendChild(createEmptyParagraph(styleDefaults));
|
||||
} else {
|
||||
fragment.appendChild(
|
||||
createParagraph(
|
||||
[createTextSpan(new Text(line), styleDefaults)],
|
||||
styleDefaults,
|
||||
),
|
||||
createEmptyParagraph(styleDefaults)
|
||||
);
|
||||
} else {
|
||||
const textSpan = createTextSpan(new Text(line), styleDefaults);
|
||||
const paragraph = createParagraph(
|
||||
[textSpan],
|
||||
styleDefaults,
|
||||
);
|
||||
if (lines.length === 1) {
|
||||
paragraph.dataset.textSpan = "force";
|
||||
}
|
||||
fragment.appendChild(paragraph);
|
||||
}
|
||||
}
|
||||
return fragment;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* Copyright (c) KALEIDOS INC
|
||||
*/
|
||||
|
||||
import StyleDeclaration from '../../controllers/StyleDeclaration.js';
|
||||
import { getFills } from "./Color.js";
|
||||
|
||||
const DEFAULT_FONT_SIZE = "16px";
|
||||
@@ -338,13 +339,14 @@ export function setStylesFromObject(element, allowedStyles, styleObject) {
|
||||
continue;
|
||||
}
|
||||
let styleValue = styleObject[styleName];
|
||||
if (!styleValue)
|
||||
continue;
|
||||
|
||||
if (styleName === "font-family") {
|
||||
styleValue = sanitizeFontFamily(styleValue);
|
||||
}
|
||||
|
||||
if (styleValue) {
|
||||
setStyle(element, styleName, styleValue, styleUnit);
|
||||
}
|
||||
setStyle(element, styleName, styleValue, styleUnit);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
@@ -386,7 +388,8 @@ export function setStylesFromDeclaration(
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
export function setStyles(element, allowedStyles, styleObjectOrDeclaration) {
|
||||
if (styleObjectOrDeclaration instanceof CSSStyleDeclaration) {
|
||||
if (styleObjectOrDeclaration instanceof CSSStyleDeclaration
|
||||
|| styleObjectOrDeclaration instanceof StyleDeclaration) {
|
||||
return setStylesFromDeclaration(
|
||||
element,
|
||||
allowedStyles,
|
||||
@@ -426,13 +429,15 @@ export function mergeStyles(allowedStyles, styleDeclaration, newStyles) {
|
||||
const mergedStyles = {};
|
||||
for (const [styleName, styleUnit] of allowedStyles) {
|
||||
if (styleName in newStyles) {
|
||||
mergedStyles[styleName] = newStyles[styleName];
|
||||
const styleValue = newStyles[styleName];
|
||||
mergedStyles[styleName] = styleValue;
|
||||
} else {
|
||||
mergedStyles[styleName] = getStyleFromDeclaration(
|
||||
const styleValue = getStyleFromDeclaration(
|
||||
styleDeclaration,
|
||||
styleName,
|
||||
styleUnit,
|
||||
);
|
||||
mergedStyles[styleName] = styleValue;
|
||||
}
|
||||
}
|
||||
return mergedStyles;
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
* Copyright (c) KALEIDOS INC
|
||||
*/
|
||||
|
||||
import SafeGuard from '../../controllers/SafeGuard.js';
|
||||
|
||||
/**
|
||||
* Iterator direction.
|
||||
*
|
||||
@@ -245,6 +247,51 @@ export class TextNodeIterator {
|
||||
this.#currentNode = previousNode;
|
||||
return this.#currentNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of text nodes.
|
||||
*
|
||||
* @param {TextNode} startNode
|
||||
* @param {TextNode} endNode
|
||||
* @returns {Array<TextNode>}
|
||||
*/
|
||||
collectFrom(startNode, endNode) {
|
||||
const nodes = [];
|
||||
for (const node of this.iterateFrom(startNode, endNode)) {
|
||||
nodes.push(node);
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterates over a list of nodes.
|
||||
*
|
||||
* @param {TextNode} startNode
|
||||
* @param {TextNode} endNode
|
||||
* @yields {TextNode}
|
||||
*/
|
||||
* iterateFrom(startNode, endNode) {
|
||||
const comparedPosition = startNode.compareDocumentPosition(
|
||||
endNode
|
||||
);
|
||||
this.#currentNode = startNode;
|
||||
SafeGuard.start();
|
||||
while (this.#currentNode !== endNode) {
|
||||
yield this.#currentNode;
|
||||
SafeGuard.update();
|
||||
if (comparedPosition === Node.DOCUMENT_POSITION_PRECEDING) {
|
||||
if (!this.previousNode()) {
|
||||
break;
|
||||
}
|
||||
} else if (comparedPosition === Node.DOCUMENT_POSITION_FOLLOWING) {
|
||||
if (!this.nextNode()) {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default TextNodeIterator;
|
||||
|
||||
@@ -28,7 +28,20 @@ export function update() {
|
||||
}
|
||||
}
|
||||
|
||||
let timeoutId = 0
|
||||
export function throwAfter(error, timeout = SAFE_GUARD_TIME) {
|
||||
timeoutId = setTimeout(() => {
|
||||
throw error
|
||||
}, timeout)
|
||||
}
|
||||
|
||||
export function throwCancel() {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
|
||||
export default {
|
||||
start,
|
||||
update,
|
||||
}
|
||||
throwAfter,
|
||||
throwCancel,
|
||||
};
|
||||
|
||||
@@ -54,6 +54,7 @@ import { isRoot, setRootStyles } from "../content/dom/Root.js";
|
||||
import { SelectionDirection } from "./SelectionDirection.js";
|
||||
import SafeGuard from "./SafeGuard.js";
|
||||
import { sanitizeFontFamily } from "../content/dom/Style.js";
|
||||
import StyleDeclaration from './StyleDeclaration.js';
|
||||
|
||||
/**
|
||||
* Supported options for the SelectionController.
|
||||
@@ -64,39 +65,7 @@ import { sanitizeFontFamily } from "../content/dom/Style.js";
|
||||
|
||||
/**
|
||||
* SelectionController uses the same concepts used by the Selection API but extending it to support
|
||||
* our own internal model based on paragraphs (in drafconst textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([
|
||||
createParagraph([createTextSpan(new Text("Hello, "))]),
|
||||
createEmptyParagraph(),
|
||||
createParagraph([createTextSpan(new Text("World!"))]),
|
||||
]);
|
||||
const root = textEditorMock.root;
|
||||
const selection = document.getSelection();
|
||||
const selectionController = new SelectionController(
|
||||
textEditorMock,
|
||||
selection
|
||||
);
|
||||
focus(
|
||||
selection,
|
||||
textEditorMock,
|
||||
root.childNodes.item(2).firstChild.firstChild,
|
||||
0
|
||||
);
|
||||
selectionController.mergeBackwardParagraph();
|
||||
expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement);
|
||||
expect(textEditorMock.root.children.length).toBe(2);
|
||||
expect(textEditorMock.root.dataset.itype).toBe("root");
|
||||
expect(textEditorMock.root.firstChild).toBeInstanceOf(HTMLDivElement);
|
||||
expect(textEditorMock.root.firstChild.dataset.itype).toBe("paragraph");
|
||||
expect(textEditorMock.root.firstChild.firstChild).toBeInstanceOf(
|
||||
HTMLSpanElement
|
||||
);
|
||||
expect(textEditorMock.root.firstChild.firstChild.dataset.itype).toBe(
|
||||
"span"
|
||||
);
|
||||
expect(textEditorMock.root.textContent).toBe("Hello, World!");
|
||||
expect(textEditorMock.root.firstChild.textContent).toBe("Hello, ");
|
||||
expect(textEditorMock.root.lastChild.textContent).toBe("World!");
|
||||
t.js they were called blocks) and text spans.
|
||||
* our own internal model based on paragraphs (in draft.js they were called blocks) and text spans.
|
||||
*/
|
||||
export class SelectionController extends EventTarget {
|
||||
/**
|
||||
@@ -164,21 +133,12 @@ export class SelectionController extends EventTarget {
|
||||
#textNodeIterator = null;
|
||||
|
||||
/**
|
||||
* CSSStyleDeclaration that we can mutate
|
||||
* StyleDeclaration that we can mutate
|
||||
* to handle style changes.
|
||||
*
|
||||
* @type {CSSStyleDeclaration}
|
||||
* @type {StyleDeclaration}
|
||||
*/
|
||||
#currentStyle = null;
|
||||
|
||||
/**
|
||||
* Element used to have a custom CSSStyleDeclaration
|
||||
* that we can modify to handle style changes when the
|
||||
* selection is changed.
|
||||
*
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#inertElement = null;
|
||||
#currentStyle = new StyleDeclaration();
|
||||
|
||||
/**
|
||||
* @type {SelectionControllerDebug}
|
||||
@@ -275,19 +235,62 @@ export class SelectionController extends EventTarget {
|
||||
*
|
||||
* @param {HTMLElement} element
|
||||
*/
|
||||
#applyStylesToCurrentStyle(element) {
|
||||
#applyStylesFromElementToCurrentStyle(element) {
|
||||
for (let index = 0; index < element.style.length; index++) {
|
||||
const styleName = element.style.item(index);
|
||||
if (styleName === "--fills") {
|
||||
continue;
|
||||
}
|
||||
let styleValue = element.style.getPropertyValue(styleName);
|
||||
|
||||
if (styleName === "font-family") {
|
||||
styleValue = sanitizeFontFamily(styleValue);
|
||||
}
|
||||
this.#currentStyle.setProperty(styleName, styleValue);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies some styles to the currentStyle
|
||||
* CSSStyleDeclaration
|
||||
*
|
||||
* @param {HTMLElement} element
|
||||
*/
|
||||
#mergeStylesFromElementToCurrentStyle(element) {
|
||||
for (let index = 0; index < element.style.length; index++) {
|
||||
const styleName = element.style.item(index);
|
||||
let styleValue = element.style.getPropertyValue(styleName);
|
||||
if (styleName === "font-family") {
|
||||
styleValue = sanitizeFontFamily(styleValue);
|
||||
}
|
||||
|
||||
this.#currentStyle.setProperty(styleName, styleValue);
|
||||
this.#currentStyle.mergeProperty(styleName, styleValue);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates current styles based on the currently selected text spans.
|
||||
*
|
||||
* @param {HTMLSpanElement} startNode
|
||||
* @param {HTMLSpanElement} endNode
|
||||
*/
|
||||
#updateCurrentStyleFrom(startNode, endNode) {
|
||||
this.#applyDefaultStylesToCurrentStyle();
|
||||
const root = startNode.parentElement.parentElement.parentElement;
|
||||
this.#applyStylesFromElementToCurrentStyle(root);
|
||||
// FIXME: I don't like this approximation. Having to iterate nodes twice
|
||||
// is bad for performance. I think we need another way of "computing"
|
||||
// the cascade.
|
||||
for (const textNode of this.#textNodeIterator.iterateFrom(startNode, endNode)) {
|
||||
const paragraph = textNode.parentElement.parentElement;
|
||||
this.#applyStylesFromElementToCurrentStyle(paragraph);
|
||||
}
|
||||
for (const textNode of this.#textNodeIterator.iterateFrom(startNode, endNode)) {
|
||||
const textSpan = textNode.parentElement;
|
||||
this.#mergeStylesFromElementToCurrentStyle(textSpan);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates current styles based on the currently selected text span.
|
||||
*
|
||||
@@ -297,20 +300,14 @@ export class SelectionController extends EventTarget {
|
||||
#updateCurrentStyle(textSpan) {
|
||||
this.#applyDefaultStylesToCurrentStyle();
|
||||
const root = textSpan.parentElement.parentElement;
|
||||
this.#applyStylesToCurrentStyle(root);
|
||||
this.#applyStylesFromElementToCurrentStyle(root);
|
||||
const paragraph = textSpan.parentElement;
|
||||
this.#applyStylesToCurrentStyle(paragraph);
|
||||
this.#applyStylesToCurrentStyle(textSpan);
|
||||
this.#applyStylesFromElementToCurrentStyle(paragraph);
|
||||
this.#applyStylesFromElementToCurrentStyle(textSpan);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is called on every `selectionchange` because it is dispatched
|
||||
* only by the `document` object.
|
||||
*
|
||||
* @param {Event} e
|
||||
*/
|
||||
#onSelectionChange = (e) => {
|
||||
#updateState() {
|
||||
// If we're outside the contenteditable element, then
|
||||
// we return.
|
||||
if (!this.hasFocus) {
|
||||
@@ -320,17 +317,23 @@ export class SelectionController extends EventTarget {
|
||||
let focusNodeChanges = false;
|
||||
let anchorNodeChanges = false;
|
||||
|
||||
if (this.#focusNode !== this.#selection.focusNode) {
|
||||
if (
|
||||
this.#focusNode !== this.#selection.focusNode ||
|
||||
this.#focusOffset !== this.#selection.focusOffset
|
||||
) {
|
||||
this.#focusNode = this.#selection.focusNode;
|
||||
this.#focusOffset = this.#selection.focusOffset;
|
||||
focusNodeChanges = true;
|
||||
}
|
||||
this.#focusOffset = this.#selection.focusOffset;
|
||||
|
||||
if (this.#anchorNode !== this.#selection.anchorNode) {
|
||||
if (
|
||||
this.#anchorNode !== this.#selection.anchorNode ||
|
||||
this.#anchorOffset !== this.#selection.anchorOffset
|
||||
) {
|
||||
this.#anchorNode = this.#selection.anchorNode;
|
||||
this.#anchorOffset = this.#selection.anchorOffset;
|
||||
anchorNodeChanges = true;
|
||||
}
|
||||
this.#anchorOffset = this.#selection.anchorOffset;
|
||||
|
||||
// We need to handle multi selection from firefox
|
||||
// and remove all the old ranges and just keep the
|
||||
@@ -359,7 +362,7 @@ export class SelectionController extends EventTarget {
|
||||
// If focus node changed, we need to retrieve all the
|
||||
// styles of the current text span and dispatch an event
|
||||
// to notify that the styles have changed.
|
||||
if (focusNodeChanges) {
|
||||
if (focusNodeChanges || anchorNodeChanges) {
|
||||
this.#notifyStyleChange();
|
||||
}
|
||||
|
||||
@@ -372,43 +375,42 @@ export class SelectionController extends EventTarget {
|
||||
if (this.#debug) {
|
||||
this.#debug.update(this);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* This is called on every `selectionchange` because it is dispatched
|
||||
* only by the `document` object.
|
||||
*
|
||||
* @param {Event} e
|
||||
*/
|
||||
#onSelectionChange = (_) => this.#updateState();
|
||||
|
||||
/**
|
||||
* Notifies that the styles have changed.
|
||||
*/
|
||||
#notifyStyleChange() {
|
||||
const textSpan = this.focusTextSpan;
|
||||
if (textSpan) {
|
||||
this.#updateCurrentStyle(textSpan);
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("stylechange", {
|
||||
detail: this.#currentStyle,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const firstTextSpan =
|
||||
if (this.#selection.isCollapsed) {
|
||||
// CARET
|
||||
const textSpan =
|
||||
this.focusTextSpan ??
|
||||
this.#textEditor.root?.firstElementChild?.firstElementChild;
|
||||
if (firstTextSpan) {
|
||||
this.#updateCurrentStyle(firstTextSpan);
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("stylechange", {
|
||||
detail: this.#currentStyle,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
this.#updateCurrentStyle(textSpan);
|
||||
} else {
|
||||
// SELECTION.
|
||||
this.#updateCurrentStyleFrom(this.#anchorNode, this.#focusNode);
|
||||
}
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("stylechange", {
|
||||
detail: this.#currentStyle,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setups
|
||||
*/
|
||||
#setup() {
|
||||
// This element is not attached to the DOM
|
||||
// so it doesn't trigger style or layout calculations.
|
||||
// That's why it's called "inertElement".
|
||||
this.#inertElement = document.createElement("div");
|
||||
this.#currentStyle = this.#inertElement.style;
|
||||
this.#applyDefaultStylesToCurrentStyle();
|
||||
|
||||
if (this.#selection.rangeCount > 0) {
|
||||
@@ -428,6 +430,22 @@ export class SelectionController extends EventTarget {
|
||||
document.addEventListener("selectionchange", this.#onSelectionChange);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes the current resources.
|
||||
*/
|
||||
dispose() {
|
||||
document.removeEventListener("selectionchange", this.#onSelectionChange);
|
||||
this.#textEditor = null;
|
||||
this.#ranges.clear();
|
||||
this.#ranges = null;
|
||||
this.#range = null;
|
||||
this.#selection = null;
|
||||
this.#focusNode = null;
|
||||
this.#anchorNode = null;
|
||||
this.#mutations.dispose();
|
||||
this.#mutations = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Range-like object.
|
||||
*
|
||||
@@ -502,6 +520,8 @@ export class SelectionController extends EventTarget {
|
||||
* Marks the start of a mutation.
|
||||
*
|
||||
* Clears all the mutations kept in CommandMutations.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
startMutation() {
|
||||
this.#mutations.clear();
|
||||
@@ -512,7 +532,7 @@ export class SelectionController extends EventTarget {
|
||||
/**
|
||||
* Marks the end of a mutation.
|
||||
*
|
||||
* @returns
|
||||
* @returns {CommandMutations}
|
||||
*/
|
||||
endMutation() {
|
||||
return this.#mutations;
|
||||
@@ -520,6 +540,8 @@ export class SelectionController extends EventTarget {
|
||||
|
||||
/**
|
||||
* Selects all content.
|
||||
*
|
||||
* @returns {SelectionController}
|
||||
*/
|
||||
selectAll() {
|
||||
if (this.#textEditor.isEmpty) {
|
||||
@@ -558,23 +580,15 @@ export class SelectionController extends EventTarget {
|
||||
this.#selection.removeAllRanges();
|
||||
this.#selection.addRange(range);
|
||||
|
||||
// Ensure internal state is synchronized
|
||||
this.#focusNode = this.#selection.focusNode;
|
||||
this.#focusOffset = this.#selection.focusOffset;
|
||||
this.#anchorNode = this.#selection.anchorNode;
|
||||
this.#anchorOffset = this.#selection.anchorOffset;
|
||||
this.#range = range;
|
||||
this.#ranges.clear();
|
||||
this.#ranges.add(range);
|
||||
|
||||
// Notify style changes
|
||||
this.#notifyStyleChange();
|
||||
this.#updateState();
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves cursor to end.
|
||||
*
|
||||
* @returns {SelectionController}
|
||||
*/
|
||||
cursorToEnd() {
|
||||
const range = document.createRange(); //Create a range (a range is a like the selection but invisible)
|
||||
@@ -662,22 +676,6 @@ export class SelectionController extends EventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes the current resources.
|
||||
*/
|
||||
dispose() {
|
||||
document.removeEventListener("selectionchange", this.#onSelectionChange);
|
||||
this.#textEditor = null;
|
||||
this.#ranges.clear();
|
||||
this.#ranges = null;
|
||||
this.#range = null;
|
||||
this.#selection = null;
|
||||
this.#focusNode = null;
|
||||
this.#anchorNode = null;
|
||||
this.#mutations.dispose();
|
||||
this.#mutations = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current selection.
|
||||
*
|
||||
@@ -1114,8 +1112,8 @@ export class SelectionController extends EventTarget {
|
||||
return isParagraphEnd(this.focusNode, this.focusOffset);
|
||||
}
|
||||
|
||||
#getFragmentInlineTextNode(fragment) {
|
||||
if (isInline(fragment.firstElementChild.lastChild)) {
|
||||
#getFragmentTextSpanTextNode(fragment) {
|
||||
if (isTextSpan(fragment.firstElementChild.lastChild)) {
|
||||
return fragment.firstElementChild.firstElementChild.lastChild;
|
||||
}
|
||||
return fragment.firstElementChild.lastChild;
|
||||
@@ -1131,11 +1129,15 @@ export class SelectionController extends EventTarget {
|
||||
* @param {DocumentFragment} fragment
|
||||
*/
|
||||
insertPaste(fragment) {
|
||||
const hasOnlyOneParagraph = fragment.children.length === 1;
|
||||
const forceTextSpan =
|
||||
fragment.firstElementChild?.dataset?.textSpan === "force";
|
||||
if (
|
||||
fragment.children.length === 1 &&
|
||||
fragment.firstElementChild?.dataset?.textSpan === "force"
|
||||
hasOnlyOneParagraph &&
|
||||
forceTextSpan
|
||||
) {
|
||||
const collapseNode = fragment.firstElementChild.firstChild;
|
||||
// first text span
|
||||
const collapseNode = fragment.firstElementChild.firstElementChild;
|
||||
if (this.isTextSpanStart) {
|
||||
this.focusTextSpan.before(...fragment.firstElementChild.children);
|
||||
} else if (this.isTextSpanEnd) {
|
||||
@@ -1147,7 +1149,9 @@ export class SelectionController extends EventTarget {
|
||||
newTextSpan,
|
||||
);
|
||||
}
|
||||
return this.collapse(collapseNode, collapseNode.nodeValue?.length || 0);
|
||||
// collapseNode could be a <br>, that's why we need to
|
||||
// make `nodeValue` as optional.
|
||||
return this.collapse(collapseNode, collapseNode?.nodeValue?.length || 0);
|
||||
}
|
||||
const collapseNode = this.#getFragmentParagraphTextNode(fragment);
|
||||
if (this.isParagraphStart) {
|
||||
@@ -1393,9 +1397,16 @@ export class SelectionController extends EventTarget {
|
||||
this.focusOffset,
|
||||
newText,
|
||||
);
|
||||
this.collapse(this.focusNode, this.focusOffset + newText.length);
|
||||
} else if (this.isLineBreakFocus) {
|
||||
const textNode = new Text(newText);
|
||||
this.focusNode.replaceWith(textNode);
|
||||
// the focus node is a <span>.
|
||||
if (isTextSpan(this.focusNode)) {
|
||||
this.focusNode.firstElementChild.replaceWith(textNode);
|
||||
// the focus node is a <br>.
|
||||
} else {
|
||||
this.focusNode.replaceWith(textNode);
|
||||
}
|
||||
this.collapse(textNode, newText.length);
|
||||
} else {
|
||||
throw new Error("Unknown node type");
|
||||
@@ -1932,11 +1943,21 @@ export class SelectionController extends EventTarget {
|
||||
const textSpan = this.startTextSpan;
|
||||
const midText = startNode.splitText(startOffset);
|
||||
const endText = midText.splitText(endOffset - startOffset);
|
||||
const midTextSpan = createTextSpanFrom(textSpan, midText, newStyles);
|
||||
textSpan.after(midTextSpan);
|
||||
if (endText.length > 0) {
|
||||
const endTextSpan = createTextSpan(endText, textSpan.style);
|
||||
midTextSpan.after(endTextSpan);
|
||||
|
||||
// Only create text span if midText is not empty
|
||||
if (midText.nodeValue && midText.nodeValue.length > 0) {
|
||||
const midTextSpan = createTextSpanFrom(textSpan, midText, newStyles);
|
||||
textSpan.after(midTextSpan);
|
||||
if (endText.length > 0) {
|
||||
const endTextSpan = createTextSpan(endText, textSpan.style);
|
||||
midTextSpan.after(endTextSpan);
|
||||
}
|
||||
} else {
|
||||
// If midText is empty, just create endTextSpan if needed
|
||||
if (endText.length > 0) {
|
||||
const endTextSpan = createTextSpan(endText, textSpan.style);
|
||||
textSpan.after(endTextSpan);
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: This is necessary because sometimes
|
||||
@@ -1953,16 +1974,21 @@ export class SelectionController extends EventTarget {
|
||||
// the styles are applied to the current caret
|
||||
else if (
|
||||
this.startOffset === this.endOffset &&
|
||||
this.endOffset === endNode.nodeValue.length
|
||||
this.endOffset === endNode.nodeValue?.length
|
||||
) {
|
||||
const newTextSpan = createVoidTextSpan(newStyles);
|
||||
this.endTextSpan.after(newTextSpan);
|
||||
this.setSelection(newTextSpan.firstChild, 0, newTextSpan.firstChild, 0);
|
||||
}
|
||||
// The styles are applied to the paragraph
|
||||
else {
|
||||
else
|
||||
{
|
||||
const paragraph = this.startParagraph;
|
||||
setParagraphStyles(paragraph, newStyles);
|
||||
// Apply styles to child text spans.
|
||||
for (const textSpan of paragraph.children) {
|
||||
setTextSpanStyles(textSpan, newStyles);
|
||||
}
|
||||
}
|
||||
return this.#notifyStyleChange();
|
||||
|
||||
@@ -1984,7 +2010,8 @@ export class SelectionController extends EventTarget {
|
||||
// new text span.
|
||||
if (
|
||||
this.#textNodeIterator.currentNode === startNode &&
|
||||
startOffset > 0
|
||||
startOffset > 0 &&
|
||||
startOffset < (startNode.nodeValue?.length || 0)
|
||||
) {
|
||||
const newTextSpan = splitTextSpan(textSpan, startOffset);
|
||||
setTextSpanStyles(newTextSpan, newStyles);
|
||||
@@ -1999,14 +2026,15 @@ export class SelectionController extends EventTarget {
|
||||
(this.#textNodeIterator.currentNode !== startNode &&
|
||||
this.#textNodeIterator.currentNode !== endNode) ||
|
||||
(this.#textNodeIterator.currentNode === endNode &&
|
||||
endOffset === endNode.nodeValue.length)
|
||||
endOffset === endNode.nodeValue?.length)
|
||||
) {
|
||||
setTextSpanStyles(textSpan, newStyles);
|
||||
|
||||
// If we're at end node
|
||||
} else if (
|
||||
this.#textNodeIterator.currentNode === endNode &&
|
||||
endOffset < endNode.nodeValue.length
|
||||
endOffset < endNode.nodeValue?.length &&
|
||||
endOffset > 0
|
||||
) {
|
||||
const newTextSpan = splitTextSpan(textSpan, endOffset);
|
||||
setTextSpanStyles(textSpan, newStyles);
|
||||
|
||||
110
frontend/text-editor/src/editor/controllers/StyleDeclaration.js
Normal file
110
frontend/text-editor/src/editor/controllers/StyleDeclaration.js
Normal file
@@ -0,0 +1,110 @@
|
||||
export class StyleDeclaration {
|
||||
static Property = class Property {
|
||||
static NULL = '["~#\'",null]';
|
||||
|
||||
static Default = new Property("", "", "");
|
||||
|
||||
name;
|
||||
value = "";
|
||||
priority = "";
|
||||
|
||||
constructor(name, value = "", priority = "") {
|
||||
this.name = name;
|
||||
this.value = value ?? "";
|
||||
this.priority = priority ?? "";
|
||||
}
|
||||
};
|
||||
|
||||
#items = new Map();
|
||||
|
||||
get cssFloat() {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
get cssText() {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
get parentRule() {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
get length() {
|
||||
return this.#items.size;
|
||||
}
|
||||
|
||||
#getProperty(name) {
|
||||
return this.#items.get(name) ?? StyleDeclaration.Property.Default;
|
||||
}
|
||||
|
||||
getPropertyPriority(name) {
|
||||
const { priority } = this.#getProperty(name);
|
||||
return priority ?? "";
|
||||
}
|
||||
|
||||
getPropertyValue(name) {
|
||||
const { value } = this.#getProperty(name);
|
||||
return value ?? "";
|
||||
}
|
||||
|
||||
item(index) {
|
||||
return Array.from(this.#items).at(index).name;
|
||||
}
|
||||
|
||||
removeProperty(name) {
|
||||
const value = this.getPropertyValue(name);
|
||||
this.#items.delete(name);
|
||||
return value;
|
||||
}
|
||||
|
||||
setProperty(name, value, priority) {
|
||||
this.#items.set(name, new StyleDeclaration.Property(name, value, priority));
|
||||
}
|
||||
|
||||
/** Non compatible methods */
|
||||
#isQuotedValue(a, b) {
|
||||
if (a.startsWith('"') && b.startsWith('"')) {
|
||||
return a === b;
|
||||
} else if (a.startsWith('"') && !b.startsWith('"')) {
|
||||
return a.slice(1, -1) === b;
|
||||
} else if (!a.startsWith('"') && b.startsWith('"')) {
|
||||
return a === b.slice(1, -1);
|
||||
}
|
||||
return a === b;
|
||||
}
|
||||
|
||||
mergeProperty(name, value) {
|
||||
const currentValue = this.getPropertyValue(name);
|
||||
if (this.#isQuotedValue(currentValue, value)) {
|
||||
return this.setProperty(name, value);
|
||||
} else if (currentValue === "" && value === StyleDeclaration.Property.NULL) {
|
||||
return this.setProperty(name, value);
|
||||
} else if (currentValue === "" && ["initial", "none"].includes(value)) {
|
||||
return this.setProperty(name, value);
|
||||
} else if (currentValue !== value && name === "--fills") {
|
||||
return this.setProperty(name, value);
|
||||
} else if (currentValue !== value) {
|
||||
return this.setProperty(name, "mixed");
|
||||
}
|
||||
}
|
||||
|
||||
fromCSSStyleDeclaration(cssStyleDeclaration) {
|
||||
for (let index = 0; index < cssStyleDeclaration.length; index++) {
|
||||
const name = cssStyleDeclaration.item(index);
|
||||
const value = cssStyleDeclaration.getPropertyValue(name);
|
||||
const priority = cssStyleDeclaration.getPropertyPriority(name);
|
||||
this.setProperty(name, value, priority);
|
||||
}
|
||||
}
|
||||
|
||||
toObject() {
|
||||
return Object.fromEntries(
|
||||
Array.from(this.#items.entries(), ([name, property]) => [
|
||||
name,
|
||||
property.value,
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default StyleDeclaration
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { StyleDeclaration } from "./StyleDeclaration.js";
|
||||
|
||||
describe("StyleDeclaration", () => {
|
||||
test("Create a new StyleDeclaration", () => {
|
||||
const styleDeclaration = new StyleDeclaration();
|
||||
expect(styleDeclaration).toBeInstanceOf(StyleDeclaration);
|
||||
});
|
||||
|
||||
test("Uninmplemented getters should throw", () => {
|
||||
expect(() => styleDeclaration.cssFloat).toThrow();
|
||||
expect(() => styleDeclaration.cssText).toThrow();
|
||||
expect(() => styleDeclaration.parentRule).toThrow();
|
||||
});
|
||||
|
||||
test("Set property", () => {
|
||||
const styleDeclaration = new StyleDeclaration();
|
||||
styleDeclaration.setProperty("line-height", "1.2");
|
||||
expect(styleDeclaration.getPropertyValue("line-height")).toBe("1.2");
|
||||
expect(styleDeclaration.getPropertyPriority("line-height")).toBe("");
|
||||
});
|
||||
|
||||
test("Remove property", () => {
|
||||
const styleDeclaration = new StyleDeclaration();
|
||||
styleDeclaration.setProperty("line-height", "1.2");
|
||||
expect(styleDeclaration.getPropertyValue("line-height")).toBe("1.2");
|
||||
expect(styleDeclaration.getPropertyPriority("line-height")).toBe("");
|
||||
styleDeclaration.removeProperty("line-height");
|
||||
expect(styleDeclaration.getPropertyValue("line-height")).toBe("");
|
||||
expect(styleDeclaration.getPropertyPriority("line-height")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -7745,6 +7745,10 @@ msgstr ""
|
||||
"Invalid token value: only none, Uppercase, Lowercase or Capitalize are "
|
||||
"accepted"
|
||||
|
||||
#: src/app/main/data/workspace/tokens/errors.cljs:93
|
||||
msgid "workspace.tokens.invalid-font-family-token-value"
|
||||
msgstr "Invalid token value: you can only reference a font-family token"
|
||||
|
||||
#: src/app/main/data/workspace/tokens/errors.cljs:85
|
||||
msgid "workspace.tokens.invalid-text-decoration-token-value"
|
||||
msgstr "Invalid token value: only none, underline and strike-through are accepted"
|
||||
|
||||
@@ -7670,6 +7670,14 @@ msgstr ""
|
||||
msgid "workspace.tokens.invalid-shadow-type-token-value"
|
||||
msgstr "Tipo de sombra no válida: solo se aceptan 'innerShadow' o 'dropShadow'"
|
||||
|
||||
#: src/app/main/data/workspace/tokens/errors.cljs:93
|
||||
msgid "workspace.tokens.invalid-font-family-token-value"
|
||||
msgstr "Valor de token no válido: solo puedes referenciar tokens tipo font-family"
|
||||
|
||||
#: src/app/main/data/workspace/tokens/errors.cljs:85
|
||||
msgid "workspace.tokens.invalid-text-decoration-token-value"
|
||||
msgstr "Valor de token no válido: solo none, underline y strike-through son aceptados"
|
||||
|
||||
#: src/app/main/data/workspace/tokens/errors.cljs:93
|
||||
msgid "workspace.tokens.invalid-token-value-typography"
|
||||
msgstr "Valor no válido: debe hacer referencia a un token tipográfico compuesto."
|
||||
|
||||
@@ -1226,16 +1226,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@penpot/svgo@penpot/svgo#v3.1":
|
||||
"@penpot/svgo@penpot/svgo#v3.2":
|
||||
version: 4.0.0
|
||||
resolution: "@penpot/svgo@https://github.com/penpot/svgo.git#commit=a46262c12c0d967708395972c374eb2adead4180"
|
||||
resolution: "@penpot/svgo@https://github.com/penpot/svgo.git#commit=8c9b0e32e9cb5f106085260bd9375f3c91a5010b"
|
||||
dependencies:
|
||||
"@trysound/sax": "npm:0.2.0"
|
||||
css-select: "npm:^5.1.0"
|
||||
css-tree: "npm:^3.1.0"
|
||||
csso: "npm:^5.0.5"
|
||||
lodash: "npm:^4.17.21"
|
||||
checksum: 10c0/db5f81c99dec2765721d73b69bb30594869ebf657380dfb46709c79775b6c0dc1af678fe9fe51bbe2272a2c78d19c2694a12ec6578bcc41235fa4aff475c9416
|
||||
checksum: 10c0/d7af2801451b97f8ffb17664147c609456f5bcc786c6d03b222546125260c0f268e750748311d61598e31f66610b00038d2b969635b1a15e5694647e19c6b63a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -4311,7 +4311,7 @@ __metadata:
|
||||
"@penpot/hljs": "portal:./vendor/hljs"
|
||||
"@penpot/mousetrap": "portal:./vendor/mousetrap"
|
||||
"@penpot/plugins-runtime": "npm:1.3.2"
|
||||
"@penpot/svgo": "penpot/svgo#v3.1"
|
||||
"@penpot/svgo": "penpot/svgo#v3.2"
|
||||
"@penpot/text-editor": "portal:./text-editor"
|
||||
"@playwright/test": "npm:1.52.0"
|
||||
"@storybook/addon-docs": "npm:10.0.4"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"license": "MPL-2.0",
|
||||
"author": "Kaleidos INC",
|
||||
"private": true,
|
||||
"packageManager": "yarn@4.9.1+sha512.f95ce356460e05be48d66401c1ae64ef84d163dd689964962c6888a9810865e39097a5e9de748876c2e0bf89b232d583c33982773e9903ae7a76257270986538",
|
||||
"packageManager": "yarn@4.12.0+sha512.f45ab632439a67f8bc759bf32ead036a1f413287b9042726b7cc4818b7b49e14e9423ba49b18f9e06ea4941c1ad062385b1d8760a8d5091a1a31e5f6219afca8",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/penpot/penpot"
|
||||
@@ -15,6 +15,7 @@
|
||||
"fmt": "./scripts/fmt"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.12.7"
|
||||
"@types/node": "^20.12.7",
|
||||
"esbuild": "^0.25.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
CURRENT_VERSION=${CURRENT_VERSION:-develop};
|
||||
export CURRENT_VERSION=${CURRENT_VERSION:-develop};
|
||||
|
||||
if [ "$NODE_ENV" = "production" ]; then
|
||||
export BUILD_MODE="release";
|
||||
@@ -8,14 +8,16 @@ else
|
||||
export BUILD_MODE=${1:-debug};
|
||||
fi
|
||||
|
||||
BUILD_NAME="${BUILD_NAME:-render-wasm}"
|
||||
export BUILD_NAME="${BUILD_NAME:-render-wasm}"
|
||||
export CARGO_BUILD_TARGET=${CARGO_BUILD_TARGET:-"wasm32-unknown-emscripten"};
|
||||
export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.87.0/skia-binaries-e551f334ad5cbdf43abf-wasm32-unknown-emscripten-gl-svg-textlayout-binary-cache-webp.tar.gz"}
|
||||
|
||||
# 256 MB of initial heap to perform less
|
||||
# initial calls to memory grow.
|
||||
EM_INITIAL_HEAP=$((256 * 1024 * 1024))
|
||||
export EM_INITIAL_HEAP=$((256 * 1024 * 1024))
|
||||
|
||||
# 1.0 doubles the heap on every growth.
|
||||
EM_MEMORY_GROWTH_GEOMETRIC_STEP="0.8"
|
||||
export EM_MEMORY_GROWTH_GEOMETRIC_STEP="0.8"
|
||||
|
||||
# Malloc implementation to use.
|
||||
# - dlmalloc: a powerful general-purpose malloc.
|
||||
@@ -25,38 +27,63 @@ EM_MEMORY_GROWTH_GEOMETRIC_STEP="0.8"
|
||||
# - emmalloc-verbose: use emmalloc with assertions + verbose logging.
|
||||
# - emmalloc-memvalidate-verbose: use emmalloc with assertions + heap consistency checking + verbose logging.
|
||||
# Default: dlmalloc
|
||||
EM_MALLOC="dlmalloc"
|
||||
export EM_MALLOC="dlmalloc"
|
||||
|
||||
EMCC_CFLAGS="--no-entry \
|
||||
export EMCC_CFLAGS="--no-entry \
|
||||
--js-library src/js/wapi.js \
|
||||
-sASSERTIONS=1 \
|
||||
-sALLOW_TABLE_GROWTH=1 \
|
||||
-sALLOW_MEMORY_GROWTH=1 \
|
||||
-sINITIAL_HEAP=$EM_INITIAL_HEAP \
|
||||
-sMEMORY_GROWTH_GEOMETRIC_STEP=$EM_MEMORY_GROWTH_GEOMETRIC_STEP \
|
||||
-sENVIRONMENT=web \
|
||||
-sERROR_ON_UNDEFINED_SYMBOLS=0 \
|
||||
-sMAX_WEBGL_VERSION=2 \
|
||||
-sMODULARIZE=1 \
|
||||
-sEXPORT_NAME=createRustSkiaModule \
|
||||
-sEXPORTED_RUNTIME_METHODS=GL,stringToUTF8,HEAPU8,HEAP32,HEAPU32,HEAPF32 \
|
||||
-sEXPORT_ES6=1"
|
||||
-sENVIRONMENT=web \
|
||||
-sMODULARIZE=1 \
|
||||
-sEXPORT_ES6=1";
|
||||
|
||||
export EM_CACHE="/tmp/emsdk_cache";
|
||||
|
||||
CARGO_PARAMS="${@:2}";
|
||||
export CARGO_PARAMS="${@:2}";
|
||||
|
||||
if [ "$BUILD_MODE" = "release" ]; then
|
||||
CARGO_PARAMS="--release $CARGO_PARAMS"
|
||||
EMCC_CFLAGS="-Os $EMCC_CFLAGS"
|
||||
export CARGO_PARAMS="--release $CARGO_PARAMS"
|
||||
export EMCC_CFLAGS="-Os $EMCC_CFLAGS"
|
||||
else
|
||||
# TODO: Extra parameters that could be good to look into:
|
||||
# -gseparate-dwarf
|
||||
# -gsplit-dwarf
|
||||
# -gsource-map
|
||||
EMCC_CFLAGS="-g $EMCC_CFLAGS -sVERBOSE=1 -sMALLOC=$EM_MALLOC"
|
||||
export EMCC_CFLAGS="-g $EMCC_CFLAGS -sVERBOSE=1 -sMALLOC=$EM_MALLOC"
|
||||
fi
|
||||
|
||||
export EMCC_CFLAGS;
|
||||
export CARGO_PARAMS;
|
||||
function clean {
|
||||
cargo clean;
|
||||
}
|
||||
|
||||
function build {
|
||||
cargo build $CARGO_PARAMS;
|
||||
}
|
||||
|
||||
function copy_artifacts {
|
||||
DEST=$1;
|
||||
|
||||
cp target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.js $DEST/$BUILD_NAME.js;
|
||||
cp target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.wasm $DEST/$BUILD_NAME.wasm;
|
||||
|
||||
sed -i "s/render_wasm.wasm/$BUILD_NAME.wasm?version=$CURRENT_VERSION/g" $DEST/$BUILD_NAME.js;
|
||||
|
||||
npx esbuild target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.js \
|
||||
--log-level=error \
|
||||
--outfile=$DEST/worker/render.js \
|
||||
--platform=neutral \
|
||||
--format=iife \
|
||||
--global-name=WasmModule;
|
||||
}
|
||||
|
||||
function copy_shared_artifact {
|
||||
SHARED_FILE=$(find target/wasm32-unknown-emscripten -name render_wasm_shared.js | head -n 1);
|
||||
cp $SHARED_FILE ../frontend/src/app/render_wasm/api/shared.js;
|
||||
}
|
||||
|
||||
@@ -7,19 +7,12 @@ pushd $_SCRIPT_DIR;
|
||||
|
||||
. ./_build_env
|
||||
|
||||
set -x
|
||||
set -x;
|
||||
|
||||
export CARGO_BUILD_TARGET=${CARGO_BUILD_TARGET:-"wasm32-unknown-emscripten"};
|
||||
export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.87.0/skia-binaries-e551f334ad5cbdf43abf-wasm32-unknown-emscripten-gl-svg-textlayout-binary-cache-webp.tar.gz"}
|
||||
build;
|
||||
copy_artifacts "../frontend/resources/public/js";
|
||||
copy_shared_artifact;
|
||||
|
||||
cargo build $CARGO_PARAMS
|
||||
|
||||
_SHARED_FILE=$(find target/wasm32-unknown-emscripten -name render_wasm_shared.js | head -n 1);
|
||||
|
||||
cat target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.js "$_SHARED_FILE" > ../frontend/resources/public/js/$BUILD_NAME.js
|
||||
cp target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.wasm ../frontend/resources/public/js/$BUILD_NAME.wasm
|
||||
sed -i "s/render_wasm.wasm/$BUILD_NAME.wasm?version=$CURRENT_VERSION/g" ../frontend/resources/public/js/$BUILD_NAME.js;
|
||||
|
||||
exit $?
|
||||
exit $?;
|
||||
|
||||
popd
|
||||
|
||||
Binary file not shown.
@@ -163,6 +163,19 @@ pub extern "C" fn render_sync() {
|
||||
pub extern "C" fn render_sync_shape(a: u32, b: u32, c: u32, d: u32) {
|
||||
with_state_mut!(state, {
|
||||
let id = uuid_from_u32_quartet(a, b, c, d);
|
||||
state.use_shape(id);
|
||||
|
||||
// look for an existing root shape, and create it if missing
|
||||
let mut was_root_missing = false;
|
||||
if !state.shapes.has(&Uuid::nil()) {
|
||||
state.shapes.add_shape(Uuid::nil());
|
||||
was_root_missing = true;
|
||||
}
|
||||
|
||||
if was_root_missing {
|
||||
state.set_parent_for_current_shape(Uuid::nil());
|
||||
}
|
||||
|
||||
state.rebuild_tiles_from(Some(&id));
|
||||
state
|
||||
.render_sync_shape(&id, performance::get_time())
|
||||
@@ -330,6 +343,10 @@ fn set_children_set(entries: Vec<Uuid>) {
|
||||
parent_id = Some(shape.id);
|
||||
(_, deleted) = shape.compute_children_differences(&entries);
|
||||
shape.children = entries.clone();
|
||||
|
||||
for id in entries {
|
||||
state.touch_shape(id);
|
||||
}
|
||||
});
|
||||
|
||||
with_state_mut!(state, {
|
||||
@@ -339,6 +356,7 @@ fn set_children_set(entries: Vec<Uuid>) {
|
||||
|
||||
for id in deleted {
|
||||
state.delete_shape_children(parent_id, id);
|
||||
state.touch_shape(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -650,6 +668,26 @@ pub extern "C" fn set_modifiers() {
|
||||
});
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn start_temp_objects() {
|
||||
unsafe {
|
||||
#[allow(static_mut_refs)]
|
||||
let mut state = STATE.take().expect("Got an invalid state pointer");
|
||||
state = Box::new(state.start_temp_objects());
|
||||
STATE = Some(state);
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn end_temp_objects() {
|
||||
unsafe {
|
||||
#[allow(static_mut_refs)]
|
||||
let mut state = STATE.take().expect("Got an invalid state pointer");
|
||||
state = Box::new(state.end_temp_objects());
|
||||
STATE = Some(state);
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
init_gl!();
|
||||
|
||||
@@ -34,9 +34,9 @@ pub use fonts::*;
|
||||
pub use images::*;
|
||||
|
||||
// This is the extra are used for tile rendering.
|
||||
const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 1;
|
||||
const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 2;
|
||||
const MAX_BLOCKING_TIME_MS: i32 = 32;
|
||||
const NODE_BATCH_THRESHOLD: i32 = 10;
|
||||
const NODE_BATCH_THRESHOLD: i32 = 3;
|
||||
|
||||
type ClipStack = Vec<(Rect, Option<Corners>, Matrix)>;
|
||||
|
||||
@@ -141,7 +141,20 @@ impl NodeRenderState {
|
||||
|
||||
match &element.shape_type {
|
||||
Type::Frame(_) => {
|
||||
let bounds = element.get_selrect_shadow_bounds(shadow);
|
||||
let mut bounds = element.get_selrect_shadow_bounds(shadow);
|
||||
let blur_inset = (shadow.blur * 2.).max(0.0);
|
||||
if blur_inset > 0.0 {
|
||||
let max_inset_x = (bounds.width() * 0.5).max(0.0);
|
||||
let max_inset_y = (bounds.height() * 0.5).max(0.0);
|
||||
// Clamp the inset so we never shrink more than half of the width/height;
|
||||
// otherwise the rect could end up inverted on small frames.
|
||||
let inset_x = blur_inset.min(max_inset_x);
|
||||
let inset_y = blur_inset.min(max_inset_y);
|
||||
if inset_x > 0.0 || inset_y > 0.0 {
|
||||
bounds.inset((inset_x, inset_y));
|
||||
}
|
||||
}
|
||||
|
||||
let mut transform = element.transform;
|
||||
transform.post_translate(element.center());
|
||||
transform.pre_translate(-element.center());
|
||||
@@ -1721,6 +1734,7 @@ impl RenderState {
|
||||
allow_stop: bool,
|
||||
) -> Result<(), String> {
|
||||
let mut should_stop = false;
|
||||
|
||||
while !should_stop {
|
||||
if let Some(current_tile) = self.current_tile {
|
||||
if self.surfaces.has_cached_tile_surface(current_tile) {
|
||||
@@ -1794,17 +1808,21 @@ impl RenderState {
|
||||
|
||||
if !self.surfaces.has_cached_tile_surface(next_tile) {
|
||||
if let Some(ids) = self.tiles.get_shapes_at(next_tile) {
|
||||
let root_ids_map: std::collections::HashMap<Uuid, usize> = root_ids
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, id)| (*id, i))
|
||||
.collect();
|
||||
|
||||
// We only need first level shapes
|
||||
let mut valid_ids: Vec<Uuid> = ids
|
||||
.iter()
|
||||
.filter(|id| root_ids.contains(id))
|
||||
.filter(|id| root_ids_map.contains_key(id))
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
// These shapes for the tile should be ordered as they are in the parent node
|
||||
valid_ids.sort_by_key(|id| {
|
||||
root_ids.iter().position(|x| x == id).unwrap_or(usize::MAX)
|
||||
});
|
||||
valid_ids.sort_by_key(|id| root_ids_map.get(id).unwrap_or(&usize::MAX));
|
||||
|
||||
self.pending_nodes.extend(valid_ids.into_iter().map(|id| {
|
||||
NodeRenderState {
|
||||
@@ -1821,6 +1839,7 @@ impl RenderState {
|
||||
should_stop = true;
|
||||
}
|
||||
}
|
||||
|
||||
self.render_in_progress = false;
|
||||
|
||||
self.surfaces.gc();
|
||||
|
||||
@@ -8,8 +8,8 @@ use super::{gpu_state::GpuState, tiles::Tile, tiles::TileViewbox, tiles::TILE_SI
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
const TEXTURES_CACHE_CAPACITY: usize = 512;
|
||||
const TEXTURES_BATCH_DELETE: usize = 32;
|
||||
const TEXTURES_CACHE_CAPACITY: usize = 1024;
|
||||
const TEXTURES_BATCH_DELETE: usize = 256;
|
||||
// This is the amount of extra space we're going to give to all the surfaces to render shapes.
|
||||
// If it's too big it could affect performance.
|
||||
const TILE_SIZE_MULTIPLIER: i32 = 2;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user