Compare commits

...

99 Commits

Author SHA1 Message Date
Alejandro Alonso
222481fa0d 🎉 Basic graph wasm support 2025-12-22 06:51:56 +01:00
Andrey Antukh
33c786498d Merge remote-tracking branch 'origin/staging-render' into develop 2025-12-12 12:19:49 +01:00
Andrey Antukh
1f886b1f88 Merge remote-tracking branch 'origin/staging' into develop 2025-12-12 12:16:41 +01:00
Aitor Moreno
5a922c6bd6 Merge pull request #7960 from penpot/superalex-fix-too-many-active-webgl-contexts
🐛 Fix too many active WEBGL contexts
2025-12-12 12:03:46 +01:00
Alejandro Alonso
1388865cfc 🐛 Fix too many active WEBGL contexts 2025-12-12 11:16:47 +01:00
Andrey Antukh
1738847694 Merge remote-tracking branch 'origin/staging' into staging-render 2025-12-12 10:20:21 +01:00
Aitor Moreno
ca1c3c799d Merge pull request #7968 from penpot/alotor-fix-border-radius
🐛 Fix problem with border radius to path
2025-12-12 10:18:07 +01:00
alonso.torres
ce5006ae84 🐛 Fix problem with border radius to path 2025-12-11 22:40:44 +01:00
Eva Marco
50dbe6ab12 🐛 Fix horizontal scroll on layer panel (#7956) 2025-12-11 21:34:18 +01:00
Belén Albeza
0a7a65af5d ♻️ Make SerializableResult to depend on From traits 2025-12-11 16:00:03 +01:00
alonso.torres
ea4d0e1238 Calculate position data in wasm 2025-12-11 16:00:03 +01:00
Elena Torro
b705cf953a 🐛 Set layout data from set-object 2025-12-11 14:52:32 +01:00
Alejandro Alonso
90ce1f56e7 Merge pull request #7958 from penpot/superalex-fix-svg-extract-ids
🐛 Fix svg extract ids
2025-12-11 14:02:05 +01:00
Alejandro Alonso
ab0438cc6f 🐛 Fix svg extract ids 2025-12-11 13:47:00 +01:00
Aitor Moreno
c6aa9cc4b7 Merge pull request #7950 from penpot/ladybenko-12851-fix-text-selection
🐛 Fix text selection when editor regains focus
2025-12-11 13:45:29 +01:00
Andrey Antukh
5779adef33 Merge remote-tracking branch 'origin/staging' into staging-render 2025-12-11 13:30:59 +01:00
Andrey Antukh
2f46cbc0d4 Make render wasm import on worker http cache aware 2025-12-11 13:27:20 +01:00
Elena Torró
ebf1758958 Merge pull request #7935 from penpot/superalex-improve-svg-import
🎉 Improve svg import
2025-12-11 13:21:29 +01:00
Elena Torró
e94c56bfa7 Merge pull request #7954 from penpot/azazeln28-fix-font-weight-mixed-value
🐛 Fix font weight mixed value
2025-12-11 12:43:53 +01:00
Andrey Antukh
53be6f996b 🐛 Fix issues on build processs related to render-wasm 2025-12-11 12:41:19 +01:00
Alejandro Alonso
89d9591011 🎉 Improve svg import 2025-12-11 12:02:34 +01:00
Andrey Antukh
5a260294a1 🔧 Update build-tag.yml github workflow 2025-12-11 12:00:42 +01:00
Andrey Antukh
3becfcd723 🔧 Update build-tag.yml github workflow 2025-12-11 11:59:16 +01:00
Andrey Antukh
3f6e44316e 🐛 Add missing node depes install on render-wasm 2025-12-11 11:51:47 +01:00
Aitor Moreno
5501a2815f 🐛 Fix font-variant-id mixed value 2025-12-11 11:32:27 +01:00
Eva Marco
77ef8e6fe6 🐛 Fix scroll on move library modal (#7952) 2025-12-11 10:46:54 +01:00
Alejandro Alonso
1066438b02 Merge pull request #7922 from penpot/elenatorro-12855-improve-pan-rendering
🔧 Improve pan rendering
2025-12-10 15:58:59 +01:00
Alejandro Alonso
3b23a3ad19 Merge pull request #7947 from penpot/elenatorro-12880-fix-variant-ui
🔧 Support variants interactivity on the new render's UI
2025-12-10 15:27:48 +01:00
Andrey Antukh
7396f4bfb6 Merge remote-tracking branch 'origin/staging' into develop 2025-12-10 15:17:50 +01:00
Alejandro Alonso
916b7709dc Update Pencil Penpot Design System System template in carousel (#7948) 2025-12-10 15:09:28 +01:00
Belén Albeza
5cf51f3d26 🐛 Fix text selection not being restore if it was only 1 word 2025-12-10 15:05:13 +01:00
Belén Albeza
25acad5154 🔧 Add formatting rules to the TextEditor 2025-12-10 15:04:34 +01:00
Elena Torro
0a212b6291 🔧 Support variants interactivity on the new render's UI 2025-12-10 14:39:59 +01:00
Eva Marco
443e41fea4 🐛 Fix multiple selection with color tokens (#7941) 2025-12-10 14:36:08 +01:00
Alejandro Alonso
c7c9b04095 Merge pull request #7944 from penpot/niwinz-staging-exporter-fix
🐛 Fix incorrect resource lifetime handling on exporter
2025-12-10 14:35:20 +01:00
Eva Marco
c61a0c0332 📚 Add line to changelog (#7945) 2025-12-10 13:58:18 +01:00
Andrey Antukh
eb1eeb4750 Merge remote-tracking branch 'origin/staging-render' into niwinz-develop-merge 2025-12-10 13:53:15 +01:00
Andrey Antukh
a78477592b Merge remote-tracking branch 'origin/staging' into staging-render 2025-12-10 13:36:23 +01:00
Eva Marco
8707ff6511 🎉 Add spanish translation 2025-12-10 13:12:30 +01:00
Florian Schroedl
3d8a251741 🐛 Disallow font-family referencing composite token 2025-12-10 13:12:30 +01:00
Andrey Antukh
34e84ee3c8 🐛 Fix incorrect resource lifetime handling on exporter 2025-12-10 13:02:31 +01:00
Xaviju
0956b66281 💄 Group tokens by name path (#7775)
* 💄 Group tokens by name path
2025-12-10 12:34:19 +01:00
Luis de Dios
007b3f11f9 🐛 Fix pass new icons to radio buttons (#7939) 2025-12-10 12:28:27 +01:00
Alejandro Alonso
e8201402a7 Merge pull request #7938 from penpot/niwinz-staging-bugfix-5
🐛 Fix several issues
2025-12-10 12:05:42 +01:00
Aitor Moreno
8a22477b96 Merge pull request #7932 from penpot/niwinz-staging-worker-wasm-load
🐛 Fix WASM loading strategy on worker
2025-12-10 11:47:31 +01:00
Elena Torro
a661b2564f 🐛 Fix default case on vertical align 2025-12-10 10:59:27 +01:00
Elena Torro
2c3732f3f4 🔧 Fix line height calculation 2025-12-10 10:59:27 +01:00
Andrey Antukh
e16645227b Merge branch 'staging-render' into develop 2025-12-10 10:10:44 +01:00
Andrey Antukh
45665a3c21 Merge remote-tracking branch 'origin/staging' into staging-render 2025-12-10 10:09:58 +01:00
Alejandro Alonso
3e684ea54f ⬆️ Update svgo dependency on frontend (#7936) 2025-12-10 10:07:02 +01:00
Eva Marco
179e6a195d 🎉 Add test for token creation (#7915) 2025-12-10 09:56:21 +01:00
Andrey Antukh
98039f13d8 🐛 Fix main toolbar z-index 2025-12-10 09:47:40 +01:00
Alejandro Alonso
40c27591f6 🐛 Fix svg import (#7925) 2025-12-10 08:36:54 +01:00
Andrey Antukh
91d20a46d1 💄 Add cosmetic changes to exports assets progress component 2025-12-10 08:23:05 +01:00
Andrey Antukh
50bead7c56 🐛 Fix react warning on having p inside p on assets export progress 2025-12-10 08:22:41 +01:00
Andrey Antukh
b75b999903 📎 Fix devenv jvm warning 2025-12-10 08:22:05 +01:00
Andrey Antukh
810f1721c8 🐛 Fix recursion render on subscription modal 2025-12-10 07:54:52 +01:00
Andrey Antukh
a4646373cf ♻️ Refactor wasm loading strategy on worker 2025-12-09 19:41:19 +01:00
Andrey Antukh
f111cbb2a4 Add better cache config on devenv nginx 2025-12-09 19:38:30 +01:00
Elena Torro
81bc1bb0af 🔧 Log performance when building using profile-macros 2025-12-09 15:25:13 +01:00
Aitor Moreno
7d36bc4025 Merge pull request #7907 from penpot/alotor-fix-export-text
🐛 Fix problem when exporting texts
2025-12-09 11:28:47 +01:00
Elena Torro
b8feb6374d 🔧 Rebuild indices on zoom change, not pan 2025-12-09 11:26:03 +01:00
Elena Torro
0889df8e08 🔧 Skip slow operations on fast render 2025-12-09 11:26:03 +01:00
Belén Albeza
7be8ac3fd7 🐛 Fix internal error while importing a library 2025-12-09 11:10:32 +01:00
Elena Torro
9216d965ef 🔧 Update rendering settings to smooth render 2025-12-09 10:43:33 +01:00
alonso.torres
520e979363 🐛 Fix problem when exporting texts 2025-12-04 17:32:54 +01:00
Andrey Antukh
a38f425dd3 Merge remote-tracking branch 'origin/staging' into staging-render 2025-12-04 11:06:48 +01:00
Aitor Moreno
4e84deca44 Merge pull request #7879 from penpot/elenatorro-12797-fix-update-spans
🐛 Fix paragraph with text spans with multiple styles
2025-12-03 11:30:17 +01:00
Aitor Moreno
0d21e52068 🐛 Fix applyStylesTo entire selection 2025-12-03 11:07:33 +01:00
alonso.torres
1b29e9a50f 🐛 Fix race condition with fix fonts patch 2025-12-03 10:39:05 +01:00
Elena Torro
9f567c3bf4 🐛 Fix italic variant 2025-12-03 08:59:25 +01:00
Elena Torro
1ba15e5d10 🐛 Do not merge fill styles 2025-12-03 08:55:11 +01:00
alonso.torres
37e45a8bbf 🐛 Fix race condition with text and type 2025-12-02 17:28:20 +01:00
alonso.torres
3471d40f46 🐛 Fix problem with boolean shapes updates 2025-12-02 17:28:20 +01:00
Elena Torro
c6b64a8e39 🐛 Fix selectAll on mixed span styles 2025-12-02 16:50:48 +01:00
Elena Torro
511e80c948 🐛 Fix merge fill styles when there are multiple fills 2025-12-02 16:50:04 +01:00
Elena Torró
f5a640d104 Merge pull request #7876 from penpot/ladybenko-12805-slow-loading
🐛 Fix viewport not being fully drawn on first load until a mouse …
2025-12-02 15:31:43 +01:00
Belén Albeza
3ae7c514e4 🐛 Fix viewport not being fully drawn on first load until a mouse hover 2025-12-02 15:06:28 +01:00
alonso.torres
fad9ed1c48 🐛 Fix problem with reordering layers 2025-12-02 12:27:00 +01:00
alonso.torres
0caaefefea 🐛 Fix outline with single click text creation 2025-12-02 11:08:58 +01:00
Elena Torro
b179aa79b1 🐛 Fix create empty text on click regression 2025-12-02 11:08:58 +01:00
Aitor Moreno
405ddb60d8 🐛 Fix letter spacing applied to paragraph 2025-12-02 10:45:19 +01:00
Elena Torró
95c0d42d5b Merge pull request #7868 from penpot/alotor-fix-flex-tools
🐛 Fix visual feedback on padding/margin/gaps modified
2025-12-01 17:51:44 +01:00
alonso.torres
721b337511 🐛 Fix visual feedback on padding/margin/gaps modified 2025-12-01 16:31:15 +01:00
Elena Torró
359379be09 Merge pull request #7867 from penpot/azazeln28-add-text-editor-v2-tests-to-staging
 Add text editor v2 integration tests
2025-12-01 16:11:25 +01:00
Aitor Moreno
876d5783cf Add text editor v2 integration tests 2025-12-01 15:56:52 +01:00
Elena Torro
786f73767b 🔧 Normalize font attributes to support old formats 2025-12-01 14:59:24 +01:00
Andrey Antukh
4282cdcd2c Merge remote-tracking branch 'origin/staging' into staging-render 2025-12-01 10:11:06 +01:00
Alejandro Alonso
e889413f26 🐛 Fix nested shadows clipping 2025-12-01 09:22:23 +01:00
Elena Torró
115273b478 Merge pull request #7852 from penpot/alotor-flex-issues
🐛 Fix flex problems in new render
2025-11-28 14:10:42 +01:00
Elena Torró
fdddd3284a Merge pull request #7859 from penpot/ladybenko-12801-fix-mismatched-fonts
🐛 Fix mismatch between fonts for rendered and selected text when no fallback fonts apply
2025-11-28 14:10:17 +01:00
Belén Albeza
51385a04a0 🐛 Fix mismatch between fonts for rendered and selected text when no fallback fonts apply 2025-11-28 13:54:17 +01:00
Belén Albeza
f96ed8ccd6 Fix playwright tests 2025-11-28 13:25:13 +01:00
Belén Albeza
bda5de5c1b 🔧 Update google fonts list 2025-11-28 13:25:13 +01:00
alonso.torres
59f3b4db4c 🐛 Fix problem with auto-size and element margins 2025-11-28 12:12:19 +01:00
alonso.torres
7ee03ad911 🐛 Fix problem with grid layout editor 2025-11-28 12:12:09 +01:00
alonso.torres
130b8c8214 🐛 Fix problems with flex layout in new render 2025-11-28 10:49:55 +01:00
alonso.torres
0198d41757 🐛 Fix crash when cleanup 2025-11-28 10:44:54 +01:00
alonso.torres
567a955151 🐛 Fix problem with change gap/margin/padding 2025-11-28 10:44:38 +01:00
2767 changed files with 1280329 additions and 13330 deletions

View File

@@ -11,7 +11,7 @@ jobs:
secrets: inherit
with:
gh_ref: ${{ github.ref_name }}
build_wasm: "no"
build_wasm: "yes"
build_storybook: "yes"
build-docker:

View File

@@ -114,6 +114,8 @@ example. It's still usable as before, we just removed the example.
- Fix unpublish library modal not scrolling a long file list [Taiga #12285](https://tree.taiga.io/project/penpot/issue/12285)
- Fix incorrect interaction betwen hower and scroll on assets sidebar [Taiga #12389](https://tree.taiga.io/project/penpot/issue/12389)
- Fix switch variants with paths [Taiga #12841](https://tree.taiga.io/project/penpot/issue/12841)
- Fix referencing typography tokens on font-family tokens [Taiga #12492](https://tree.taiga.io/project/penpot/issue/12492)
- Fix horizontal scroll on layer panel [Taiga #12843](https://tree.taiga.io/project/penpot/issue/12843)
## 2.11.1

View File

@@ -3,7 +3,7 @@
:file-uri "https://github.com/penpot/penpot-files/raw/refs/heads/main/Tokens%20starter%20kit.penpot"}
{:id "penpot-design-system"
:name "Penpot Design System | Pencil"
:file-uri "https://github.com/penpot/penpot-files/raw/refs/heads/main/penpot-app.penpot"}
:file-uri "https://github.com/penpot/penpot-files/raw/refs/heads/main/Pencil-Penpot-Design-System.penpot"}
{:id "wireframing-kit"
:name "Wireframe library"
:file-uri "https://github.com/penpot/penpot-files/raw/refs/heads/main/Wireframing%20kit%20v1.1.penpot"}

View File

@@ -56,6 +56,7 @@
"text-editor/v2-html-paste"
"text-editor/v2"
"render-wasm/v1"
"graph-wasm/v1"
"variants/v1"})
;; A set of features enabled by default
@@ -79,7 +80,8 @@
"text-editor/v2-html-paste"
"text-editor/v2"
"tokens/numeric-input"
"render-wasm/v1"})
"render-wasm/v1"
"graph-wasm/v1"})
;; Features that are mainly backend only or there are a proper
;; fallback when frontend reports no support for it
@@ -128,6 +130,7 @@
:feature-text-editor-v2 "text-editor/v2"
:feature-text-editor-v2-html-paste "text-editor/v2-html-paste"
:feature-render-wasm "render-wasm/v1"
:feature-graph-wasm "graph-wasm/v1"
:feature-variants "variants/v1"
:feature-token-input "tokens/numeric-input"
nil))

View File

@@ -82,6 +82,113 @@
(declare create-svg-children)
(declare parse-svg-element)
(defn- process-gradient-stops
"Processes gradient stops to extract stop-color and stop-opacity from style attributes
and convert them to direct attributes. This ensures stops with style='stop-color:#...;stop-opacity:1'
are properly converted to stop-color and stop-opacity attributes."
[stops]
(mapv (fn [stop]
(let [stop-attrs (:attrs stop)
stop-style (get stop-attrs :style)
;; Parse style if it's a string using csvg/parse-style utility
parsed-style (when (and (string? stop-style) (seq stop-style))
(csvg/parse-style stop-style))
;; Extract stop-color and stop-opacity from style
style-stop-color (when parsed-style (:stop-color parsed-style))
style-stop-opacity (when parsed-style (:stop-opacity parsed-style))
;; Merge: use direct attributes first, then style values as fallback
final-attrs (cond-> stop-attrs
(and style-stop-color (not (contains? stop-attrs :stop-color)))
(assoc :stop-color style-stop-color)
(and style-stop-opacity (not (contains? stop-attrs :stop-opacity)))
(assoc :stop-opacity style-stop-opacity)
;; Remove style attribute if we've extracted its values
(or style-stop-color style-stop-opacity)
(dissoc :style))]
(assoc stop :attrs final-attrs)))
stops))
(defn- resolve-gradient-href
"Resolves xlink:href references in gradients by merging the referenced gradient's
stops and attributes with the referencing gradient. This ensures gradients that
reference other gradients (like linearGradient3550 referencing linearGradient3536)
inherit the stops from the base gradient.
According to SVG spec, when a gradient has xlink:href:
- It inherits all attributes from the referenced gradient
- It inherits all stops from the referenced gradient
- The referencing gradient's attributes override the base ones
- If the referencing gradient has stops, they replace the base stops
Returns the defs map with all gradient href references resolved."
[defs]
(letfn [(resolve-gradient [gradient-id gradient-node defs visited]
(if (contains? visited gradient-id)
(do
#?(:cljs (js/console.warn "[resolve-gradient] Circular reference detected for" gradient-id)
:clj nil)
gradient-node) ;; Avoid circular references
(let [attrs (:attrs gradient-node)
href-id (or (:href attrs) (:xlink:href attrs))
href-id (when (and (string? href-id) (pos? (count href-id)))
(subs href-id 1)) ;; Remove leading #
base-gradient (when (and href-id (contains? defs href-id))
(get defs href-id))
resolved-base (when base-gradient (resolve-gradient href-id base-gradient defs (conj visited gradient-id)))]
(if resolved-base
;; Merge: base gradient attributes + referencing gradient attributes
;; Use referencing gradient's stops if present, otherwise use base stops
(let [base-attrs (:attrs resolved-base)
ref-attrs (:attrs gradient-node)
;; Start with base attributes (without id), then merge with ref attributes
;; This ensures ref attributes override base ones
base-attrs-clean (dissoc base-attrs :id)
ref-attrs-clean (dissoc ref-attrs :href :xlink:href :id)
;; Special handling for gradientTransform: if both have it, combine them
base-transform (get base-attrs :gradientTransform)
ref-transform (get ref-attrs :gradientTransform)
combined-transform (cond
(and base-transform ref-transform)
(str base-transform " " ref-transform) ;; Apply base first, then ref
:else (or ref-transform base-transform))
;; Merge attributes: base first, then ref (ref overrides)
merged-attrs (-> (d/deep-merge base-attrs-clean ref-attrs-clean)
(cond-> combined-transform
(assoc :gradientTransform combined-transform)))
;; If referencing gradient has content (stops), use it; otherwise use base content
final-content (if (seq (:content gradient-node))
(:content gradient-node)
(:content resolved-base))
;; Process stops to extract stop-color and stop-opacity from style attributes
processed-content (process-gradient-stops final-content)
result {:tag (:tag gradient-node)
:attrs (assoc merged-attrs :id gradient-id)
:content processed-content}]
result)
;; Process stops even for gradients without references to extract style attributes
(let [processed-content (process-gradient-stops (:content gradient-node))]
(assoc gradient-node :content processed-content))))))]
(let [gradient-tags #{:linearGradient :radialGradient}
result (reduce-kv
(fn [acc id node]
(if (contains? gradient-tags (:tag node))
(assoc acc id (resolve-gradient id node defs #{}))
(assoc acc id node)))
{}
defs)]
result)))
(defn create-svg-shapes
([svg-data pos objects frame-id parent-id selected center?]
(create-svg-shapes (uuid/next) svg-data pos objects frame-id parent-id selected center?))
@@ -112,6 +219,9 @@
(csvg/fix-percents)
(csvg/extract-defs))
;; Resolve gradient href references in all defs before processing shapes
def-nodes (resolve-gradient-href def-nodes)
;; In penpot groups have the size of their children. To
;; respect the imported svg size and empty space let's create
;; a transparent shape as background to respect the imported
@@ -142,12 +252,23 @@
(reduce (partial create-svg-children objects selected frame-id root-id svg-data)
[unames []]
(d/enumerate (->> (:content svg-data)
(mapv #(csvg/inherit-attributes root-attrs %)))))]
(mapv #(csvg/inherit-attributes root-attrs %)))))
[root-shape children])))
;; Collect all defs from children and merge into root shape
all-defs-from-children (reduce (fn [acc child]
(if-let [child-defs (:svg-defs child)]
(merge acc child-defs)
acc))
{}
children)
;; Merge defs from svg-data and children into root shape
root-shape-with-defs (assoc root-shape :svg-defs (merge def-nodes all-defs-from-children))]
[root-shape-with-defs children])))
(defn create-raw-svg
[name frame-id {:keys [x y width height offset-x offset-y]} {:keys [attrs] :as data}]
[name frame-id {:keys [x y width height offset-x offset-y defs] :as svg-data} {:keys [attrs] :as data}]
(let [props (csvg/attrs->props attrs)
vbox (grc/make-rect offset-x offset-y width height)]
(cts/setup-shape
@@ -160,10 +281,11 @@
:y y
:content data
:svg-attrs props
:svg-viewbox vbox})))
:svg-viewbox vbox
:svg-defs defs})))
(defn create-svg-root
[id frame-id parent-id {:keys [name x y width height offset-x offset-y attrs]}]
[id frame-id parent-id {:keys [name x y width height offset-x offset-y attrs defs] :as svg-data}]
(let [props (-> (dissoc attrs :viewBox :view-box :xmlns)
(d/without-keys csvg/inheritable-props)
(csvg/attrs->props))]
@@ -177,7 +299,8 @@
:height height
:x (+ x offset-x)
:y (+ y offset-y)
:svg-attrs props})))
:svg-attrs props
:svg-defs defs})))
(defn create-svg-children
[objects selected frame-id parent-id svg-data [unames children] [_index svg-element]]
@@ -198,7 +321,7 @@
(defn create-group
[name frame-id {:keys [x y width height offset-x offset-y] :as svg-data} {:keys [attrs]}]
[name frame-id {:keys [x y width height offset-x offset-y defs] :as svg-data} {:keys [attrs]}]
(let [transform (csvg/parse-transform (:transform attrs))
attrs (-> attrs
(d/without-keys csvg/inheritable-props)
@@ -214,7 +337,8 @@
:height height
:svg-transform transform
:svg-attrs attrs
:svg-viewbox vbox})))
:svg-viewbox vbox
:svg-defs defs})))
(defn create-path-shape [name frame-id svg-data {:keys [attrs] :as data}]
(when (and (contains? attrs :d) (seq (:d attrs)))
@@ -523,6 +647,21 @@
:else (dm/str tag))]
(dm/str "svg-" suffix)))
(defn- filter-valid-def-references
"Filters out false positive references that are not valid def IDs.
Filters out:
- Colors in style attributes (hex colors like #f9dd67)
- Style fragments that contain CSS keywords (like stop-opacity)
- References that don't exist in defs"
[ref-ids defs]
(let [is-style-fragment? (fn [ref-id]
(or (clr/hex-color-string? (str "#" ref-id))
(str/includes? ref-id ";") ;; Contains CSS separator
(str/includes? ref-id "stop-opacity") ;; CSS keyword
(str/includes? ref-id "stop-color")))] ;; CSS keyword
(->> ref-ids
(remove is-style-fragment?) ;; Filter style fragments and hex colors
(filter #(contains? defs %))))) ;; Only existing defs
(defn parse-svg-element
[frame-id svg-data {:keys [tag attrs hidden] :as element} unames]
@@ -534,7 +673,11 @@
(let [name (or (:id attrs) (tag->name tag))
att-refs (csvg/find-attr-references attrs)
defs (get svg-data :defs)
references (csvg/find-def-references defs att-refs)
valid-refs (filter-valid-def-references att-refs defs)
all-refs (csvg/find-def-references defs valid-refs)
;; Filter the final result to ensure all references are valid defs
;; This prevents false positives from style attributes in gradient stops
references (filter-valid-def-references all-refs defs)
href-id (or (:href attrs) (:xlink:href attrs) " ")
href-id (if (and (string? href-id)

View File

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

View File

@@ -546,9 +546,19 @@
filter-values)))
(defn extract-ids [val]
(when (some? val)
;; Extract referenced ids from string values like "url(#myId)".
;; Non-string values (maps, numbers, nil, etc.) return an empty seq
;; to avoid re-seq type errors when attributes carry nested structures.
(cond
(string? val)
(->> (re-seq xml-id-regex val)
(mapv second))))
(mapv second))
(sequential? val)
(mapcat extract-ids val)
:else
[]))
(defn fix-dot-number
"Fixes decimal numbers starting in dot but without leading 0"

View File

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

View File

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

View File

@@ -21,6 +21,7 @@
"raw-body": "^3.0.1",
"source-map-support": "^0.5.21",
"svgo": "penpot/svgo#v3.1",
"undici": "^7.16.0",
"xml-js": "^1.6.11",
"xregexp": "^5.1.2"
},

View File

@@ -7,5 +7,4 @@ bb -i '(babashka.wait/wait-for-port "localhost" 9630)';
bb -i '(babashka.wait/wait-for-path "target/app.js")';
sleep 2;
export NODE_TLS_REJECT_UNAUTHORIZED=0
exec node target/app.js

View File

@@ -107,12 +107,12 @@
:on-progress on-progress)
append (fn [{:keys [filename path] :as resource}]
(rsc/add-to-zip! zip path (str/replace filename sanitize-file-regex "_")))
(rsc/add-to-zip zip path (str/replace filename sanitize-file-regex "_")))
proc (->> exports
(map (fn [export] (rd/render export append)))
(p/all)
(p/fnly (fn [_] (.finalize zip)))
(p/mcat (fn [_] (rsc/close-zip zip)))
(p/fmap (constantly resource))
(p/mcat (partial rsc/upload-resource auth-token))
(p/fmap (fn [resource]

View File

@@ -11,6 +11,7 @@
["node:fs" :as fs]
["node:fs/promises" :as fsp]
["node:path" :as path]
["undici" :as http]
[app.common.exceptions :as ex]
[app.common.transit :as t]
[app.common.uri :as u]
@@ -53,30 +54,40 @@
(.pipe zip out)
zip))
(defn add-to-zip!
(defn add-to-zip
[zip path name]
(.file ^js zip path #js {:name name}))
(defn close-zip!
(defn close-zip
[zip]
(.finalize ^js zip))
(p/create (fn [resolve]
(.on ^js zip "close" resolve)
(.finalize ^js zip))))
(defn upload-resource
[auth-token resource]
(->> (fsp/readFile (:path resource))
(p/fmap (fn [buffer]
(js/console.log buffer)
(new js/Blob #js [buffer] #js {:type (:mtype resource)})))
(p/mcat (fn [blob]
(let [fdata (new js/FormData)
uri (-> (cf/get :public-uri)
(u/ensure-path-slash)
(u/join "api/management/methods/upload-tempfile")
(str))]
(let [fdata (new http/FormData)
agent (new http/Agent #js {:connect #js {:rejectUnauthorized false}})
headers #js {"X-Shared-Key" cf/management-key
"Authorization" (str "Bearer " auth-token)}
request #js {:headers headers
:method "POST"
:body fdata
:dispatcher agent}
uri (-> (cf/get :public-uri)
(u/ensure-path-slash)
(u/join "api/management/methods/upload-tempfile")
(str))]
(.append fdata "content" blob (:filename resource))
(js/fetch uri #js {:headers #js {"X-Shared-Key" cf/management-key
"Authorization" (str "Bearer " auth-token)}
:method "POST"
:body fdata}))))
(http/fetch uri request))))
(p/mcat (fn [response]
(if (not= (.-status response) 200)
(ex/raise :type :internal

View File

@@ -75,7 +75,8 @@
[path]
(->> (.stat fs/promises path)
(p/fmap (fn [data]
{:created-at (inst-ms (.-ctime ^js data))
{:path path
:created-at (inst-ms (.-ctime ^js data))
:size (.-size data)}))
(p/merr (fn [_cause]
(p/resolved nil)))))

View File

@@ -582,6 +582,7 @@ __metadata:
raw-body: "npm:^3.0.1"
source-map-support: "npm:^0.5.21"
svgo: "penpot/svgo#v3.1"
undici: "npm:^7.16.0"
ws: "npm:^8.18.3"
xml-js: "npm:^1.6.11"
xregexp: "npm:^5.1.2"
@@ -1513,6 +1514,13 @@ __metadata:
languageName: node
linkType: hard
"undici@npm:^7.16.0":
version: 7.16.0
resolution: "undici@npm:7.16.0"
checksum: 10c0/efd867792e9f233facf9efa0a087e2d9c3e4415c0b234061b9b40307ca4fa01d945fee4d43c7b564e1b80e0d519bcc682f9f6e0de13c717146c00a80e2f1fb0f
languageName: node
linkType: hard
"unique-filename@npm:^4.0.0":
version: 4.0.0
resolution: "unique-filename@npm:4.0.0"

View File

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

View File

@@ -32,8 +32,8 @@
"e2e:server": "node ./scripts/e2e-server.js",
"fmt:clj": "cljfmt fix --parallel=true src/ test/",
"fmt:clj:check": "cljfmt check --parallel=false src/ test/",
"fmt:js": "yarn run prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js -w",
"fmt:js:check": "yarn run prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js",
"fmt:js": "yarn run prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js -c text-editor/**/*.js -w",
"fmt:js:check": "yarn run prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js text-editor/**/*.js",
"lint:clj": "clj-kondo --parallel --lint src/",
"lint:scss": "yarn run prettier -c resources/styles -c src/**/*.scss",
"lint:scss:fix": "yarn run prettier -c resources/styles -c src/**/*.scss -w",
@@ -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",

View 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
}
]
}
}

View 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"
}
}
}

View File

@@ -1,5 +1 @@
{
"~:revn": 2,
"~:lagged": []
}
w

View File

@@ -0,0 +1,4 @@
{
"~:revn": 2,
"~:lagged": []
}

View File

@@ -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": []
}
]

View 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);
}
}

View File

@@ -0,0 +1,28 @@
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);
}
}

View File

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

View File

@@ -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();

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -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();
});

View File

@@ -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 = {

View File

@@ -51,7 +51,7 @@ test.skip("BUG 12164 - Crash when trying to fetch a missing font", async ({
pageId: "2b7f0188-51a1-8193-8006-e05bad87b74d",
});
await workspacePage.page.waitForTimeout(1000)
await workspacePage.page.waitForTimeout(1000);
await workspacePage.waitForFirstRender();
await expect(

View File

@@ -1,12 +1,323 @@
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 +325,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");

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
</head>
<body>
<script type="module">
import initWasmModule from '/js/graph-wasm.js';
let Module = null;
function init(moduleInstance) {
Module = moduleInstance;
}
console.log("Loading module");
initWasmModule().then(Module => {
init(Module);
Module._hello();
});
</script>
</body>
</html>

View File

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

View File

@@ -20,27 +20,26 @@ echo $PATH
set -ex
corepack enable;
corepack install || exit 1;
corepack install;
yarn install || exit 1;
rm -rf resources/public;
rm -rf target/dist;
rm -rf resources/public;
mkdir -p resources/public;
mkdir -p target/dist;
yarn run build:app:main $EXTRA_PARAMS || exit 1
pushd ../render-wasm;
./build
popd
if [ "$INCLUDE_WASM" = "yes" ]; then
yarn run build:wasm || exit 1;
fi
yarn run build:app:main $EXTRA_PARAMS;
yarn run build:app:libs;
yarn run build:app:assets;
yarn run build:app:libs || exit 1;
yarn run build:app:assets || exit 1;
sed -i "s/\.\/render.js/.\/render.js?version=$CURRENT_VERSION/g" resources/public/js/worker/main*.js
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/;
rsync -avr resources/public/ target/dist/
if [ "$INCLUDE_STORYBOOK" = "yes" ]; then
# build storybook

View File

@@ -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('./render.js', './graph-wasm-worker.js');"
:depends-on #{}}}
:js-options

View File

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

View File

@@ -0,0 +1,12 @@
;; 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.graph-wasm
"A WASM based render API"
(:require
[app.graph-wasm.api :as wasm.api]))
(def module wasm.api/module)

View File

@@ -0,0 +1,91 @@
;; 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.graph-wasm.api
(:require
[app.common.data.macros :as dm]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.graph-wasm.wasm :as wasm]
[app.render-wasm.helpers :as h]
[app.render-wasm.serializers :as sr]
[app.util.modules :as mod]
[promesa.core :as p]))
(defn hello []
(h/call wasm/internal-module "_hello"))
(defn init []
(h/call wasm/internal-module "_init"))
(defn use-shape
[id]
(let [buffer (uuid/get-u32 id)]
(println "use-shape" id)
(h/call wasm/internal-module "_use_shape"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(aget buffer 3))))
(defn set-shape-parent-id
[id]
(let [buffer (uuid/get-u32 id)]
(h/call wasm/internal-module "_set_shape_parent"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(aget buffer 3))))
(defn set-shape-type
[type]
(h/call wasm/internal-module "_set_shape_type" (sr/translate-shape-type type)))
(defn set-shape-selrect
[selrect]
(h/call wasm/internal-module "_set_shape_selrect"
(dm/get-prop selrect :x1)
(dm/get-prop selrect :y1)
(dm/get-prop selrect :x2)
(dm/get-prop selrect :y2)))
(defn set-object
[shape]
(let [id (dm/get-prop shape :id)
type (dm/get-prop shape :type)
parent-id (get shape :parent-id)
selrect (get shape :selrect)
children (get shape :shapes)]
(use-shape id)
(set-shape-type type)
(set-shape-parent-id parent-id)
(set-shape-selrect selrect)))
(defn set-objects
[objects]
(doseq [shape (vals objects)]
(set-object shape)))
(defn init-wasm-module
[module]
(let [default-fn (unchecked-get module "default")
href (cf/resolve-href "js/graph-wasm.wasm")]
(default-fn #js {:locateFile (constantly href)})))
(defonce module
(delay
(if (exists? js/dynamicImport)
(let [uri (cf/resolve-href "js/graph-wasm.js")]
(->> (mod/import uri)
(p/mcat init-wasm-module)
(p/fmap (fn [default]
(set! wasm/internal-module default)
true))
(p/merr
(fn [cause]
(js/console.error cause)
(p/resolved false)))))
(p/resolved false))))

View File

@@ -0,0 +1,9 @@
;; 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.graph-wasm.wasm)
(defonce internal-module #js {})

View File

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

View File

@@ -24,6 +24,8 @@
(def revn-data (atom {}))
(def queue-conj (fnil conj #queue []))
(def force-persist? #(= % ::force-persist))
(defn- update-status
[status]
(ptk/reify ::update-status

View File

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

View File

@@ -32,7 +32,7 @@
[app.main.data.helpers :as dsh]
[app.main.data.modal :as modal]
[app.main.data.notifications :as ntf]
[app.main.data.persistence :as-alias dps]
[app.main.data.persistence :as dps]
[app.main.data.plugins :as dp]
[app.main.data.profile :as du]
[app.main.data.project :as dpj]
@@ -67,6 +67,7 @@
[app.main.errors]
[app.main.features :as features]
[app.main.features.pointer-map :as fpmap]
[app.main.refs :as refs]
[app.main.repo :as rp]
[app.main.router :as rt]
[app.render-wasm :as wasm]
@@ -379,6 +380,59 @@
(->> (rx/from added)
(rx/map process-wasm-object)))))))
(when render-wasm?
(let [local-commits-s
(->> stream
(rx/filter dch/commit?)
(rx/map deref)
(rx/filter #(and (= :local (:source %))
(not (contains? (:tags %) :position-data))))
(rx/filter (complement empty?)))
notifier-s
(rx/merge
(->> local-commits-s (rx/debounce 1000))
(->> stream (rx/filter dps/force-persist?)))
objects-s
(rx/from-atom refs/workspace-page-objects {:emit-current-value? true})
current-page-id-s
(rx/from-atom refs/current-page-id {:emit-current-value? true})]
(->> local-commits-s
(rx/buffer-until notifier-s)
(rx/with-latest-from objects-s)
(rx/map
(fn [[commits objects]]
(->> commits
(mapcat :redo-changes)
(filter #(contains? #{:mod-obj :add-obj} (:type %)))
(filter #(cfh/text-shape? objects (:id %)))
(map #(vector
(:id %)
(wasm.api/calculate-position-data (get objects (:id %))))))))
(rx/with-latest-from current-page-id-s)
(rx/map
(fn [[text-position-data page-id]]
(let [changes
(->> text-position-data
(mapv (fn [[id position-data]]
{:type :mod-obj
:id id
:page-id page-id
:operations
[{:type :set
:attr :position-data
:val position-data
:ignore-touched true
:ignore-geometry true}]})))]
(dch/commit-changes
{:redo-changes changes :undo-changes []
:save-undo? false
:tags #{:position-data}})))))))
(->> stream
(rx/filter dch/commit?)
(rx/map deref)

View File

@@ -0,0 +1,288 @@
;; 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
;;
;; High level helpers to turn a shape subtree into a component and
;; replace equivalent subtrees by instances of that component.
(ns app.main.data.workspace.componentize
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.changes-builder :as pcb]
[app.common.files.helpers :as cfh]
[app.common.geom.point :as gpt]
[app.common.logic.libraries :as cll]
[app.common.logic.shapes :as cls]
[app.common.types.shape :as cts]
[app.common.uuid :as uuid]
[app.main.data.changes :as dch]
[app.main.data.helpers :as dsh]
[app.main.data.workspace.libraries :as dwl]
[app.main.data.workspace.selection :as dws]
[app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.undo :as dwu]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
;; NOTE: We keep this separate from `workspace.libraries` to avoid
;; introducing more complexity in that already big namespace.
(def ^:private instance-structural-keys
"Keys we do NOT want to copy from the original shape when creating a
new component instance. These are identity / structural / component
metadata keys that must be managed by the component system itself."
#{:id
:parent-id
:frame-id
:shapes
;; Component metadata
:component-id
:component-file
:component-root
:main-instance
:remote-synced
:shape-ref
:touched})
(def ^:private instance-geometry-keys
"Geometry-related keys that we *do* want to override per instance when
copying props from an existing subtree to a component instance."
#{:x
:y
:width
:height
:rotation
:flip-x
:flip-y
:selrect
:points
:proportion
:proportion-lock
:transform
:transform-inverse})
(defn- instantiate-similar-subtrees
"Internal helper. Given an atom `id-ref` that will contain the
`component-id`, replace each subtree rooted at the ids in
`similar-ids` by an instance of that component.
The operation is performed in a single undo transaction:
- Instantiate the component once per similar id, roughly at the same
top-left position as the original root.
- Delete the original subtrees.
- Select the main instance plus all the new instances."
[id-ref root-id similar-ids]
(ptk/reify ::instantiate-similar-subtrees
ptk/WatchEvent
(watch [it state _]
(let [component-id @id-ref
similar-ids (vec (or similar-ids []))]
(if (or (uuid/zero? component-id)
(empty? similar-ids))
(rx/empty)
(let [file-id (:current-file-id state)
page (dsh/lookup-page state)
page-id (:id page)
objects (:objects page)
libraries (dsh/lookup-libraries state)
fdata (dsh/lookup-file-data state file-id)
;; Reference subtree: shapes used to build the component.
;; We'll compute per-shape deltas against this subtree so
;; that we only override attributes that actually differ.
ref-subtree-ids (cfh/get-children-ids objects root-id)
ref-all-ids (into [root-id] ref-subtree-ids)
undo-id (js/Symbol)
;; 1) Instantiate component at each similar root position,
;; preserving per-instance overrides (geometry, style, etc.)
[changes new-root-ids]
(reduce
(fn [[changes acc] sid]
(if-let [shape (get objects sid)]
(let [position (gpt/point (:x shape) (:y shape))
;; Remember original parent and index so we can keep
;; the same ordering among the parent's children.
orig-root (get objects sid)
orig-parent-id (:parent-id orig-root)
orig-index (when orig-parent-id
(cfh/get-position-on-parent objects sid))
;; Instantiate a new component instance at the same position
[new-shape changes']
(cll/generate-instantiate-component
(or changes
(-> (pcb/empty-changes it page-id)
(pcb/with-objects objects)))
objects
file-id
component-id
position
page
libraries)
;; Build a structural mapping between the original subtree
;; (rooted at `sid`) and the new instance subtree.
;; NOTE 1: instantiating a component can introduce an extra
;; wrapper frame, so we try to align the original root
;; with the "equivalent" root inside the instance.
;; NOTE 2: by default the instance may be created *inside*
;; the original shape (because of layout / hit-testing).
;; We explicitly move the new instance to the same parent
;; and index as the original root, so that later deletes of
;; the original subtree don't remove the new instances and
;; the ordering among siblings is preserved.
changes' (cond-> changes'
(some? orig-parent-id)
(pcb/change-parent orig-parent-id [new-shape] orig-index
{:allow-altering-copies true
:ignore-touched true}))
objects' (pcb/get-objects changes')
orig-root (get objects sid)
new-root new-shape
orig-type (:type orig-root)
new-type (:type new-root)
;; Full original subtree (root + descendants)
orig-subtree-ids (cfh/get-children-ids objects sid)
orig-all-ids (into [sid] orig-subtree-ids)
;; Try to find an inner instance root matching the original type
;; when the outer instance root type differs (e.g. rect -> frame+rect).
direct-new-children (cfh/get-children-ids objects' (:id new-root))
candidate-instance-root
(when (and orig-type (not= orig-type new-type))
(let [cands (->> direct-new-children
(filter (fn [nid]
(when-let [s (get objects' nid)]
(= (:type s) orig-type)))))]
(when (= 1 (count cands))
(first cands))))
instance-root-id (or candidate-instance-root (:id new-root))
instance-root (get objects' instance-root-id)
new-subtree-ids (cfh/get-children-ids objects' instance-root-id)
new-all-ids (into [instance-root-id] new-subtree-ids)
id-pairs (map vector orig-all-ids new-all-ids)
changes''
;; Compute per-shape deltas against the reference
;; subtree (root-id) and apply only the differences
;; to the new instance subtree, so we don't blindly
;; overwrite attributes that are the same.
(reduce
(fn [ch [idx orig-id new-id]]
(let [ref-id (nth ref-all-ids idx nil)
ref-shape (get objects ref-id)
orig-shape (get objects orig-id)]
(if (and ref-shape orig-shape)
(let [;; Style / layout / text props (see `extract-props`)
ref-style (cts/extract-props ref-shape)
orig-style (cts/extract-props orig-shape)
style-delta (reduce (fn [m k]
(let [rv (get ref-style k ::none)
ov (get orig-style k ::none)]
(if (= rv ov)
m
(assoc m k ov))))
{}
(keys orig-style))
;; Geometry props
ref-geom (select-keys ref-shape instance-geometry-keys)
orig-geom (select-keys orig-shape instance-geometry-keys)
geom-delta (reduce (fn [m k]
(let [rv (get ref-geom k ::none)
ov (get orig-geom k ::none)]
(if (= rv ov)
m
(assoc m k ov))))
{}
(keys orig-geom))
;; Text content: if the subtree reference and the
;; original differ in `:content`, treat the whole
;; content tree as an override for this instance.
content-delta? (not= (:content ref-shape) (:content orig-shape))]
(-> ch
;; First patch style/text/layout props using the
;; canonical helpers so we don't touch structural ids.
(cond-> (seq style-delta)
(pcb/update-shapes
[new-id]
(fn [s objs] (cts/patch-props s style-delta objs))
{:with-objects? true}))
;; Then patch geometry directly on the instance.
(cond-> (seq geom-delta)
(pcb/update-shapes
[new-id]
(d/patch-object geom-delta)))
;; Finally, if text content differs between the
;; reference subtree and the similar subtree,
;; override the instance content with the original.
(cond-> content-delta?
(pcb/update-shapes
[new-id]
#(assoc % :content (:content orig-shape))))))
ch)))
changes'
(map-indexed (fn [idx [orig-id new-id]]
[idx orig-id new-id])
id-pairs))]
[changes'' (conj acc (:id new-shape))])
;; If the shape does not exist we just skip it
[changes acc]))
[nil []]
similar-ids)
changes (or changes
(-> (pcb/empty-changes it page-id)
(pcb/with-objects objects)))
;; 2) Delete original similar subtrees
;; NOTE: `d/ordered-set` with a single arg treats it as a single
;; element, so we must use `into` when we already have a collection.
ids-to-delete (into (d/ordered-set) similar-ids)
[all-parents changes]
(cls/generate-delete-shapes
changes
fdata
page
objects
ids-to-delete
{:allow-altering-copies true})
;; 3) Select main instance + new instances
;; Root id is kept as-is; add all new roots.
sel-ids (into (d/ordered-set) (cons root-id new-root-ids))]
(rx/of
(dwu/start-undo-transaction undo-id)
(dch/commit-changes changes)
(ptk/data-event :layout/update {:ids all-parents})
(dwu/commit-undo-transaction undo-id))))))))
(defn componentize-similar-subtrees
"Turn the subtree rooted at `root-id` into a component, then replace
the subtrees rooted at `similar-ids` with instances of that component.
This is implemented in two phases:
1) Use the existing `dwl/add-component` flow to create a component
from `root-id` (and obtain its `component-id`).
2) Using the new `component-id`, instantiate the component once per
entry in `similar-ids` and delete the old subtrees."
[root-id similar-ids]
(dm/assert!
"expected valid uuid for `root-id`"
(uuid? root-id))
(let [similar-ids (vec (or similar-ids []))]
(ptk/reify ::componentize-similar-subtrees
ptk/WatchEvent
(watch [_ _ _]
(let [id-ref (atom uuid/zero)]
(rx/concat
;; 1) Create component using the existing pipeline
(rx/of (dwl/add-component id-ref (d/ordered-set root-id)))
;; 2) Replace similar subtrees by instances of the new component
(rx/of (instantiate-similar-subtrees id-ref root-id similar-ids))))))))

View File

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

View File

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

View File

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

View File

@@ -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" %)}

View File

@@ -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)))
#{})))

View File

@@ -30,6 +30,9 @@
(def profile
(l/derived (l/key :profile) st/state))
(def current-page-id
(l/derived (l/key :current-page-id) st/state))
(def team
(l/derived (fn [state]
(let [team-id (:current-team-id state)
@@ -372,6 +375,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]

View File

@@ -60,6 +60,7 @@
current-id (get state :id)
current-value (get state :current-value)
current-label (get label-index current-value)
is-open? (get state :is-open?)
node-ref (mf/use-ref nil)

View File

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

View File

@@ -45,7 +45,7 @@
.element-list {
@include t.use-typography("body-large");
color: var(--modal-text-foreground-color);
overflow-y: scroll;
overflow-y: auto;
margin-block: 0;
}

View 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]]))

View 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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@
// Copyright (c) KALEIDOS INC
@use "refactor/common-refactor.scss" as deprecated;
@use "ds/_utils.scss" as *;
.layer-row {
--layer-indentation-size: calc(#{deprecated.$s-4} * 6);
@@ -87,7 +88,7 @@
height: deprecated.$s-32;
width: calc(100% - (var(--depth) * var(--layer-indentation-size)));
cursor: pointer;
min-width: px2rem(140);
&.filtered {
width: calc(100% - deprecated.$s-12);
}

View File

@@ -211,9 +211,7 @@
overflow-x: auto;
overflow-y: overlay;
scrollbar-gutter: stable;
.element-list {
width: var(--left-sidebar-width);
display: grid;
}
}
.element-list {
display: grid;
}

View File

@@ -102,7 +102,7 @@
[:> deprecated-input/numeric-input*
{:placeholder (cond
(not all-equal?)
"Mixed"
(tr "settings.multiple")
(= :multiple (:r1 values))
(tr "settings.multiple")
:else

View File

@@ -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"}])]])

View File

@@ -265,11 +265,13 @@
(mf/deps font on-change)
(fn [new-variant-id]
(let [variant (d/seek #(= new-variant-id (:id %)) (:variants font))]
(on-change {:font-id (:id font)
:font-family (:family font)
:font-variant-id new-variant-id
:font-weight (:weight variant)
:font-style (:style variant)})
(when-not (nil? variant)
(on-change {:font-id (:id font)
:font-family (:family font)
:font-variant-id new-variant-id
:font-weight (:weight variant)
:font-style (:style variant)}))
(dom/blur! (dom/get-target new-variant-id)))))
on-font-select
@@ -342,12 +344,13 @@
{:value (:id variant)
:key (pr-str variant)
:label (:name variant)})))
variant-options (if (= font-size :multiple)
variant-options (if (= font-variant-id :multiple)
(conj basic-variant-options
{:value :multiple
{:value ""
:key :multiple-variants
:label "--"})
basic-variant-options)]
;; TODO Add disabled mode
[:& select
{:class (stl/css :font-variant-select)
@@ -379,6 +382,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 +401,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)

View File

@@ -68,7 +68,7 @@
(mf/defc color-token-row*
{::mf/private true}
[{:keys [active-tokens color-token color on-swatch-click-token detach-token open-modal-from-token]}]
[{:keys [active-tokens applied-token-name color on-swatch-click-token detach-token open-modal-from-token]}]
(let [;; `active-tokens` may be provided as a `delay` (lazy computation).
;; In that case we must deref it (`@active-tokens`) to force evaluation
;; and obtain the actual value. If its already realized (not a delay),
@@ -77,21 +77,22 @@
@active-tokens
active-tokens)
color-tokens (:color active-tokens)
active-color-tokens (:color active-tokens)
token (some #(when (= (:name %) color-token) %) color-tokens)
token (some #(when (= (:name %) applied-token-name) %) active-color-tokens)
on-detach-token
(mf/use-fn
(mf/deps detach-token token color-token)
(mf/deps detach-token token applied-token-name)
(fn []
(let [token (or token color-token)]
(let [token (or token applied-token-name)]
(detach-token token))))
has-errors (some? (:errors token))
token-name (:name token)
resolved (:resolved-value token)
not-active (and (some? active-tokens) (nil? token))
not-active (and (empty? active-tokens)
(nil? token))
id (dm/str (:id token) "-name")
swatch-tooltip-content (cond
not-active
@@ -109,7 +110,7 @@
#(mf/html
[:div
[:span (dm/str (tr "workspace.tokens.token-name") ": ")]
[:span {:class (stl/css :token-name-tooltip)} color-token]]))]
[:span {:class (stl/css :token-name-tooltip)} applied-token-name]]))]
[:div {:class (stl/css :color-info)}
[:div {:class (stl/css-case :token-color-wrapper true
@@ -128,7 +129,7 @@
:class (stl/css :token-tooltip)}
[:div {:class (stl/css :token-name)
:aria-labelledby id}
(or token-name color-token)]]
(or token-name applied-token-name)]]
[:div {:class (stl/css :token-actions)}
[:> icon-button*
{:variant "action"
@@ -146,7 +147,11 @@
on-change on-reorder on-detach on-open on-close on-remove origin on-detach-token
disable-drag on-focus on-blur select-only select-on-focus on-token-change applied-token]}]
(let [token-color (contains? cfg/flags :token-color)
(let [;; TODO: Remove this workaround fixing `get-attrs*` fn on sidebar/options/shapes/multiple.cljs
applied-token (if (= :multiple applied-token)
nil
applied-token)
token-color (contains? cfg/flags :token-color)
libraries (mf/deref refs/files)
color-without-hash (mf/use-memo
@@ -177,7 +182,6 @@
(-> (deref active-tokens*)
(select-keys (get tk/tokens-by-input origin))
(not-empty)))))
on-focus'
(mf/use-fn
(mf/deps on-focus)
@@ -352,7 +356,7 @@
(cond
(and token-color applied-token)
[:> color-token-row* {:active-tokens tokens
:color-token applied-token
:applied-token-name applied-token
:color (dissoc color :ref-id :ref-file)
:on-swatch-click-token on-swatch-click-token
:detach-token detach-token

View File

@@ -63,7 +63,8 @@
:data {:index index})
[nil nil])
stroke-color-token (:stroke-color applied-tokens)
stroke-color-token
(:stroke-color applied-tokens)
on-color-change-refactor
(mf/use-fn

View File

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

View File

@@ -43,8 +43,8 @@
;; 2) Indexed Color Input
;; - Used when the tokens 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))))

View File

@@ -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.
;;

View File

@@ -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.
;;
;;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,11 +13,16 @@
[app.common.geom.shapes :as gsh]
[app.common.types.color :as clr]
[app.common.types.component :as ctk]
[app.common.types.container :as ctn]
[app.common.types.path :as path]
[app.common.types.shape :as cts]
[app.common.types.shape-tree :as ctt]
[app.common.types.shape.layout :as ctl]
[app.config :as cf]
[app.graph-wasm.api :as graph-wasm.api]
[app.main.data.workspace.componentize :as dwc]
[app.main.data.workspace.modifiers :as dwm]
[app.main.data.workspace.selection :as dws]
[app.main.data.workspace.variants :as dwv]
[app.main.features :as features]
[app.main.refs :as refs]
@@ -57,8 +62,11 @@
[app.main.ui.workspace.viewport.utils :as utils]
[app.main.ui.workspace.viewport.viewport-ref :refer [create-viewport-ref]]
[app.main.ui.workspace.viewport.widgets :as widgets]
[app.main.worker :as worker]
[app.util.debug :as dbg]
[app.util.modules :as mod]
[beicon.v2.core :as rx]
[promesa.core :as p]
[rumext.v2 :as mf]))
;; --- Viewport
@@ -134,6 +142,7 @@
mod? (mf/use-state false)
space? (mf/use-state false)
z? (mf/use-state false)
g? (mf/use-state false)
cursor (mf/use-state #(utils/get-cursor :pointer-inner))
hover-ids (mf/use-state nil)
hover (mf/use-state nil)
@@ -302,12 +311,79 @@
(mf/use-fn
(mf/deps first-shape)
#(st/emit!
(dwv/add-new-variant (:id first-shape))))]
(dwv/add-new-variant (:id first-shape))))
graph-wasm-enabled? (features/use-feature "graph-wasm/v1")]
(mf/with-effect [page-id]
(when graph-wasm-enabled?
;; Initialize graph-wasm in the worker to avoid blocking main thread
(let [subscription
(->> (worker/ask! {:cmd :graph-wasm/init})
(rx/filter #(= (:status %) :ok))
(rx/take 1)
(rx/merge-map (fn [_]
(worker/ask! {:cmd :graph-wasm/set-objects
:objects base-objects}))))]
(rx/subscribe subscription
(fn [result]
(when (= (:status result) :ok)
(js/console.debug "Graph WASM initialized in worker"
(select-keys result [:processed]))))
(fn [error]
(js/console.error "Error initializing graph-wasm in worker:" error))
(fn []
(js/console.debug "Graph WASM worker operations completed"))))))
(mf/with-effect [selected @g?]
(when graph-wasm-enabled?
;; Search for similar shapes when selection changes or when
;; the user presses the \"c\" key while having a single
;; selection.
(when (and @g?
(some? selected)
(= (count selected) 1))
(let [selected-id (first selected)
selected-shape (get base-objects selected-id)
;; Skip shapes that already belong to a component
non-component? (and (some? selected-shape)
(not (ctn/in-any-component? base-objects selected-shape)))]
(println selected-shape)
(println (ctn/in-any-component? base-objects selected-shape))
(when non-component?
(let [subscription
(worker/ask! {:cmd :graph-wasm/search-similar-shapes
:shape-id selected-id})]
(rx/subscribe subscription
(fn [result]
(when (= (:status result) :ok)
(let [raw-similar-shapes (:similar-shapes result)
;; Filter out shapes that already belong to some component
;; (main instance, instance head or inside a component copy).
similar-shapes (->> raw-similar-shapes
(remove (fn [sid]
(when-let [s (get base-objects sid)]
(ctn/in-any-component? base-objects s))))
(into []))]
(when (d/not-empty? similar-shapes)
;; Transform the selected subtree into a component and
;; replace similar subtrees by instances of that component.
(st/emit! (dwc/componentize-similar-subtrees
selected-id
similar-shapes))))))
(fn [error]
(js/console.error "Error searching similar shapes:" error))
(fn []
(js/console.debug "Similar shapes search completed")))))))))
(hooks/setup-dom-events zoom disable-paste-ref in-viewport-ref read-only? drawing-tool path-drawing?)
(hooks/setup-viewport-size vport viewport-ref)
(hooks/setup-cursor cursor alt? mod? space? panning drawing-tool path-drawing? path-editing? z? read-only?)
(hooks/setup-keyboard alt? mod? space? z? shift?)
(hooks/setup-keyboard alt? mod? space? z? shift? g?)
(hooks/setup-hover-shapes page-id move-stream base-objects transform selected mod? hover measure-hover
hover-ids hover-top-frame-id @hover-disabled? focus zoom show-measures?)
(hooks/setup-viewport-modifiers modifiers base-objects)

View File

@@ -16,6 +16,7 @@
[app.common.geom.shapes.points :as gpo]
[app.common.types.shape.layout :as ctl]
[app.common.uuid :as uuid]
[app.render-wasm.api :as wasm.api]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
@@ -275,3 +276,26 @@
:y2 (:y end-p)
:style {:stroke "red"
:stroke-width (/ 1 zoom)}}]))]))))
(mf/defc debug-text-wasm-position-data
{::mf/wrap-props false}
[props]
(let [zoom (unchecked-get props "zoom")
selected-shapes (unchecked-get props "selected-shapes")
selected-text
(when (and (= (count selected-shapes) 1) (= :text (-> selected-shapes first :type)))
(first selected-shapes))
position-data
(when selected-text
(wasm.api/calculate-position-data selected-text))]
(for [{:keys [x y width height]} position-data]
[:rect {:x x
:y (- y height)
:width width
:height height
:fill "none"
:strokeWidth (/ 1 zoom)
:stroke "red"}])))

View File

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

View File

@@ -124,7 +124,7 @@
(reset! cursor new-cursor))))))
(defn setup-keyboard
[alt* mod* space* z* shift*]
[alt* mod* space* z* shift* g*]
(let [kbd-zoom-s
(mf/with-memo []
(->> ms/keyboard
@@ -151,12 +151,22 @@
(rx/filter kbd/z?)
(rx/filter (complement kbd/editing-event?))
(rx/map kbd/key-down-event?)
(rx/pipe (rxo/distinct-contiguous))))]
(rx/pipe (rxo/distinct-contiguous))))
kbd-g-s
(mf/with-memo []
(let [c-pred (kbd/is-key-ignore-case? "g")]
(->> ms/keyboard
(rx/filter c-pred)
(rx/filter (complement kbd/editing-event?))
(rx/map kbd/key-down-event?)
(rx/pipe (rxo/distinct-contiguous)))))]
(hooks/use-stream ms/keyboard-alt (partial reset! alt*))
(hooks/use-stream ms/keyboard-space (partial reset! space*))
(hooks/use-stream kbd-z-s (partial reset! z*))
(hooks/use-stream kbd-shift-s (partial reset! shift*))
(hooks/use-stream kbd-g-s (partial reset! g*))
(hooks/use-stream ms/keyboard-mod
(fn [value]
(reset! mod* value)

View File

@@ -12,10 +12,12 @@
[app.common.files.helpers :as cfh]
[app.common.geom.shapes :as gsh]
[app.common.types.color :as clr]
[app.common.types.component :as ctk]
[app.common.types.path :as path]
[app.common.types.shape :as cts]
[app.common.types.shape.layout :as ctl]
[app.main.data.workspace.transforms :as dwt]
[app.main.data.workspace.variants :as dwv]
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.store :as st]
@@ -54,15 +56,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 +96,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)
@@ -124,6 +122,7 @@
mod? (mf/use-state false)
space? (mf/use-state false)
z? (mf/use-state false)
c? (mf/use-state false)
cursor (mf/use-state (utils/get-cursor :pointer-inner))
hover-ids (mf/use-state nil)
hover (mf/use-state nil)
@@ -261,6 +260,16 @@
first-shape (first selected-shapes)
show-add-variant? (and single-select?
(or (ctk/is-variant-container? first-shape)
(ctk/is-variant? first-shape)))
add-variant
(mf/use-fn
(mf/deps first-shape)
#(st/emit!
(dwv/add-new-variant (:id first-shape))))
show-padding?
(and (nil? transform)
single-select?
@@ -352,7 +361,7 @@
(hooks/setup-dom-events zoom disable-paste-ref in-viewport-ref read-only? drawing-tool path-drawing?)
(hooks/setup-viewport-size vport viewport-ref)
(hooks/setup-cursor cursor alt? mod? space? panning drawing-tool path-drawing? path-editing? z? read-only?)
(hooks/setup-keyboard alt? mod? space? z? shift?)
(hooks/setup-keyboard alt? mod? space? z? shift? c?)
(hooks/setup-hover-shapes page-id move-stream base-objects transform selected mod? hover measure-hover
hover-ids hover-top-frame-id @hover-disabled? focus zoom show-measures?)
(hooks/setup-shortcuts path-editing? path-drawing? text-editing? grid-editing?)
@@ -639,6 +648,12 @@
:hover-top-frame-id @hover-top-frame-id
:zoom zoom}])
(when (dbg/enabled? :text-outline)
[:& wvd/debug-text-wasm-position-data
{:selected-shapes selected-shapes
:objects base-objects
:zoom zoom}])
(when show-selection-handlers?
[:g.selection-handlers {:clipPath "url(#clip-handlers)"}
(when-not text-editing?
@@ -667,6 +682,11 @@
{:id (first selected)
:zoom zoom}])
(when show-add-variant?
[:> widgets/button-add* {:shape first-shape
:zoom zoom
:on-click add-variant}])
[:g.grid-layout-editor {:clipPath "url(#clip-handlers)"}
(when show-grid-editor?
[:& grid-layout/editor

View File

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

View File

@@ -20,10 +20,10 @@
[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]
[app.main.ui.shapes.text]
[app.main.worker :as mw]
[app.render-wasm.api.fonts :as f]
[app.render-wasm.api.texts :as t]
@@ -34,7 +34,7 @@
[app.render-wasm.performance :as perf]
[app.render-wasm.serializers :as sr]
[app.render-wasm.serializers.color :as sr-clr]
[app.render-wasm.svg-fills :as svg-fills]
[app.render-wasm.svg-filters :as svg-filters]
;; FIXME: rename; confunsing name
[app.render-wasm.wasm :as wasm]
[app.util.debug :as dbg]
@@ -43,6 +43,7 @@
[app.util.modules :as mod]
[app.util.text.content :as tc]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[promesa.core :as p]
[rumext.v2 :as mf]))
@@ -61,6 +62,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))
@@ -701,7 +705,7 @@
(set-grid-layout-columns (get shape :layout-grid-columns))
(set-grid-layout-cells (get shape :layout-grid-cells)))
(defn set-layout-child
(defn set-layout-data
[shape]
(let [margins (get shape :layout-item-margin)
margin-top (get margins :m1 0)
@@ -724,7 +728,7 @@
is-absolute (boolean (get shape :layout-item-absolute))
z-index (get shape :layout-item-z-index)]
(h/call wasm/internal-module
"_set_layout_child_data"
"_set_layout_data"
margin-top
margin-right
margin-bottom
@@ -744,6 +748,11 @@
is-absolute
(d/nilv z-index 0))))
(defn has-any-layout-prop? [shape]
(some #(and (keyword? %)
(str/starts-with? (name %) "layout-"))
(keys shape)))
(defn clear-layout
[]
(h/call wasm/internal-module "_clear_shape_layout"))
@@ -751,10 +760,10 @@
(defn- set-shape-layout
[shape objects]
(clear-layout)
(when (or (ctl/any-layout? shape)
(ctl/any-layout-immediate-child? objects shape))
(set-layout-child shape))
(ctl/any-layout-immediate-child? objects shape)
(has-any-layout-prop? shape))
(set-layout-data shape))
(when (ctl/flex-layout? shape)
(set-flex-layout shape))
@@ -829,7 +838,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)]
@@ -873,27 +882,43 @@
(def render-finish
(letfn [(do-render [ts]
(perf/begin-measure "render-finish")
(h/call wasm/internal-module "_set_view_end")
(render ts))]
(fns/debounce do-render 100)))
(render ts)
(perf/end-measure "render-finish"))]
(fns/debounce do-render DEBOUNCE_DELAY_MS)))
(def render-pan
(fns/throttle render 10))
(letfn [(do-render-pan [ts]
(perf/begin-measure "render-pan")
(render ts)
(perf/end-measure "render-pan"))]
(fns/throttle do-render-pan THROTTLE_DELAY_MS)))
(defn set-view-box
[prev-zoom zoom vbox]
(h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox)))
(let [is-pan (mth/close? prev-zoom zoom)]
(perf/begin-measure "set-view-box")
(h/call wasm/internal-module "_set_view_start")
(h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox)))
(if (mth/close? prev-zoom zoom)
(do (render-pan)
(render-finish))
(do (h/call wasm/internal-module "_render_from_cache" 0)
(render-finish))))
(if is-pan
(do (perf/end-measure "set-view-box")
(perf/begin-measure "set-view-box::pan")
(render-pan)
(render-finish)
(perf/end-measure "set-view-box::pan"))
(do (perf/end-measure "set-view-box")
(perf/begin-measure "set-view-box::zoom")
(h/call wasm/internal-module "_render_from_cache" 0)
(render-finish)
(perf/end-measure "set-view-box::zoom")))))
(defn set-object
[objects shape]
(perf/begin-measure "set-object")
(let [id (dm/get-prop shape :id)
(let [shape (svg-filters/apply-svg-derived shape)
id (dm/get-prop shape :id)
type (dm/get-prop shape :type)
parent-id (get shape :parent-id)
@@ -907,14 +932,7 @@
rotation (get shape :rotation)
transform (get shape :transform)
;; If the shape comes from an imported SVG (we know this because
;; it has the :svg-attrs attribute) and it does not have its
;; own fill, we set a default black fill. This fill will be
;; inherited by child nodes and emulates the behavior of
;; standard SVG, where a node without an explicit fill
;; defaults to black.
fills (svg-fills/resolve-shape-fills shape)
fills (get shape :fills)
strokes (if (= type :group)
[] (get shape :strokes))
children (get shape :shapes)
@@ -958,12 +976,11 @@
(set-shape-svg-attrs svg-attrs))
(when (and (some? content) (= type :svg-raw))
(set-shape-svg-raw-content (get-static-markup shape)))
(when (some? shadows) (set-shape-shadows shadows))
(set-shape-shadows shadows)
(when (= type :text)
(set-shape-grow-type grow-type))
(set-shape-layout shape objects)
(set-shape-selrect selrect)
(let [pending_thumbnails (into [] (concat
@@ -987,10 +1004,7 @@
(run!
(fn [id]
(f/update-text-layout id)
(mw/emit! {:cmd :index/update-text-rect
:page-id (:current-page-id @st/state)
:shape-id id
:dimensions (get-text-dimensions id)})))))
(update-text-rect! id)))))
(defn process-pending
([shapes thumbnails full on-complete]
@@ -1044,6 +1058,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
@@ -1230,6 +1245,8 @@
(when-not (nil? context)
(let [handle (.registerContext ^js gl context #js {"majorVersion" 2})]
(.makeContextCurrent ^js gl handle)
(set! wasm/gl-context-handle handle)
(set! wasm/gl-context context)
;; Force the WEBGL_debug_renderer_info extension as emscripten does not enable it
(.getExtension context "WEBGL_debug_renderer_info")
@@ -1247,9 +1264,29 @@
(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")
;; Ensure the WebGL context is properly disposed so browsers do not keep
;; accumulating active contexts between page switches.
(when-let [gl (unchecked-get wasm/internal-module "GL")]
(when-let [handle wasm/gl-context-handle]
(try
;; Ask the browser to release resources explicitly if available.
(when-let [ctx wasm/gl-context]
(when-let [lose-ext (.getExtension ^js ctx "WEBGL_lose_context")]
(.loseContext ^js lose-ext)))
(.deleteContext ^js gl handle)
(finally
(set! wasm/gl-context-handle nil)
(set! wasm/gl-context nil)))))
;; 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 +1329,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 +1340,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 +1352,93 @@
(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)))
(def POSITION-DATA-U8-SIZE 36)
(def POSITION-DATA-U32-SIZE (/ POSITION-DATA-U8-SIZE 4))
(defn calculate-position-data
[shape]
(when wasm/context-initialized?
(use-shape (:id shape))
(let [heapf32 (mem/get-heap-f32)
heapu32 (mem/get-heap-u32)
offset (-> (h/call wasm/internal-module "_calculate_position_data")
(mem/->offset-32))
length (aget heapu32 offset)
max-offset (+ offset 1 (* length POSITION-DATA-U32-SIZE))
result
(loop [result (transient [])
offset (inc offset)]
(if (< offset max-offset)
(let [entry (dr/read-position-data-entry heapu32 heapf32 offset)]
(recur (conj! result entry)
(+ offset POSITION-DATA-U32-SIZE)))
(persistent! result)))
result
(->> result
(mapv
(fn [{:keys [paragraph span start-pos end-pos direction x y width height]}]
(let [content (:content shape)
element (-> content :children
(get 0) :children ;; paragraph-set
(get paragraph) :children ;; paragraph
(get span))
text (subs (:text element) start-pos end-pos)]
{:x x
:y (+ y height)
:width width
:height height
:direction (dr/translate-direction direction)
:font-family (get element :font-family)
:font-size (get element :font-size)
:font-weight (get element :font-weight)
:text-transform (get element :text-transform)
:text-decoration (get element :text-decoration)
:letter-spacing (get element :letter-spacing)
:font-style (get element :font-style)
:fills (get element :fills)
:text text}))))]
(mem/free)
result)))
(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

View File

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

View 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,
};

View File

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

View File

@@ -45,4 +45,29 @@
:center (gpt/point cx cy)
:transform (gmt/matrix a b c d e f)}))
(defn read-position-data-entry
[heapu32 heapf32 offset]
(let [paragraph (aget heapu32 (+ offset 0))
span (aget heapu32 (+ offset 1))
start-pos (aget heapu32 (+ offset 2))
end-pos (aget heapu32 (+ offset 3))
x (aget heapf32 (+ offset 4))
y (aget heapf32 (+ offset 5))
width (aget heapf32 (+ offset 6))
height (aget heapf32 (+ offset 7))
direction (aget heapu32 (+ offset 8))]
{:paragraph paragraph
:span span
:start-pos start-pos
:end-pos end-pos
:x x
:y y
:width width
:height height
:direction direction}))
(defn translate-direction
[direction]
(case direction
0 "rtl"
"ltr"))

View File

@@ -14,7 +14,7 @@
[app.common.types.shape.layout :as ctl]
[app.main.refs :as refs]
[app.render-wasm.api :as api]
[app.render-wasm.svg-fills :as svg-fills]
[app.render-wasm.svg-filters :as svg-filters]
[app.render-wasm.wasm :as wasm]
[beicon.v2.core :as rx]
[cljs.core :as c]
@@ -130,7 +130,11 @@
(defn- set-wasm-attr!
[shape k]
(when wasm/context-initialized?
(let [v (get shape k)
(let [shape (case k
:svg-attrs (svg-filters/apply-svg-derived (assoc shape :svg-attrs (get shape :svg-attrs)))
(:fills :blur :shadow) (svg-filters/apply-svg-derived shape)
shape)
v (get shape k)
id (get shape :id)]
(case k
:parent-id
@@ -163,8 +167,7 @@
(api/set-shape-transform v)
:fills
(let [fills (svg-fills/resolve-shape-fills shape)]
(into [] (api/set-shape-fills id fills false)))
(api/set-shape-fills id v false)
:strokes
(into [] (api/set-shape-strokes id v false))
@@ -222,8 +225,12 @@
v])
:svg-attrs
(when (cfh/path-shape? shape)
(api/set-shape-svg-attrs v))
(do
(api/set-shape-svg-attrs v)
;; Always update fills/blur/shadow to clear previous state if filters disappear
(api/set-shape-fills id (:fills shape) false)
(api/set-shape-blur (:blur shape))
(api/set-shape-shadows (:shadow shape)))
:masked-group
(when (cfh/mask-shape? shape)
@@ -262,7 +269,7 @@
:layout-item-min-w
:layout-item-absolute
:layout-item-z-index)
(api/set-layout-child shape)
(api/set-layout-data shape)
:layout-grid-rows
(api/set-grid-layout-rows v)
@@ -291,7 +298,8 @@
(api/set-grid-layout-data shape)
(ctl/flex-layout? shape)
(api/set-flex-layout shape)))
(api/set-flex-layout shape))
(api/set-layout-data shape))
;; Property not in WASM
nil))))
@@ -322,7 +330,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]

View File

@@ -74,6 +74,30 @@
:width (max 0.01 (or (dm/get-prop shape :width) 1))
:height (max 0.01 (or (dm/get-prop shape :height) 1))}))))
(defn- apply-svg-transform
"Applies SVG transform to a point if present."
[pt svg-transform]
(if svg-transform
(gpt/transform pt svg-transform)
pt))
(defn- apply-viewbox-transform
"Transforms a point from viewBox space to selrect space."
[pt viewbox rect]
(if viewbox
(let [{svg-x :x svg-y :y svg-width :width svg-height :height} viewbox
rect-width (max 0.01 (dm/get-prop rect :width))
rect-height (max 0.01 (dm/get-prop rect :height))
origin-x (or (dm/get-prop rect :x) (dm/get-prop rect :x1) 0)
origin-y (or (dm/get-prop rect :y) (dm/get-prop rect :y1) 0)
scale-x (/ rect-width svg-width)
scale-y (/ rect-height svg-height)
;; Transform from viewBox space to selrect space
transformed-x (+ origin-x (* (- (dm/get-prop pt :x) svg-x) scale-x))
transformed-y (+ origin-y (* (- (dm/get-prop pt :y) svg-y) scale-y))]
(gpt/point transformed-x transformed-y))
pt))
(defn- normalize-point
[pt units shape]
(if (= units "userspaceonuse")
@@ -81,9 +105,16 @@
width (max 0.01 (dm/get-prop rect :width))
height (max 0.01 (dm/get-prop rect :height))
origin-x (or (dm/get-prop rect :x) (dm/get-prop rect :x1) 0)
origin-y (or (dm/get-prop rect :y) (dm/get-prop rect :y1) 0)]
(gpt/point (/ (- (dm/get-prop pt :x) origin-x) width)
(/ (- (dm/get-prop pt :y) origin-y) height)))
origin-y (or (dm/get-prop rect :y) (dm/get-prop rect :y1) 0)
svg-transform (:svg-transform shape)
viewbox (:svg-viewbox shape)
;; For userSpaceOnUse, coordinates are in SVG user space
;; We need to transform them to shape space before normalizing
pt-after-svg-transform (apply-svg-transform pt svg-transform)
transformed-pt (apply-viewbox-transform pt-after-svg-transform viewbox rect)
normalized-x (/ (- (dm/get-prop transformed-pt :x) origin-x) width)
normalized-y (/ (- (dm/get-prop transformed-pt :y) origin-y) height)]
(gpt/point normalized-x normalized-y))
pt))
(defn- normalize-attrs
@@ -257,18 +288,25 @@
(parse-gradient-stop node))))
vec)]
(when (seq stops)
(let [[center radius-point]
(let [[center point-x point-y]
(let [points (apply-gradient-transform [(gpt/point cx cy)
(gpt/point (+ cx r) cy)]
(gpt/point (+ cx r) cy)
(gpt/point cx (+ cy r))]
transform)]
(map #(normalize-point % units shape) points))
radius (gpt/distance center radius-point)]
radius-x (gpt/distance center point-x)
radius-y (gpt/distance center point-y)
;; Prefer Y as the base radius so width becomes the X/Y ratio.
base-radius (if (pos? radius-y) radius-y radius-x)
radius-point (if (pos? radius-y) point-y point-x)
width (let [safe-radius (max base-radius 1.0e-6)]
(/ radius-x safe-radius))]
{:type :radial
:start-x (dm/get-prop center :x)
:start-y (dm/get-prop center :y)
:end-x (dm/get-prop radius-point :x)
:end-y (dm/get-prop radius-point :y)
:width radius
:width width
:stops stops}))))
(defn- svg-gradient->fill

View File

@@ -0,0 +1,98 @@
;; 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.render-wasm.svg-filters
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.svg :as csvg]
[app.common.uuid :as uuid]
[app.render-wasm.svg-fills :as svg-fills]))
(def ^:private drop-shadow-tags
#{:feOffset :feGaussianBlur :feColorMatrix})
(defn- find-filter-element
"Finds a filter element by tag in filter content."
[filter-content tag]
(some #(when (= tag (:tag %)) %) filter-content))
(defn- find-filter-def
[shape]
(let [filter-attr (or (dm/get-in shape [:svg-attrs :filter])
(dm/get-in shape [:svg-attrs :style :filter]))
svg-defs (dm/get-prop shape :svg-defs)]
(when (and filter-attr svg-defs)
(let [filter-ids (csvg/extract-ids filter-attr)]
(some #(get svg-defs %) filter-ids)))))
(defn- build-blur
[gaussian-blur]
(when gaussian-blur
{:id (uuid/next)
:type :layer-blur
;; For layer blur the value matches stdDeviation directly
:value (-> (dm/get-in gaussian-blur [:attrs :stdDeviation])
(d/parse-double 0))
:hidden false}))
(defn- build-drop-shadow
[filter-content drop-shadow-elements]
(let [offset-elem (find-filter-element filter-content :feOffset)]
(when (and offset-elem (seq drop-shadow-elements))
(let [blur-elem (find-filter-element drop-shadow-elements :feGaussianBlur)
dx (-> (dm/get-in offset-elem [:attrs :dx])
(d/parse-double 0))
dy (-> (dm/get-in offset-elem [:attrs :dy])
(d/parse-double 0))
blur-value (if blur-elem
(-> (dm/get-in blur-elem [:attrs :stdDeviation])
(d/parse-double 0)
(* 2))
0)]
[{:id (uuid/next)
:style :drop-shadow
:offset-x dx
:offset-y dy
:blur blur-value
:spread 0
:hidden false
;; TODO: parse feColorMatrix to extract color/opacity
:color {:color "#000000" :opacity 1}}]))))
(defn apply-svg-filters
"Derives native blur/shadow from SVG filter definitions when the shape does
not already have them. The SVG attributes are left untouched so SVG fallback
rendering keeps working the same way as gradient fills."
[shape]
(let [existing-blur (:blur shape)
existing-shadow (:shadow shape)]
(if-let [filter-def (find-filter-def shape)]
(let [content (:content filter-def)
gaussian-blur (find-filter-element content :feGaussianBlur)
drop-shadow-elements (filter #(contains? drop-shadow-tags (:tag %)) content)
blur (or existing-blur (build-blur gaussian-blur))
shadow (if (seq existing-shadow)
existing-shadow
(build-drop-shadow content drop-shadow-elements))]
(cond-> shape
blur (assoc :blur blur)
(seq shadow) (assoc :shadow shadow)))
shape)))
(defn apply-svg-derived
"Applies SVG-derived effects (fills, blur, shadows) uniformly.
- Keeps user fills if present; otherwise derives from SVG.
- Converts SVG filters into native blur/shadow when needed.
- Always returns shape with :fills (possibly []) and blur/shadow keys."
[shape]
(let [shape' (apply-svg-filters shape)
fills (or (svg-fills/resolve-shape-fills shape') [])]
(assoc shape'
:fills fills
:blur (:blur shape')
:shadow (:shadow shape'))))

Some files were not shown because too many files have changed in this diff Show More