Compare commits

...

57 Commits

Author SHA1 Message Date
Alejandro Alonso
2ded3211b5 🐛 Fix using cache on first zoom after pan 2025-12-30 09:49:12 +01:00
Alejandro Alonso
0bcec53ad3 🐛 Detecting situations where WebGL context is lost or no WebGL support 2025-12-30 09:47:48 +01:00
Alejandro Alonso
5e8d46e47b 🎉 Resize cache only when required 2025-12-30 09:47:48 +01:00
Alejandro Alonso
cc43f4c1af 🐛 Fix resize cache memory leak 2025-12-30 09:47:48 +01:00
alonso.torres
032c2c5edb 🐛 Fix problem when changing colors with multiple fonts 2025-12-30 09:47:48 +01:00
Alejandro Alonso
8df488d0b7 🐛 Fix object added in different page (#7988) 2025-12-30 09:47:48 +01:00
Alejandro Alonso
44df4aca48 🐛 Fix unmasking shapes (#7989) 2025-12-30 09:47:48 +01:00
Alonso Torres
c7b47f5d98 🐛 Fix font weight token (#7991) 2025-12-30 09:47:48 +01:00
Alejandro Alonso
1e2f87e8f7 🐛 Fix comment bubbles (#7990) 2025-12-30 09:47:48 +01:00
Belén Albeza
6586ab79e6 🐛 Fix text editor not getting focus back after font variant change 2025-12-30 09:47:48 +01:00
alonso.torres
5497fa2a66 🐛 Fix problems with alignments and margins 2025-12-30 09:47:48 +01:00
alonso.torres
afa43e35f4 🐛 Fix problem with flex fill size distribution 2025-12-30 09:47:48 +01:00
alonso.torres
33b8d4b04b 🐛 Fix problem with reflow layout 2025-12-30 09:47:48 +01:00
Alejandro Alonso
e9fd384d94 🐛 Fix too many active WEBGL contexts 2025-12-30 09:47:48 +01:00
alonso.torres
5561946a0a 🐛 Fix problem with border radius to path 2025-12-30 09:47:48 +01:00
Belén Albeza
153452a40c ♻️ Make SerializableResult to depend on From traits 2025-12-30 09:47:48 +01:00
alonso.torres
66aea88457 Calculate position data in wasm 2025-12-30 09:47:47 +01:00
Elena Torro
3493429b4e 🐛 Set layout data from set-object 2025-12-30 09:47:47 +01:00
Alejandro Alonso
886614ecab 🐛 Fix svg extract ids 2025-12-30 09:47:47 +01:00
Belén Albeza
fd9b4431f3 🐛 Fix text selection not being restore if it was only 1 word 2025-12-30 09:47:47 +01:00
Belén Albeza
38ffc809f2 🔧 Add formatting rules to the TextEditor 2025-12-30 09:47:47 +01:00
Alejandro Alonso
9b9dd57050 🎉 Improve svg import 2025-12-30 09:47:47 +01:00
Aitor Moreno
f4f3d9d8c3 🐛 Fix font-variant-id mixed value 2025-12-30 09:47:47 +01:00
Elena Torro
6eeec08331 🔧 Log performance when building using profile-macros 2025-12-30 09:47:47 +01:00
Elena Torro
b5da73f957 🔧 Rebuild indices on zoom change, not pan 2025-12-30 09:47:47 +01:00
Elena Torro
3fc6a70da6 🔧 Skip slow operations on fast render 2025-12-30 09:47:47 +01:00
Elena Torro
f3a442cf24 🔧 Support variants interactivity on the new render's UI 2025-12-30 09:47:47 +01:00
Elena Torro
cae9afc5b7 🐛 Fix default case on vertical align 2025-12-30 09:47:47 +01:00
Elena Torro
084c5141a2 🔧 Fix line height calculation 2025-12-30 09:47:47 +01:00
alonso.torres
422be8f4f5 🐛 Fix problem when exporting texts 2025-12-30 09:47:47 +01:00
Belén Albeza
28682e4c5c 🐛 Fix internal error while importing a library 2025-12-30 09:47:47 +01:00
Elena Torro
ecc63e5cec 🔧 Update rendering settings to smooth render 2025-12-30 09:47:47 +01:00
Aitor Moreno
be7d91a5c4 🐛 Fix applyStylesTo entire selection 2025-12-30 09:47:47 +01:00
Elena Torro
b2736be858 🐛 Fix italic variant 2025-12-30 09:47:47 +01:00
Elena Torro
3f4b7c5fcc 🐛 Do not merge fill styles 2025-12-30 09:47:47 +01:00
Elena Torro
f23724ddfe 🐛 Fix selectAll on mixed span styles 2025-12-30 09:47:47 +01:00
Elena Torro
bfb6d63a6d 🐛 Fix merge fill styles when there are multiple fills 2025-12-30 09:47:47 +01:00
alonso.torres
0fadcde413 🐛 Fix race condition with fix fonts patch 2025-12-30 09:47:47 +01:00
alonso.torres
95bf311c24 🐛 Fix race condition with text and type 2025-12-30 09:47:47 +01:00
alonso.torres
cbba2c6de1 🐛 Fix problem with boolean shapes updates 2025-12-30 09:47:45 +01:00
Belén Albeza
d4e09280ca 🐛 Fix viewport not being fully drawn on first load until a mouse hover 2025-12-30 09:46:48 +01:00
alonso.torres
307920168a 🐛 Fix problem with reordering layers 2025-12-30 09:46:48 +01:00
alonso.torres
5cbed12763 🐛 Fix outline with single click text creation 2025-12-30 09:46:48 +01:00
Elena Torro
bcbbbd0d5d 🐛 Fix create empty text on click regression 2025-12-30 09:46:48 +01:00
Aitor Moreno
4e33112e2c 🐛 Fix letter spacing applied to paragraph 2025-12-30 09:46:48 +01:00
alonso.torres
0349cc1859 🐛 Fix visual feedback on padding/margin/gaps modified 2025-12-30 09:46:48 +01:00
Aitor Moreno
922587bc9e Add text editor v2 integration tests 2025-12-30 09:46:48 +01:00
Elena Torro
d545de17a6 🔧 Normalize font attributes to support old formats 2025-12-30 09:46:48 +01:00
Alejandro Alonso
d30579aedb 🐛 Fix nested shadows clipping 2025-12-30 09:46:48 +01:00
alonso.torres
5db9b173c4 🐛 Fix problem with auto-size and element margins 2025-12-30 09:46:48 +01:00
alonso.torres
851d7d414a 🐛 Fix problem with grid layout editor 2025-12-30 09:46:48 +01:00
alonso.torres
af5b672c3e 🐛 Fix problems with flex layout in new render 2025-12-30 09:46:48 +01:00
alonso.torres
ce8aeb7028 🐛 Fix crash when cleanup 2025-12-30 09:46:48 +01:00
alonso.torres
b023184394 🐛 Fix problem with change gap/margin/padding 2025-12-30 09:46:48 +01:00
Belén Albeza
6ddce5bcba 🐛 Fix mismatch between fonts for rendered and selected text when no fallback fonts apply 2025-12-30 09:46:48 +01:00
Belén Albeza
d9680ea159 Fix playwright tests 2025-12-30 09:46:48 +01:00
Belén Albeza
56e956644c 🔧 Update google fonts list 2025-12-30 09:46:47 +01:00
105 changed files with 18181 additions and 12893 deletions

View File

@@ -82,6 +82,113 @@
(declare create-svg-children) (declare create-svg-children)
(declare parse-svg-element) (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 (defn create-svg-shapes
([svg-data pos objects frame-id parent-id selected center?] ([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?)) (create-svg-shapes (uuid/next) svg-data pos objects frame-id parent-id selected center?))
@@ -112,6 +219,9 @@
(csvg/fix-percents) (csvg/fix-percents)
(csvg/extract-defs)) (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 ;; In penpot groups have the size of their children. To
;; respect the imported svg size and empty space let's create ;; respect the imported svg size and empty space let's create
;; a transparent shape as background to respect the imported ;; 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) (reduce (partial create-svg-children objects selected frame-id root-id svg-data)
[unames []] [unames []]
(d/enumerate (->> (:content svg-data) (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 (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) (let [props (csvg/attrs->props attrs)
vbox (grc/make-rect offset-x offset-y width height)] vbox (grc/make-rect offset-x offset-y width height)]
(cts/setup-shape (cts/setup-shape
@@ -160,10 +281,11 @@
:y y :y y
:content data :content data
:svg-attrs props :svg-attrs props
:svg-viewbox vbox}))) :svg-viewbox vbox
:svg-defs defs})))
(defn create-svg-root (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) (let [props (-> (dissoc attrs :viewBox :view-box :xmlns)
(d/without-keys csvg/inheritable-props) (d/without-keys csvg/inheritable-props)
(csvg/attrs->props))] (csvg/attrs->props))]
@@ -177,7 +299,8 @@
:height height :height height
:x (+ x offset-x) :x (+ x offset-x)
:y (+ y offset-y) :y (+ y offset-y)
:svg-attrs props}))) :svg-attrs props
:svg-defs defs})))
(defn create-svg-children (defn create-svg-children
[objects selected frame-id parent-id svg-data [unames children] [_index svg-element]] [objects selected frame-id parent-id svg-data [unames children] [_index svg-element]]
@@ -198,7 +321,7 @@
(defn create-group (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)) (let [transform (csvg/parse-transform (:transform attrs))
attrs (-> attrs attrs (-> attrs
(d/without-keys csvg/inheritable-props) (d/without-keys csvg/inheritable-props)
@@ -214,7 +337,8 @@
:height height :height height
:svg-transform transform :svg-transform transform
:svg-attrs attrs :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}] (defn create-path-shape [name frame-id svg-data {:keys [attrs] :as data}]
(when (and (contains? attrs :d) (seq (:d attrs))) (when (and (contains? attrs :d) (seq (:d attrs)))
@@ -523,6 +647,21 @@
:else (dm/str tag))] :else (dm/str tag))]
(dm/str "svg-" suffix))) (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 (defn parse-svg-element
[frame-id svg-data {:keys [tag attrs hidden] :as element} unames] [frame-id svg-data {:keys [tag attrs hidden] :as element} unames]
@@ -534,7 +673,11 @@
(let [name (or (:id attrs) (tag->name tag)) (let [name (or (:id attrs) (tag->name tag))
att-refs (csvg/find-attr-references attrs) att-refs (csvg/find-attr-references attrs)
defs (get svg-data :defs) 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 (or (:href attrs) (:xlink:href attrs) " ")
href-id (if (and (string? href-id) href-id (if (and (string? href-id)

View File

@@ -546,9 +546,19 @@
filter-values))) filter-values)))
(defn extract-ids [val] (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) (->> (re-seq xml-id-regex val)
(mapv second)))) (mapv second))
(sequential? val)
(mapcat extract-ids val)
:else
[]))
(defn fix-dot-number (defn fix-dot-number
"Fixes decimal numbers starting in dot but without leading 0" "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 "Calculate the boolean content from shape and objects. Returns a
packed PathData instance" packed PathData instance"
[shape objects] [shape objects]
(let [content (if (fn? wasm:calc-bool-content) (let [content (calc-bool-content* shape objects)]
(wasm:calc-bool-content (get shape :bool-type)
(get shape :shapes))
(calc-bool-content* shape objects))]
(impl/path-data content))) (impl/path-data content)))
(defn update-bool-shape (defn update-bool-shape
"Calculates the selrect+points for the boolean shape" "Calculates the selrect+points for the boolean shape"
[shape objects] [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)] shape (assoc shape :content content)]
(update-geometry shape))) (update-geometry shape)))

View File

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

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 @@
{ w
"~:revn": 2,
"~:lagged": []
}

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 { 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) { static async mockRPC(page, path, jsonFilename, options) {
if (!page) { if (!page) {
throw new TypeError("Invalid page argument. Must be a Playwright page."); throw new TypeError("Invalid page argument. Must be a Playwright page.");
@@ -93,6 +116,10 @@ export class BasePage {
return this.#page; return this.#page;
} }
async mockRPCs(paths, options) {
return BasePage.mockRPCs(this.page, paths, options);
}
async mockRPC(path, jsonFilename, options) { async mockRPC(path, jsonFilename, options) {
return BasePage.mockRPC(this.page, path, jsonFilename, options); return BasePage.mockRPC(this.page, path, jsonFilename, options);
} }

View File

@@ -1,7 +1,146 @@
import { expect } from "@playwright/test"; import { expect } from "@playwright/test";
import { readFile } from "node:fs/promises";
import { BaseWebSocketPage } from "./BaseWebSocketPage"; import { BaseWebSocketPage } from "./BaseWebSocketPage";
import { Transit } from "../../helpers/Transit";
export class WorkspacePage extends BaseWebSocketPage { 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`. * This should be called on `test.beforeEach`.
* *
@@ -11,50 +150,21 @@ export class WorkspacePage extends BaseWebSocketPage {
static async init(page) { static async init(page) {
await BaseWebSocketPage.initWebSockets(page); await BaseWebSocketPage.initWebSockets(page);
await BaseWebSocketPage.mockRPC( await BaseWebSocketPage.mockRPCs(page, {
page, "get-profile": "logged-in-user/get-profile-logged-in.json",
"get-profile", "get-team-users?file-id=*":
"logged-in-user/get-profile-logged-in.json", "logged-in-user/get-team-users-single-user.json",
); "get-comment-threads?file-id=*":
await BaseWebSocketPage.mockRPC( "workspace/get-comment-threads-empty.json",
page, "get-project?id=*": "workspace/get-project-default.json",
"get-team-users?file-id=*", "get-team?id=*": "workspace/get-team-default.json",
"logged-in-user/get-team-users-single-user.json", "get-teams": "get-teams.json",
); "get-team-members?team-id=*":
await BaseWebSocketPage.mockRPC( "logged-in-user/get-team-members-your-penpot.json",
page, "get-profiles-for-file-comments?file-id=*":
"get-comment-threads?file-id=*", "workspace/get-profile-for-file-comments.json",
"workspace/get-comment-threads-empty.json", "update-profile-props": "workspace/update-profile-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",
);
} }
static anyTeamId = "c7ce0794-0992-8105-8004-38e630f7920a"; static anyTeamId = "c7ce0794-0992-8105-8004-38e630f7920a";
@@ -62,9 +172,20 @@ export class WorkspacePage extends BaseWebSocketPage {
static anyFileId = "c7ce0794-0992-8105-8004-38f280443849"; static anyFileId = "c7ce0794-0992-8105-8004-38f280443849";
static anyPageId = "c7ce0794-0992-8105-8004-38f28044384a"; static anyPageId = "c7ce0794-0992-8105-8004-38f28044384a";
/**
* WebSocket mock
*
* @type {MockWebSocketHelper}
*/
#ws = null; #ws = null;
constructor(page) { /**
* Constructor
*
* @param {Page} page
* @param {} [options]
*/
constructor(page, options) {
super(page); super(page);
this.pageName = page.getByTestId("page-name"); this.pageName = page.getByTestId("page-name");
@@ -112,11 +233,14 @@ export class WorkspacePage extends BaseWebSocketPage {
"tokens-context-menu-for-set", "tokens-context-menu-for-set",
); );
this.contextMenuForShape = page.getByTestId("context-menu"); this.contextMenuForShape = page.getByTestId("context-menu");
if (options?.textEditor) {
this.textEditor = new WorkspacePage.TextEditor(this);
}
} }
async goToWorkspace({ async goToWorkspace({
fileId = WorkspacePage.anyFileId, fileId = this.fileId ?? WorkspacePage.anyFileId,
pageId = WorkspacePage.anyPageId, pageId = this.pageId ?? WorkspacePage.anyPageId,
} = {}) { } = {}) {
await this.page.goto( await this.page.goto(
`/#/workspace?team-id=${WorkspacePage.anyTeamId}&file-id=${fileId}&page-id=${pageId}`, `/#/workspace?team-id=${WorkspacePage.anyTeamId}&file-id=${fileId}&page-id=${pageId}`,
@@ -141,48 +265,59 @@ export class WorkspacePage extends BaseWebSocketPage {
} }
async setupEmptyFile() { async setupEmptyFile() {
await this.mockRPC( await this.mockRPCs({
"get-profile", "get-profile": "logged-in-user/get-profile-logged-in.json",
"logged-in-user/get-profile-logged-in.json", "get-team-users?file-id=*":
); "logged-in-user/get-team-users-single-user.json ",
await this.mockRPC( "get-comment-threads?file-id=*":
"get-team-users?file-id=*", "workspace/get-comment-threads-empty.json",
"logged-in-user/get-team-users-single-user.json", "get-project?id=*": "workspace/get-project-default.json",
); "get-team?id=*": "workspace/get-team-default.json",
await this.mockRPC( "get-profiles-for-file-comments?file-id=*":
"get-comment-threads?file-id=*", "workspace/get-profile-for-file-comments.json",
"workspace/get-comment-threads-empty.json", "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",
"get-project?id=*", "get-file-fragment?file-id=*": "workspace/get-file-fragment-blank.json",
"workspace/get-project-default.json", "get-file-libraries?file-id=*": "workspace/get-file-libraries-empty.json",
); });
await this.mockRPC("get-team?id=*", "workspace/get-team-default.json");
await this.mockRPC( if (this.textEditor) {
"get-profiles-for-file-comments?file-id=*", await this.mockRPC("update-file?id=*", "text-editor/update-file.json");
"workspace/get-profile-for-file-comments.json", }
);
await this.mockRPC(/get\-file\?/, "workspace/get-file-blank.json"); // by default we mock the blank file.
await this.mockRPC( await this.mockGetFile("workspace/get-file-blank.json");
"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",
);
} }
async mockGetFile(jsonFile) { async mockGetFile(jsonFilename, options) {
await this.mockRPC(/get\-file\?/, jsonFile); 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) { async mockGetAsset(regex, asset) {
@@ -190,22 +325,15 @@ export class WorkspacePage extends BaseWebSocketPage {
} }
async setupFileWithComments() { async setupFileWithComments() {
await this.mockRPC( await this.mockRPCs({
"get-comment-threads?file-id=*", "get-comment-threads?file-id=*":
"workspace/get-comment-threads-unread.json", "workspace/get-comment-threads-unread.json",
); "get-file-fragment?file-id=*&fragment-id=*":
await this.mockRPC( "viewer/get-file-fragment-single-board.json",
"get-file-fragment?file-id=*&fragment-id=*", "get-comments?thread-id=*": "workspace/get-thread-comments.json",
"viewer/get-file-fragment-single-board.json", "update-comment-thread-status":
); "workspace/update-comment-thread-status.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",
);
} }
async clickWithDragViewportAt(x, y, width, height) { async clickWithDragViewportAt(x, y, width, height) {
@@ -223,6 +351,67 @@ export class WorkspacePage extends BaseWebSocketPage {
await this.page.mouse.up(); 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) { async panOnViewportAt(x, y, width, height) {
await this.page.waitForTimeout(100); await this.page.waitForTimeout(100);
await this.viewport.hover({ position: { x, y } }); await this.viewport.hover({ position: { x, y } });
@@ -250,10 +439,15 @@ export class WorkspacePage extends BaseWebSocketPage {
await this.page.waitForTimeout(500); await this.page.waitForTimeout(500);
} }
async doubleClickLeafLayer(name, clickOptions = {}) {
await this.clickLeafLayer(name, clickOptions);
await this.clickLeafLayer(name, clickOptions);
}
async clickToggableLayer(name, clickOptions = {}) { async clickToggableLayer(name, clickOptions = {}) {
const layer = this.layers const layer = this.layers
.getByTestId("layer-row") .getByTestId("layer-row")
.filter({ hasText: name }); .filter({ hasText: name });
const button = layer.getByRole("button"); const button = layer.getByRole("button");
await button.waitFor(); 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", id: "a5f238bd-dd8a-8164-8007-1bc3481eaf05",
pageId: "a5f238bd-dd8a-8164-8007-1bc3481eaf06", pageId: "a5f238bd-dd8a-8164-8007-1bc3481eaf06",
}); });
await workspace.waitForFirstRender(); await workspace.waitForFirstRenderWithoutUI();
await expect(workspace.canvas).toHaveScreenshot(); await expect(workspace.canvas).toHaveScreenshot();
}); });

View File

@@ -18,6 +18,10 @@ const setupFile = async (workspacePage) => {
fileId: "7b2da435-6186-815a-8007-0daa95d2f26d", fileId: "7b2da435-6186-815a-8007-0daa95d2f26d",
pageId: "ce79274b-11ab-8088-8007-0487ad43f789", pageId: "ce79274b-11ab-8088-8007-0487ad43f789",
}); });
await workspacePage.mockRPC(
"update-file?id=*",
"workspace/update-file-empty.json",
);
}; };
const shapeToLayerName = { 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", pageId: "2b7f0188-51a1-8193-8006-e05bad87b74d",
}); });
await workspacePage.page.waitForTimeout(1000) await workspacePage.page.waitForTimeout(1000);
await workspacePage.waitForFirstRender(); await workspacePage.waitForFirstRender();
await expect( await expect(

View File

@@ -1,12 +1,323 @@
import { test, expect } from "@playwright/test"; import { test, expect } from "@playwright/test";
import { Clipboard } from "../../helpers/Clipboard";
import { WorkspacePage } from "../pages/WorkspacePage"; 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.init(page);
await WorkspacePage.mockConfigFlags(page, ["enable-feature-text-editor-v2"]); 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); const workspace = new WorkspacePage(page);
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-11552.json"); 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=*", "update-file?id=*",
"text-editor/update-file-11552.json", "text-editor/update-file-11552.json",
); );
await workspace.goToWorkspace();
await workspace.goToWorkspace({ await workspace.doubleClickLeafLayer("Lorem ipsum");
fileId: "238a17e0-75ff-8075-8006-934586ea2230",
pageId: "238a17e0-75ff-8075-8006-934586ea2231",
});
await workspace.clickLeafLayer("Lorem ipsum");
await workspace.clickLeafLayer("Lorem ipsum");
const fontSizeInput = workspace.rightSidebar.getByRole("textbox", { const fontSizeInput = workspace.rightSidebar.getByRole("textbox", {
name: "Font Size", name: "Font Size",
}); });
await expect(fontSizeInput).toBeVisible(); await expect(fontSizeInput).toBeVisible();
await workspace.page.keyboard.press("Enter"); await page.keyboard.press("Enter");
await workspace.page.keyboard.press("ArrowRight"); await page.keyboard.press("ArrowRight");
await fontSizeInput.fill("36"); await fontSizeInput.fill("36");

View File

@@ -303,7 +303,7 @@ test.describe("Tokens: Tokens Tab", () => {
const nameField = tokensUpdateCreateModal.getByLabel("Name"); const nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.pressSequentially(".changed"); await nameField.pressSequentially(".changed");
await tokensUpdateCreateModal.getByRole("button", {name: "Save"}).click(); await tokensUpdateCreateModal.getByRole("button", { name: "Save" }).click();
await expect(tokensUpdateCreateModal).not.toBeVisible(); await expect(tokensUpdateCreateModal).not.toBeVisible();

View File

@@ -76,7 +76,7 @@
(map :page-id)) (map :page-id))
(defn- apply-changes-localy (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/reify ::apply-changes-localy
ptk/UpdateEvent ptk/UpdateEvent
(update [_ state] (update [_ state]
@@ -103,7 +103,7 @@
pids (into #{} xf:map-page-id redo-changes)] pids (into #{} xf:map-page-id redo-changes)]
(reduce #(ctst/update-object-indices %1 %2) fdata pids)))] (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 ;; Update the wasm model
(let [shape-changes (volatile! {}) (let [shape-changes (volatile! {})
@@ -122,7 +122,7 @@
(defn commit (defn commit
"Create a commit event instance" "Create a commit event instance"
[{:keys [commit-id redo-changes undo-changes origin save-undo? features [{: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) (assert (cpc/check-changes redo-changes)
"expect valid vector of changes for redo-changes") "expect valid vector of changes for redo-changes")
@@ -147,7 +147,8 @@
:save-undo? save-undo? :save-undo? save-undo?
:undo-group undo-group :undo-group undo-group
:tags tags :tags tags
:stack-undo? stack-undo?}] :stack-undo? stack-undo?
:ignore-wasm? ignore-wasm?}]
(ptk/reify ::commit (ptk/reify ::commit
cljs.core/IDeref cljs.core/IDeref

View File

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

View File

@@ -32,7 +32,7 @@
[app.main.data.helpers :as dsh] [app.main.data.helpers :as dsh]
[app.main.data.modal :as modal] [app.main.data.modal :as modal]
[app.main.data.notifications :as ntf] [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.plugins :as dp]
[app.main.data.profile :as du] [app.main.data.profile :as du]
[app.main.data.project :as dpj] [app.main.data.project :as dpj]
@@ -67,6 +67,7 @@
[app.main.errors] [app.main.errors]
[app.main.features :as features] [app.main.features :as features]
[app.main.features.pointer-map :as fpmap] [app.main.features.pointer-map :as fpmap]
[app.main.refs :as refs]
[app.main.repo :as rp] [app.main.repo :as rp]
[app.main.router :as rt] [app.main.router :as rt]
[app.render-wasm :as wasm] [app.render-wasm :as wasm]
@@ -269,8 +270,12 @@
(ptk/reify ::process-wasm-object (ptk/reify ::process-wasm-object
ptk/EffectEvent ptk/EffectEvent
(effect [_ state _] (effect [_ state _]
(let [objects (dsh/lookup-page-objects state)] (let [objects (dsh/lookup-page-objects state)
(wasm.api/process-object (get objects id)))))) shape (get objects id)]
;; Only process objects that exist in the current page
;; This prevents errors when processing changes from other pages
(when shape
(wasm.api/process-object shape))))))
(defn initialize-workspace (defn initialize-workspace
[team-id file-id] [team-id file-id]
@@ -379,6 +384,59 @@
(->> (rx/from added) (->> (rx/from added)
(rx/map process-wasm-object))))))) (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 (->> stream
(rx/filter dch/commit?) (rx/filter dch/commit?)
(rx/map deref) (rx/map deref)

View File

@@ -102,7 +102,8 @@
{:origin it {:origin it
:redo-changes changes :redo-changes changes
:undo-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 ;; FIXME: would be nice to not execute this code twice per page in the
;; same working session, maybe some local memoization can improve that ;; same working session, maybe some local memoization can improve that
@@ -119,4 +120,5 @@
{:origin it {:origin it
:redo-changes changes :redo-changes changes
:undo-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)) (propagate-structure-modifiers modif-tree (dsh/lookup-page-objects state))
ids ids
(into [] xf:without-uuid-zero (keys transforms)) (into (set (keys modif-tree)) xf:without-uuid-zero (keys transforms))
update-shape update-shape
(fn [shape] (fn [shape]

View File

@@ -554,7 +554,7 @@
(when (features/active-feature? state "text-editor/v2") (when (features/active-feature? state "text-editor/v2")
(let [instance (:workspace-editor state) (let [instance (:workspace-editor state)
styles (some-> (editor.v2/getCurrentStyle instance) styles (some-> (editor.v2/getCurrentStyle instance)
(styles/get-styles-from-style-declaration) (styles/get-styles-from-style-declaration :removed-mixed true)
((comp update-node-fn migrate-node)) ((comp update-node-fn migrate-node))
(styles/attrs->styles))] (styles/attrs->styles))]
(editor.v2/applyStylesToSelection instance styles))))))) (editor.v2/applyStylesToSelection instance styles)))))))
@@ -831,7 +831,8 @@
(effect [_ state _] (effect [_ state _]
(when (features/active-feature? state "text-editor/v2") (when (features/active-feature? state "text-editor/v2")
(let [instance (:workspace-editor state) (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) overriden-attrs (merge attrs-to-override attrs)
styles (styles/attrs->styles overriden-attrs)] styles (styles/attrs->styles overriden-attrs)]
(editor.v2/applyStylesToSelection instance styles)))))) (editor.v2/applyStylesToSelection instance styles))))))

View File

@@ -26,7 +26,7 @@
(log/set-level! :warn) (log/set-level! :warn)
(def google-fonts (def google-fonts
(preload-gfonts "fonts/gfonts.2025.05.19.json")) (preload-gfonts "fonts/gfonts.2025.11.28.json"))
(def local-fonts (def local-fonts
[{:id "sourcesanspro" [{:id "sourcesanspro"
@@ -342,8 +342,8 @@
(fn [result {:keys [font-id] :as node}] (fn [result {:keys [font-id] :as node}]
(let [current-font (let [current-font
(if (some? font-id) (if (some? font-id)
(select-keys node [: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]))] (select-keys txt/default-typography [:font-id :font-variant-id :font-weight :font-style]))]
(conj result current-font))) (conj result current-font)))
#{}))) #{})))

View File

@@ -30,6 +30,9 @@
(def profile (def profile
(l/derived (l/key :profile) st/state)) (l/derived (l/key :profile) st/state))
(def current-page-id
(l/derived (l/key :current-page-id) st/state))
(def team (def team
(l/derived (fn [state] (l/derived (fn [state]
(let [team-id (:current-team-id state) (let [team-id (:current-team-id state)
@@ -372,6 +375,9 @@
(def workspace-modifiers (def workspace-modifiers
(l/derived :workspace-modifiers st/state)) (l/derived :workspace-modifiers st/state))
(def workspace-wasm-modifiers
(l/derived :workspace-wasm-modifiers st/state))
(def ^:private workspace-modifiers-with-objects (def ^:private workspace-modifiers-with-objects
(l/derived (l/derived
(fn [state] (fn [state]

View File

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

View File

@@ -30,6 +30,7 @@
(def current-zoom (mf/create-context nil)) (def current-zoom (mf/create-context nil))
(def workspace-read-only? (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 is-component? (mf/create-context false))
(def sidebar (def sidebar

View File

@@ -7,6 +7,7 @@
(ns app.main.ui.flex-controls.gap (ns app.main.ui.flex-controls.gap
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.helpers :as cfh] [app.common.files.helpers :as cfh]
[app.common.geom.point :as gpt] [app.common.geom.point :as gpt]
[app.common.geom.shapes :as gsh] [app.common.geom.shapes :as gsh]
@@ -16,6 +17,8 @@
[app.common.types.shape.layout :as ctl] [app.common.types.shape.layout :as ctl]
[app.main.data.helpers :as dsh] [app.main.data.helpers :as dsh]
[app.main.data.workspace.modifiers :as dwm] [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.refs :as refs]
[app.main.store :as st] [app.main.store :as st]
[app.main.ui.css-cursors :as cur] [app.main.ui.css-cursors :as cur]
@@ -27,10 +30,11 @@
(mf/defc gap-display (mf/defc gap-display
[{:keys [frame-id zoom gap-type gap on-pointer-enter on-pointer-leave [{:keys [frame-id zoom gap-type gap on-pointer-enter on-pointer-leave
rect-data hover? selected? mouse-pos hover-value 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) (let [resizing (mf/use-var nil)
start (mf/use-var nil) start (mf/use-var nil)
original-value (mf/use-var 0) original-value (mf/use-var 0)
last-pos (mf/use-var nil)
negate? (:resize-negate? rect-data) negate? (:resize-negate? rect-data)
axis (:resize-axis rect-data) axis (:resize-axis rect-data)
@@ -43,32 +47,55 @@
(reset! start (dom/get-client-position event)) (reset! start (dom/get-client-position event))
(reset! original-value (:initial-value rect-data)))) (reset! original-value (:initial-value rect-data))))
on-lost-pointer-capture calc-modifiers
(mf/use-fn (mf/use-fn
(mf/deps frame-id gap-type gap) (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] (fn [event]
(dom/release-pointer 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! resizing nil)
(reset! start nil) (reset! start nil)
(reset! original-value 0) (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 on-pointer-move
(mf/use-fn (mf/use-fn
(mf/deps frame-id gap-type gap) (mf/deps calc-modifiers on-change)
(fn [event] (fn [event]
(let [pos (dom/get-client-position event)] (let [pos (dom/get-client-position event)]
(reset! last-pos pos)
(reset! mouse-pos (point->viewport pos)) (reset! mouse-pos (point->viewport pos))
(when (= @resizing gap-type) (when (= @resizing gap-type)
(let [delta (-> (gpt/to-vec @start pos) (let [[val modifiers] (calc-modifiers 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))]
(reset! hover-value val) (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 [:g.gap-rect
[:rect.info-area [:rect.info-area
@@ -120,10 +147,17 @@
pill-width (/ fcc/flex-display-pill-width zoom) pill-width (/ fcc/flex-display-pill-width zoom)
pill-height (/ fcc/flex-display-pill-height zoom) pill-height (/ fcc/flex-display-pill-height zoom)
workspace-modifiers (mf/deref refs/workspace-modifiers) workspace-modifiers (mf/deref refs/workspace-modifiers)
workspace-wasm-modifiers (mf/deref refs/workspace-wasm-modifiers)
gap-selected (mf/deref refs/workspace-gap-selected) gap-selected (mf/deref refs/workspace-gap-selected)
hover (mf/use-state nil) hover (mf/use-state nil)
hover-value (mf/use-state 0) hover-value (mf/use-state 0)
mouse-pos (mf/use-state nil) 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) padding (:layout-padding frame)
gap (:layout-gap frame) gap (:layout-gap frame)
{:keys [width height x1 y1]} (:selrect frame) {:keys [width height x1 y1]} (:selrect frame)
@@ -132,6 +166,12 @@
(reset! hover-value val)) (reset! hover-value val))
on-pointer-leave #(reset! hover nil) 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) negate {:column-gap (if flip-x true false)
:row-gap (if flip-y true false)} :row-gap (if flip-y true false)}
@@ -143,8 +183,16 @@
(= :column-reverse saved-dir)) (= :column-reverse saved-dir))
(drop-last children) (drop-last children)
(rest children)) (rest children))
children-to-display (->> children-to-display children-to-display
(map #(gsh/transform-shape % (get-in workspace-modifiers [(:id %) :modifiers])))) (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 wrap-blocks
(let [block-children (->> children (let [block-children (->> children
@@ -272,20 +320,22 @@
[:g.gaps {:pointer-events "visible"} [:g.gaps {:pointer-events "visible"}
(for [[index display-item] (d/enumerate (concat display-blocks display-children))] (for [[index display-item] (d/enumerate (concat display-blocks display-children))]
(let [gap-type (:gap-type display-item)] (let [gap-type (:gap-type display-item)]
[:& gap-display {:key (str frame-id index) [:& gap-display
:frame-id frame-id {:key (str frame-id index)
:zoom zoom :frame-id frame-id
:gap-type gap-type :zoom zoom
:gap gap :gap-type gap-type
:on-pointer-enter (partial on-pointer-enter gap-type (get gap gap-type)) :gap gap
:on-pointer-leave on-pointer-leave :on-pointer-enter (partial on-pointer-enter gap-type (get gap gap-type))
:on-move-selected on-move-selected :on-pointer-leave on-pointer-leave
:on-context-menu on-context-menu :on-move-selected on-move-selected
:rect-data display-item :on-context-menu on-context-menu
:hover? (= @hover gap-type) :on-change on-change
:selected? (= gap-selected gap-type) :rect-data display-item
:mouse-pos mouse-pos :hover? (= @hover gap-type)
:hover-value hover-value}])) :selected? (= gap-selected gap-type)
:mouse-pos mouse-pos
:hover-value hover-value}]))
(when @hover (when @hover
[:& fcc/flex-display-pill [:& fcc/flex-display-pill

View File

@@ -6,9 +6,12 @@
(ns app.main.ui.flex-controls.margin (ns app.main.ui.flex-controls.margin
(:require (:require
[app.common.data.macros :as dm]
[app.common.geom.point :as gpt] [app.common.geom.point :as gpt]
[app.common.types.modifiers :as ctm] [app.common.types.modifiers :as ctm]
[app.main.data.workspace.modifiers :as dwm] [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.refs :as refs]
[app.main.store :as st] [app.main.store :as st]
[app.main.ui.css-cursors :as cur] [app.main.ui.css-cursors :as cur]
@@ -17,11 +20,14 @@
[app.util.dom :as dom] [app.util.dom :as dom]
[rumext.v2 :as mf])) [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 (mf/defc margin-display
rect-data hover? selected? mouse-pos hover-value]}] [{: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) (let [resizing? (mf/use-var false)
start (mf/use-var nil) start (mf/use-var nil)
original-value (mf/use-var 0) original-value (mf/use-var 0)
last-pos (mf/use-var nil)
negate? (true? (:resize-negate? rect-data)) negate? (true? (:resize-negate? rect-data))
axis (:resize-axis rect-data) axis (:resize-axis rect-data)
@@ -34,39 +40,69 @@
(reset! start (dom/get-client-position event)) (reset! start (dom/get-client-position event))
(reset! original-value (:initial-value rect-data)))) (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 on-lost-pointer-capture
(mf/use-fn (mf/use-fn
(mf/deps shape-id margin-num margin) (mf/deps calc-modifiers)
(fn [event] (fn [event]
(dom/release-pointer 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! resizing? false)
(reset! start nil) (reset! start nil)
(reset! original-value 0) (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 on-pointer-move
(mf/use-fn (mf/use-fn
(mf/deps shape-id margin-num margin hover-all? hover-v? hover-h?) (mf/deps calc-modifiers on-change)
(fn [event] (fn [event]
(let [pos (dom/get-client-position event)] (let [pos (dom/get-client-position event)]
(reset! mouse-pos (point->viewport pos)) (reset! mouse-pos (point->viewport pos))
(reset! last-pos pos)
(when @resizing? (when @resizing?
(let [delta (-> (gpt/to-vec @start pos) (let [[val modifiers] (calc-modifiers 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)))]
(reset! hover-value val) (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 [:rect.margin-rect
{:x (:x rect-data) {:x (:x rect-data)
@@ -89,6 +125,11 @@
pill-width (/ fcc/flex-display-pill-width zoom) pill-width (/ fcc/flex-display-pill-width zoom)
pill-height (/ fcc/flex-display-pill-height zoom) pill-height (/ fcc/flex-display-pill-height zoom)
margins-selected (mf/deref refs/workspace-margins-selected) 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) hover-value (mf/use-state 0)
mouse-pos (mf/use-state nil) mouse-pos (mf/use-state nil)
hover (mf/use-state nil) hover (mf/use-state nil)
@@ -97,50 +138,67 @@
hover-h? (and (or (= @hover :m2) (= @hover :m4)) shift?) hover-h? (and (or (= @hover :m2) (= @hover :m4)) shift?)
margin (:layout-item-margin shape) margin (:layout-item-margin shape)
{:keys [width height x1 x2 y1 y2]} (:selrect shape) {:keys [width height x1 x2 y1 y2]} (:selrect shape)
on-pointer-enter (fn [hover-type val]
(reset! hover hover-type) on-pointer-enter
(reset! hover-value val)) (mf/use-fn
on-pointer-leave #(reset! hover nil) (fn [hover-type val]
hover? #(or hover-all? (reset! hover hover-type)
(and (or (= % :m1) (= % :m3)) hover-v?) (reset! hover-value val)))
(and (or (= % :m2) (= % :m4)) hover-h?)
(= @hover %)) on-pointer-leave
margin-display-data {:m1 {:key (str shape-id "-m1") (mf/use-fn
:x x1 (fn []
:y (if (:flip-y frame) y2 (- y1 (:m1 margin))) (reset! hover nil)))
:width width
:height (:m1 margin) on-change
:initial-value (:m1 margin) (mf/use-fn
:resize-type :top (fn [modifiers]
:resize-axis :y (reset! current-modifiers modifiers)))
:resize-negate? (:flip-y frame)}
:m2 {:key (str shape-id "-m2") hover?
:x (if (:flip-x frame) (- x1 (:m2 margin)) x2) (fn [value]
:y y1 (or hover-all?
:width (:m2 margin) (and (or (= value :m1) (= value :m3)) hover-v?)
:height height (and (or (= value :m2) (= value :m4)) hover-h?)
:initial-value (:m2 margin) (= @hover value)))
:resize-type :left
:resize-axis :x margin-display-data
:resize-negate? (:flip-x frame)} {:m1 {:key (str shape-id "-m1")
:m3 {:key (str shape-id "-m3") :x x1
:x x1 :y (if (:flip-y frame) y2 (- y1 (:m1 margin)))
:y (if (:flip-y frame) (- y1 (:m3 margin)) y2) :width width
:width width :height (:m1 margin)
:height (:m3 margin) :initial-value (:m1 margin)
:initial-value (:m3 margin) :resize-type :top
:resize-type :top :resize-axis :y
:resize-axis :y :resize-negate? (:flip-y frame)}
:resize-negate? (:flip-y frame)} :m2 {:key (str shape-id "-m2")
:m4 {:key (str shape-id "-m4") :x (if (:flip-x frame) (- x1 (:m2 margin)) x2)
:x (if (:flip-x frame) x2 (- x1 (:m4 margin))) :y y1
:y y1 :width (:m2 margin)
:width (:m4 margin) :height height
:height height :initial-value (:m2 margin)
:initial-value (:m4 margin) :resize-type :left
:resize-type :left :resize-axis :x
:resize-axis :x :resize-negate? (:flip-x frame)}
: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"} [:g.margins {:pointer-events "visible"}
(for [[margin-num rect-data] margin-display-data] (for [[margin-num rect-data] margin-display-data]
@@ -155,6 +213,7 @@
:margin margin :margin margin
:on-pointer-enter (partial on-pointer-enter margin-num (get margin margin-num)) :on-pointer-enter (partial on-pointer-enter margin-num (get margin margin-num))
:on-pointer-leave on-pointer-leave :on-pointer-leave on-pointer-leave
:on-change on-change
:rect-data rect-data :rect-data rect-data
:hover? (hover? margin-num) :hover? (hover? margin-num)
:selected? (get margins-selected margin-num) :selected? (get margins-selected margin-num)

View File

@@ -6,9 +6,12 @@
(ns app.main.ui.flex-controls.padding (ns app.main.ui.flex-controls.padding
(:require (:require
[app.common.data.macros :as dm]
[app.common.geom.point :as gpt] [app.common.geom.point :as gpt]
[app.common.types.modifiers :as ctm] [app.common.types.modifiers :as ctm]
[app.main.data.workspace.modifiers :as dwm] [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.refs :as refs]
[app.main.store :as st] [app.main.store :as st]
[app.main.ui.css-cursors :as cur] [app.main.ui.css-cursors :as cur]
@@ -18,11 +21,13 @@
[rumext.v2 :as mf])) [rumext.v2 :as mf]))
(mf/defc padding-display (mf/defc padding-display
[{:keys [frame-id zoom hover-all? hover-v? hover-h? padding-num padding on-pointer-enter on-pointer-leave [{:keys [frame-id zoom hover-all? hover-v? hover-h? padding-num padding on-pointer-enter
rect-data hover? selected? mouse-pos hover-value on-move-selected on-context-menu]}] 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) (let [resizing? (mf/use-var false)
start (mf/use-var nil) start (mf/use-var nil)
original-value (mf/use-var 0) original-value (mf/use-var 0)
last-pos (mf/use-var nil)
negate? (true? (:resize-negate? rect-data)) negate? (true? (:resize-negate? rect-data))
axis (:resize-axis rect-data) axis (:resize-axis rect-data)
@@ -35,41 +40,69 @@
(reset! start (dom/get-client-position event)) (reset! start (dom/get-client-position event))
(reset! original-value (:initial-value rect-data)))) (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 on-lost-pointer-capture
(mf/use-fn (mf/use-fn
(mf/deps frame-id padding-num padding) (mf/deps calc-modifiers)
(fn [event] (fn [event]
(dom/release-pointer 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! resizing? false)
(reset! start nil) (reset! start nil)
(reset! original-value 0) (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 on-pointer-move
(mf/use-fn (mf/use-fn
(mf/deps frame-id padding-num padding hover-all? hover-v? hover-h?) (mf/deps calc-modifiers on-change)
(fn [event] (fn [event]
(let [pos (dom/get-client-position event)] (let [pos (dom/get-client-position event)]
(reset! mouse-pos (point->viewport pos)) (reset! mouse-pos (point->viewport pos))
(reset! last-pos pos)
(when @resizing? (when @resizing?
(let [delta (-> (gpt/to-vec @start pos) (let [[val modifiers] (calc-modifiers 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)))]
(reset! hover-value val) (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 [:g.padding-rect
[:rect.info-area [:rect.info-area
@@ -105,77 +138,108 @@
:on-lost-pointer-capture on-lost-pointer-capture :on-lost-pointer-capture on-lost-pointer-capture
:on-pointer-move on-pointer-move :on-pointer-move on-pointer-move
:on-context-menu on-context-menu :on-context-menu on-context-menu
:class (when (or hover? selected?) :class
(if (= (:resize-axis rect-data) :x) (cur/get-dynamic "resize-ew" 0) (cur/get-dynamic "resize-ew" 90))) (when (or hover? selected?)
:style {:fill (if (or hover? selected?) fcc/distance-color "none") (if (= (:resize-axis rect-data) :x)
:opacity (if selected? 0 1)}}])])) (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 (mf/defc padding-rects
[{:keys [frame zoom alt? shift? on-move-selected on-context-menu]}] [{:keys [frame zoom alt? shift? on-move-selected on-context-menu]}]
(let [frame-id (:id frame) (let [frame-id (:id frame)
paddings-selected (mf/deref refs/workspace-paddings-selected) 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) hover-value (mf/use-state 0)
mouse-pos (mf/use-state nil) mouse-pos (mf/use-state nil)
hover (mf/use-state nil) hover (mf/use-state nil)
hover-all? (and (not (nil? @hover)) alt?) hover-all? (and (not (nil? @hover)) alt?)
hover-v? (and (or (= @hover :p1) (= @hover :p3)) shift?) hover-v? (and (or (= @hover :p1) (= @hover :p3)) shift?)
hover-h? (and (or (= @hover :p2) (= @hover :p4)) shift?) hover-h? (and (or (= @hover :p2) (= @hover :p4)) shift?)
padding (:layout-padding frame) padding (:layout-padding frame)
{:keys [width height x1 x2 y1 y2]} (:selrect 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-width (/ fcc/flex-display-pill-width zoom)
pill-height (/ fcc/flex-display-pill-height 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") negate
:x x1 {:p1 (if (:flip-y frame) true false)
:y (if (:flip-y frame) (- y2 (:p1 padding)) y1) :p2 (if (:flip-x frame) true false)
:width width :p3 (if (:flip-y frame) true false)
:height (:p1 padding) :p4 (if (:flip-x frame) true false)}
:initial-value (:p1 padding)
:resize-type (if (:flip-y frame) :bottom :top) negate
:resize-axis :y (cond-> negate
:resize-negate? (:p1 negate)} (not= :auto (:layout-item-h-sizing frame)) (assoc :p2 (not (:p2 negate)))
:p2 {:key (str frame-id "-p2") (not= :auto (:layout-item-v-sizing frame)) (assoc :p3 (not (:p3 negate))))
:x (if (:flip-x frame) x1 (- x2 (:p2 padding)))
:y y1 padding-rect-data
:width (:p2 padding) {:p1 {:key (str frame-id "-p1")
:height height :x x1
:initial-value (:p2 padding) :y (if (:flip-y frame) (- y2 (:p1 padding)) y1)
:resize-type :left :width width
:resize-axis :x :height (:p1 padding)
:resize-negate? (:p2 negate)} :initial-value (:p1 padding)
:p3 {:key (str frame-id "-p3") :resize-type (if (:flip-y frame) :bottom :top)
:x x1 :resize-axis :y
:y (if (:flip-y frame) y1 (- y2 (:p3 padding))) :resize-negate? (:p1 negate)}
:width width :p2 {:key (str frame-id "-p2")
:height (:p3 padding) :x (if (:flip-x frame) x1 (- x2 (:p2 padding)))
:initial-value (:p3 padding) :y y1
:resize-type :bottom :width (:p2 padding)
:resize-axis :y :height height
:resize-negate? (:p3 negate)} :initial-value (:p2 padding)
:p4 {:key (str frame-id "-p4") :resize-type :left
:x (if (:flip-x frame) (- x2 (:p4 padding)) x1) :resize-axis :x
:y y1 :resize-negate? (:p2 negate)}
:width (:p4 padding) :p3 {:key (str frame-id "-p3")
:height height :x x1
:initial-value (:p4 padding) :y (if (:flip-y frame) y1 (- y2 (:p3 padding)))
:resize-type (if (:flip-x frame) :right :left) :width width
:resize-axis :x :height (:p3 padding)
:resize-negate? (:p4 negate)}}] :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"} [:g.paddings {:pointer-events "visible"}
(for [[padding-num rect-data] padding-rect-data] (for [[padding-num rect-data] padding-rect-data]
@@ -194,9 +258,11 @@
:on-pointer-leave on-pointer-leave :on-pointer-leave on-pointer-leave
:on-move-selected on-move-selected :on-move-selected on-move-selected
:on-context-menu on-context-menu :on-context-menu on-context-menu
:on-change on-change
:hover? (hover? padding-num) :hover? (hover? padding-num)
:selected? (get paddings-selected padding-num) :selected? (get paddings-selected padding-num)
:rect-data rect-data}]) :rect-data rect-data}])
(when @hover (when @hover
[:& fcc/flex-display-pill [:& fcc/flex-display-pill
{:height pill-height {:height pill-height

View File

@@ -28,6 +28,7 @@
{::mf/wrap-props false} {::mf/wrap-props false}
[props] [props]
(let [{:keys [position-data content] :as shape} (obj/get props "shape") (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?)] is-component? (mf/use-ctx ctx/is-component?)]
(mf/with-memo [content] (mf/with-memo [content]
@@ -41,5 +42,5 @@
;; Only use this for component preview, otherwise the dashboard thumbnails ;; Only use this for component preview, otherwise the dashboard thumbnails
;; will give a tainted canvas error because the `foreignObject` cannot be ;; will give a tainted canvas error because the `foreignObject` cannot be
;; rendered. ;; rendered.
(and (nil? position-data) is-component?) (and (nil? position-data) (or is-component? is-render?))
[:> fo/text-shape props]))) [:> fo/text-shape props])))

View File

@@ -12,18 +12,20 @@
[app.main.features :as features] [app.main.features :as features]
[app.main.refs :as refs] [app.main.refs :as refs]
[app.main.store :as st] [app.main.store :as st]
[app.render-wasm.api :as wasm.api]
[rumext.v2 :as mf])) [rumext.v2 :as mf]))
(mf/defc text-edition-outline (mf/defc text-edition-outline
[{:keys [shape zoom modifiers]}] [{:keys [shape zoom modifiers]}]
(if (features/active-feature? @st/state "render-wasm/v1") (if (features/active-feature? @st/state "render-wasm/v1")
(let [selrect-transform (mf/deref refs/workspace-selrect) (let [{:keys [width height]} (wasm.api/get-text-dimensions (:id shape))
[{:keys [x y width height]} transform] (dsh/get-selrect selrect-transform shape)] selrect-transform (mf/deref refs/workspace-selrect)
[selrect transform] (dsh/get-selrect selrect-transform shape)]
[:rect.main.viewport-selrect [:rect.main.viewport-selrect
{:x x {:x (:x selrect)
:y y :y (:y selrect)
:width width :width (max width (:width selrect))
:height height :height (max height (:height selrect))
:transform transform :transform transform
:style {:stroke "var(--color-accent-tertiary)" :style {:stroke "var(--color-accent-tertiary)"
:stroke-width (/ 1 zoom) :stroke-width (/ 1 zoom)

View File

@@ -320,10 +320,12 @@
[{:keys [x y width height]} transform] [{:keys [x y width height]} transform]
(if render-wasm? (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 (mf/deref refs/workspace-selrect)
[selrect transform] (dsh/get-selrect selrect-transform shape) [selrect transform] (dsh/get-selrect selrect-transform shape)
selrect-height (:height selrect) selrect-height (:height selrect)
selrect-width (:width selrect)
max-width (max width selrect-width)
max-height (max height selrect-height) max-height (max height selrect-height)
valign (-> shape :content :vertical-align) valign (-> shape :content :vertical-align)
y (:y selrect) y (:y selrect)
@@ -331,9 +333,9 @@
(case valign (case valign
"bottom" (- y (- height selrect-height)) "bottom" (- y (- height selrect-height))
"center" (- y (/ (- height selrect-height) 2)) "center" (- y (/ (- height selrect-height) 2))
"top" y) 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) (let [bounds (gst/shape->rect shape)
x (mth/min (dm/get-prop bounds :x) x (mth/min (dm/get-prop bounds :x)
@@ -352,7 +354,7 @@
(obj/merge! (obj/merge!
#js {"--editor-container-width" (dm/str width "px") #js {"--editor-container-width" (dm/str width "px")
"--editor-container-height" (dm/str height "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?) (not render-wasm?)
(obj/merge! (obj/merge!

View File

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

View File

@@ -264,12 +264,16 @@
(mf/deps font on-change) (mf/deps font on-change)
(fn [new-variant-id] (fn [new-variant-id]
(let [variant (d/seek #(= new-variant-id (:id %)) (:variants font))] (let [variant (d/seek #(= new-variant-id (:id %)) (:variants font))]
(on-change {:font-id (:id font) (when-not (nil? variant)
:font-family (:family font) (on-change {:font-id (:id font)
:font-variant-id new-variant-id :font-family (:family font)
:font-weight (:weight variant) :font-variant-id new-variant-id
:font-style (:style variant)}) :font-weight (:weight variant)
(dom/blur! (dom/get-target new-variant-id))))) :font-style (:style variant)}))
;; NOTE: the select component we are using does not fire on-blur event
;; so we need to call on-blur manually
(when (some? on-blur)
(on-blur)))))
on-font-select on-font-select
(mf/use-fn (mf/use-fn
@@ -303,7 +307,7 @@
:title (tr "inspect.attributes.typography.font-family") :title (tr "inspect.attributes.typography.font-family")
:on-click #(reset! open-selector? true)} :on-click #(reset! open-selector? true)}
(cond (cond
(= :multiple font-id) (or (= :multiple font-id) (= "mixed" font-id))
"--" "--"
(some? font) (some? font)
@@ -341,12 +345,13 @@
{:value (:id variant) {:value (:id variant)
:key (pr-str variant) :key (pr-str variant)
:label (:name variant)}))) :label (:name variant)})))
variant-options (if (= font-size :multiple) variant-options (if (= font-variant-id :multiple)
(conj basic-variant-options (conj basic-variant-options
{:value :multiple {:value ""
:key :multiple-variants :key :multiple-variants
:label "--"}) :label "--"})
basic-variant-options)] basic-variant-options)]
;; TODO Add disabled mode ;; TODO Add disabled mode
[:& select [:& select
{:class (stl/css :font-variant-select) {:class (stl/css :font-variant-select)
@@ -378,6 +383,7 @@
:step 0.1 :step 0.1
:default-value "1.2" :default-value "1.2"
:class (stl/css :line-height-input) :class (stl/css :line-height-input)
:aria-label (tr "inspect.attributes.typography.line-height")
:value (attr->string line-height) :value (attr->string line-height)
:placeholder (if (= :multiple line-height) (tr "settings.multiple") "--") :placeholder (if (= :multiple line-height) (tr "settings.multiple") "--")
:nillable (= :multiple line-height) :nillable (= :multiple line-height)
@@ -396,6 +402,7 @@
:step 0.1 :step 0.1
:default-value "0" :default-value "0"
:class (stl/css :letter-spacing-input) :class (stl/css :letter-spacing-input)
:aria-label (tr "inspect.attributes.typography.letter-spacing")
:value (attr->string letter-spacing) :value (attr->string letter-spacing)
:placeholder (if (= :multiple letter-spacing) (tr "settings.multiple") "--") :placeholder (if (= :multiple letter-spacing) (tr "settings.multiple") "--")
:on-change #(handle-change % :letter-spacing) :on-change #(handle-change % :letter-spacing)

View File

@@ -19,5 +19,5 @@
} }
.threads { .threads {
position: fixed; position: absolute;
} }

View File

@@ -16,6 +16,7 @@
[app.common.geom.shapes.points :as gpo] [app.common.geom.shapes.points :as gpo]
[app.common.types.shape.layout :as ctl] [app.common.types.shape.layout :as ctl]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.render-wasm.api :as wasm.api]
[cuerdas.core :as str] [cuerdas.core :as str]
[rumext.v2 :as mf])) [rumext.v2 :as mf]))
@@ -275,3 +276,26 @@
:y2 (:y end-p) :y2 (:y end-p)
:style {:stroke "red" :style {:stroke "red"
:stroke-width (/ 1 zoom)}}]))])))) :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.grid-layout.editor :as dwge]
[app.main.data.workspace.modifiers :as dwm] [app.main.data.workspace.modifiers :as dwm]
[app.main.data.workspace.shape-layout :as dwsl] [app.main.data.workspace.shape-layout :as dwsl]
[app.main.data.workspace.transforms :as dwt]
[app.main.features :as features] [app.main.features :as features]
[app.main.refs :as refs] [app.main.refs :as refs]
[app.main.store :as st] [app.main.store :as st]
@@ -257,7 +258,8 @@
(let [modifiers (calculate-drag-modifiers position) (let [modifiers (calculate-drag-modifiers position)
modif-tree (dwm/create-modif-tree [(:id shape)] modifiers)] modif-tree (dwm/create-modif-tree [(:id shape)] modifiers)]
(when on-clear-modifiers (on-clear-modifiers 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))))) (st/emit! (dwm/apply-modifiers)))))
{:keys [handle-pointer-down handle-lost-pointer-capture handle-pointer-move]} {:keys [handle-pointer-down handle-lost-pointer-capture handle-pointer-move]}
@@ -506,7 +508,8 @@
(let [modifiers (calculate-modifiers position) (let [modifiers (calculate-modifiers position)
modif-tree (dwm/create-modif-tree [(:id shape)] modifiers)] modif-tree (dwm/create-modif-tree [(:id shape)] modifiers)]
(when on-clear-modifiers (on-clear-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))) (st/emit! (dwm/apply-modifiers)))
(reset! start-size-before nil) (reset! start-size-before nil)
(reset! start-size-after nil)))] (reset! start-size-after nil)))]

View File

@@ -12,10 +12,13 @@
[app.common.files.helpers :as cfh] [app.common.files.helpers :as cfh]
[app.common.geom.shapes :as gsh] [app.common.geom.shapes :as gsh]
[app.common.types.color :as clr] [app.common.types.color :as clr]
[app.common.types.component :as ctk]
[app.common.types.path :as path] [app.common.types.path :as path]
[app.common.types.shape :as cts] [app.common.types.shape :as cts]
[app.common.types.shape.layout :as ctl] [app.common.types.shape.layout :as ctl]
[app.main.data.common :as dcm]
[app.main.data.workspace.transforms :as dwt] [app.main.data.workspace.transforms :as dwt]
[app.main.data.workspace.variants :as dwv]
[app.main.features :as features] [app.main.features :as features]
[app.main.refs :as refs] [app.main.refs :as refs]
[app.main.store :as st] [app.main.store :as st]
@@ -54,15 +57,11 @@
[app.util.debug :as dbg] [app.util.debug :as dbg]
[app.util.text-editor :as ted] [app.util.text-editor :as ted]
[beicon.v2.core :as rx] [beicon.v2.core :as rx]
[okulary.core :as l]
[promesa.core :as p] [promesa.core :as p]
[rumext.v2 :as mf])) [rumext.v2 :as mf]))
;; --- Viewport ;; --- Viewport
(def workspace-wasm-modifiers
(l/derived :workspace-wasm-modifiers st/state))
(defn apply-modifiers-to-selected (defn apply-modifiers-to-selected
[selected objects modifiers] [selected objects modifiers]
(->> modifiers (->> modifiers
@@ -98,7 +97,7 @@
;; DEREFS ;; DEREFS
drawing (mf/deref refs/workspace-drawing) drawing (mf/deref refs/workspace-drawing)
focus (mf/deref refs/workspace-focus-selected) 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) workspace-editor-state (mf/deref refs/workspace-editor-state)
@@ -261,6 +260,16 @@
first-shape (first selected-shapes) 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? show-padding?
(and (nil? transform) (and (nil? transform)
single-select? single-select?
@@ -297,9 +306,15 @@
(->> wasm.api/module (->> wasm.api/module
(p/fmap (fn [ready?] (p/fmap (fn [ready?]
(when ready? (when ready?
(let [init? (wasm.api/init-canvas-context canvas)] (let [init? (try
(wasm.api/init-canvas-context canvas)
(catch :default e
(js/console.error "Error initializing canvas context:" e)
false))]
(reset! canvas-init? init?) (reset! canvas-init? init?)
(when-not init? (js/alert "WebGL not supported"))))))) (when-not init?
(js/alert "WebGL not supported")
(st/emit! (dcm/go-to-dashboard-recent))))))))
(fn [] (fn []
(wasm.api/clear-canvas)))) (wasm.api/clear-canvas))))
@@ -639,6 +654,12 @@
:hover-top-frame-id @hover-top-frame-id :hover-top-frame-id @hover-top-frame-id
:zoom zoom}]) :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? (when show-selection-handlers?
[:g.selection-handlers {:clipPath "url(#clip-handlers)"} [:g.selection-handlers {:clipPath "url(#clip-handlers)"}
(when-not text-editing? (when-not text-editing?
@@ -667,6 +688,11 @@
{:id (first selected) {:id (first selected)
:zoom zoom}]) :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)"} [:g.grid-layout-editor {:clipPath "url(#clip-handlers)"}
(when show-grid-editor? (when show-grid-editor?
[:& grid-layout/editor [:& grid-layout/editor

View File

@@ -18,6 +18,7 @@
[app.main.render :as render] [app.main.render :as render]
[app.main.repo :as repo] [app.main.repo :as repo]
[app.main.store :as st] [app.main.store :as st]
[app.main.ui.context :as ctx]
[app.util.dom :as dom] [app.util.dom :as dom]
[app.util.globals :as glob] [app.util.globals :as glob]
[beicon.v2.core :as rx] [beicon.v2.core :as rx]
@@ -76,11 +77,12 @@
(mth/ceil height) "px")})))) (mth/ceil height) "px")}))))
(when objects (when objects
[:& render/object-svg [:& (mf/provider ctx/is-render?) {:value true}
{:objects objects [:& render/object-svg
:object-id object-id {:objects objects
:embed embed :object-id object-id
:skip-children skip-children}]))) :embed embed
:skip-children skip-children}]])))
(mf/defc objects-svg (mf/defc objects-svg
{::mf/wrap-props false} {::mf/wrap-props false}
@@ -88,12 +90,13 @@
(when-let [objects (mf/deref ref:objects)] (when-let [objects (mf/deref ref:objects)]
(for [object-id object-ids] (for [object-id object-ids]
(let [objects (render/adapt-objects-for-shape objects object-id)] (let [objects (render/adapt-objects-for-shape objects object-id)]
[:& render/object-svg [:& (mf/provider ctx/is-render?) {:value true}
{:objects objects [:& render/object-svg
:key (str object-id) {:objects objects
:object-id object-id :key (str object-id)
:embed embed :object-id object-id
:skip-children skip-children}])))) :embed embed
:skip-children skip-children}]]))))
(defn- fetch-objects-bundle (defn- fetch-objects-bundle
[& {:keys [file-id page-id share-id object-id] :as options}] [& {:keys [file-id page-id share-id object-id] :as options}]

View File

@@ -18,12 +18,14 @@
[app.common.types.path :as path] [app.common.types.path :as path]
[app.common.types.path.impl :as path.impl] [app.common.types.path.impl :as path.impl]
[app.common.types.shape.layout :as ctl] [app.common.types.shape.layout :as ctl]
[app.common.types.text :as txt]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.config :as cf] [app.config :as cf]
[app.main.fonts :as fonts] [app.main.data.render-wasm :as drw]
[app.main.refs :as refs] [app.main.refs :as refs]
[app.main.render :as render] [app.main.render :as render]
[app.main.store :as st] [app.main.store :as st]
[app.main.ui.shapes.text]
[app.main.worker :as mw] [app.main.worker :as mw]
[app.render-wasm.api.fonts :as f] [app.render-wasm.api.fonts :as f]
[app.render-wasm.api.texts :as t] [app.render-wasm.api.texts :as t]
@@ -34,7 +36,7 @@
[app.render-wasm.performance :as perf] [app.render-wasm.performance :as perf]
[app.render-wasm.serializers :as sr] [app.render-wasm.serializers :as sr]
[app.render-wasm.serializers.color :as sr-clr] [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 ;; FIXME: rename; confunsing name
[app.render-wasm.wasm :as wasm] [app.render-wasm.wasm :as wasm]
[app.util.debug :as dbg] [app.util.debug :as dbg]
@@ -42,6 +44,7 @@
[app.util.globals :as ug] [app.util.globals :as ug]
[app.util.text.content :as tc] [app.util.text.content :as tc]
[beicon.v2.core :as rx] [beicon.v2.core :as rx]
[cuerdas.core :as str]
[promesa.core :as p] [promesa.core :as p]
[rumext.v2 :as mf])) [rumext.v2 :as mf]))
@@ -60,6 +63,9 @@
(def ^:const MAX_BUFFER_CHUNK_SIZE (* 256 1024)) (def ^:const MAX_BUFFER_CHUNK_SIZE (* 256 1024))
(def ^:const DEBOUNCE_DELAY_MS 100)
(def ^:const THROTTLE_DELAY_MS 10)
(def dpr (def dpr
(if use-dpr? (if (exists? js/window) js/window.devicePixelRatio 1.0) 1.0)) (if use-dpr? (if (exists? js/window) js/window.devicePixelRatio 1.0) 1.0))
@@ -90,20 +96,20 @@
;; This should never be called from the outside. ;; This should never be called from the outside.
(defn- render (defn- render
[timestamp] [timestamp]
(when wasm/context-initialized? (when (and wasm/context-initialized? (not @wasm/context-lost?))
(h/call wasm/internal-module "_render" timestamp) (h/call wasm/internal-module "_render" timestamp)
(set! wasm/internal-frame-id nil) (set! wasm/internal-frame-id nil)
(ug/dispatch! (ug/event "penpot:wasm:render")))) (ug/dispatch! (ug/event "penpot:wasm:render"))))
(defn render-sync (defn render-sync
[] []
(when wasm/context-initialized? (when (and wasm/context-initialized? (not @wasm/context-lost?))
(h/call wasm/internal-module "_render_sync") (h/call wasm/internal-module "_render_sync")
(set! wasm/internal-frame-id nil))) (set! wasm/internal-frame-id nil)))
(defn render-sync-shape (defn render-sync-shape
[id] [id]
(when wasm/context-initialized? (when (and wasm/context-initialized? (not @wasm/context-lost?))
(let [buffer (uuid/get-u32 id)] (let [buffer (uuid/get-u32 id)]
(h/call wasm/internal-module "_render_sync_shape" (h/call wasm/internal-module "_render_sync_shape"
(aget buffer 0) (aget buffer 0)
@@ -117,7 +123,7 @@
(defn request-render (defn request-render
[_requester] [_requester]
(when (not @pending-render) (when (and wasm/context-initialized? (not @pending-render) (not @wasm/context-lost?))
(reset! pending-render true) (reset! pending-render true)
(js/requestAnimationFrame (js/requestAnimationFrame
(fn [ts] (fn [ts]
@@ -700,7 +706,7 @@
(set-grid-layout-columns (get shape :layout-grid-columns)) (set-grid-layout-columns (get shape :layout-grid-columns))
(set-grid-layout-cells (get shape :layout-grid-cells))) (set-grid-layout-cells (get shape :layout-grid-cells)))
(defn set-layout-child (defn set-layout-data
[shape] [shape]
(let [margins (get shape :layout-item-margin) (let [margins (get shape :layout-item-margin)
margin-top (get margins :m1 0) margin-top (get margins :m1 0)
@@ -723,7 +729,7 @@
is-absolute (boolean (get shape :layout-item-absolute)) is-absolute (boolean (get shape :layout-item-absolute))
z-index (get shape :layout-item-z-index)] z-index (get shape :layout-item-z-index)]
(h/call wasm/internal-module (h/call wasm/internal-module
"_set_layout_child_data" "_set_layout_data"
margin-top margin-top
margin-right margin-right
margin-bottom margin-bottom
@@ -743,6 +749,11 @@
is-absolute is-absolute
(d/nilv z-index 0)))) (d/nilv z-index 0))))
(defn has-any-layout-prop? [shape]
(some #(and (keyword? %)
(str/starts-with? (name %) "layout-"))
(keys shape)))
(defn clear-layout (defn clear-layout
[] []
(h/call wasm/internal-module "_clear_shape_layout")) (h/call wasm/internal-module "_clear_shape_layout"))
@@ -750,10 +761,10 @@
(defn- set-shape-layout (defn- set-shape-layout
[shape objects] [shape objects]
(clear-layout) (clear-layout)
(when (or (ctl/any-layout? shape) (when (or (ctl/any-layout? shape)
(ctl/any-layout-immediate-child? objects shape)) (ctl/any-layout-immediate-child? objects shape)
(set-layout-child shape)) (has-any-layout-prop? shape))
(set-layout-data shape))
(when (ctl/flex-layout? shape) (when (ctl/flex-layout? shape)
(set-flex-layout shape)) (set-flex-layout shape))
@@ -828,7 +839,7 @@
(set-shape-vertical-align (get content :vertical-align)) (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) fallback-fonts (fonts-from-text-content content true)
all-fonts (concat fonts fallback-fonts) all-fonts (concat fonts fallback-fonts)
result (f/store-fonts shape-id all-fonts)] result (f/store-fonts shape-id all-fonts)]
@@ -872,27 +883,43 @@
(def render-finish (def render-finish
(letfn [(do-render [ts] (letfn [(do-render [ts]
(perf/begin-measure "render-finish")
(h/call wasm/internal-module "_set_view_end") (h/call wasm/internal-module "_set_view_end")
(render ts))] (render ts)
(fns/debounce do-render 100))) (perf/end-measure "render-finish"))]
(fns/debounce do-render DEBOUNCE_DELAY_MS)))
(def render-pan (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 (defn set-view-box
[prev-zoom zoom vbox] [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) (if is-pan
(do (render-pan) (do (perf/end-measure "set-view-box")
(render-finish)) (perf/begin-measure "set-view-box::pan")
(do (h/call wasm/internal-module "_render_from_cache" 0) (render-pan)
(render-finish)))) (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 (defn set-object
[objects shape] [objects shape]
(perf/begin-measure "set-object") (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) type (dm/get-prop shape :type)
parent-id (get shape :parent-id) parent-id (get shape :parent-id)
@@ -906,14 +933,7 @@
rotation (get shape :rotation) rotation (get shape :rotation)
transform (get shape :transform) transform (get shape :transform)
;; If the shape comes from an imported SVG (we know this because fills (get shape :fills)
;; 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)
strokes (if (= type :group) strokes (if (= type :group)
[] (get shape :strokes)) [] (get shape :strokes))
children (get shape :shapes) children (get shape :shapes)
@@ -945,8 +965,8 @@
(set-shape-children children) (set-shape-children children)
(set-shape-corners corners) (set-shape-corners corners)
(set-shape-blur blur) (set-shape-blur blur)
(when (and (= type :group) masked) (when (= type :group)
(set-masked masked)) (set-masked (boolean masked)))
(when (= type :bool) (when (= type :bool)
(set-shape-bool-type bool-type)) (set-shape-bool-type bool-type))
(when (and (some? content) (when (and (some? content)
@@ -957,12 +977,11 @@
(set-shape-svg-attrs svg-attrs)) (set-shape-svg-attrs svg-attrs))
(when (and (some? content) (= type :svg-raw)) (when (and (some? content) (= type :svg-raw))
(set-shape-svg-raw-content (get-static-markup shape))) (set-shape-svg-raw-content (get-static-markup shape)))
(when (some? shadows) (set-shape-shadows shadows)) (set-shape-shadows shadows)
(when (= type :text) (when (= type :text)
(set-shape-grow-type grow-type)) (set-shape-grow-type grow-type))
(set-shape-layout shape objects) (set-shape-layout shape objects)
(set-shape-selrect selrect) (set-shape-selrect selrect)
(let [pending_thumbnails (into [] (concat (let [pending_thumbnails (into [] (concat
@@ -986,10 +1005,7 @@
(run! (run!
(fn [id] (fn [id]
(f/update-text-layout id) (f/update-text-layout id)
(mw/emit! {:cmd :index/update-text-rect (update-text-rect! id)))))
:page-id (:current-page-id @st/state)
:shape-id id
:dimensions (get-text-dimensions id)})))))
(defn process-pending (defn process-pending
([shapes thumbnails full on-complete] ([shapes thumbnails full on-complete]
@@ -1043,6 +1059,7 @@
(process-pending shapes thumbnails full noop-fn (process-pending shapes thumbnails full noop-fn
(fn [] (fn []
(when render-callback (render-callback)) (when render-callback (render-callback))
(render-finish)
(ug/dispatch! (ug/event "penpot:wasm:set-objects"))))))) (ug/dispatch! (ug/event "penpot:wasm:set-objects")))))))
(defn clear-focus-mode (defn clear-focus-mode
@@ -1229,26 +1246,65 @@
(when-not (nil? context) (when-not (nil? context)
(let [handle (.registerContext ^js gl context #js {"majorVersion" 2})] (let [handle (.registerContext ^js gl context #js {"majorVersion" 2})]
(.makeContextCurrent ^js gl handle) (.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 ;; Force the WEBGL_debug_renderer_info extension as emscripten does not enable it
(.getExtension context "WEBGL_debug_renderer_info") (.getExtension context "WEBGL_debug_renderer_info")
;; Initialize Wasm Render Engine ;; Initialize Wasm Render Engine
(h/call wasm/internal-module "_init" (/ (.-width ^js canvas) dpr) (/ (.-height ^js canvas) dpr)) (h/call wasm/internal-module "_init" (/ (.-width ^js canvas) dpr) (/ (.-height ^js canvas) dpr))
(h/call wasm/internal-module "_set_render_options" flags dpr)) (h/call wasm/internal-module "_set_render_options" flags dpr)
(set! wasm/context-initialized? true))
(h/call wasm/internal-module "_set_browser" browser) ;; Set browser and canvas size only after initialization
(h/call wasm/internal-module "_set_browser" browser)
(set-canvas-size canvas)
;; Add event listeners for WebGL context lost
(let [handler (fn [event]
(.preventDefault event)
(reset! wasm/context-lost? true)
(log/warn :hint "WebGL context lost")
(st/emit! (drw/context-lost)))]
(set! wasm/context-lost-handler handler)
(set! wasm/context-lost-canvas canvas)
(.addEventListener canvas "webglcontextlost" handler))
(set! wasm/context-initialized? true)))
(h/call wasm/internal-module "_set_render_options" flags dpr)
(set-canvas-size canvas)
context-init?)) context-init?))
(defn clear-canvas (defn clear-canvas
[] []
;; TODO: perform corresponding cleaning (when wasm/context-initialized?
(set! wasm/context-initialized? false) (try
(h/call wasm/internal-module "_clean_up")) ;; TODO: perform corresponding cleaning
(set! wasm/context-initialized? false)
(h/call wasm/internal-module "_clean_up")
;; Remove event listener for WebGL context lost
(when (and wasm/context-lost-handler wasm/context-lost-canvas)
(.removeEventListener wasm/context-lost-canvas "webglcontextlost" wasm/context-lost-handler)
(set! wasm/context-lost-canvas nil)
(set! wasm/context-lost-handler nil))
;; 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 (defn show-grid
[id] [id]
@@ -1291,12 +1347,7 @@
(mem/free) (mem/free)
content)) content))
(defn- calculate-bool* (defn calculate-bool*
[bool-type]
(-> (h/call wasm/internal-module "_calculate_bool" (sr/translate-bool-type bool-type))
(mem/->offset-32)))
(defn calculate-bool
[bool-type ids] [bool-type ids]
(let [size (mem/get-alloc-size ids UUID-U8-SIZE) (let [size (mem/get-alloc-size ids UUID-U8-SIZE)
heap (mem/get-heap-u32) heap (mem/get-heap-u32)
@@ -1307,7 +1358,10 @@
offset offset
(rseq ids)) (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) length (aget heap offset)
data (mem/slice heap data (mem/slice heap
(+ offset 1) (+ offset 1)
@@ -1316,6 +1370,86 @@
(mem/free) (mem/free)
content))) 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)]
(d/patch-object
txt/default-text-attrs
(d/without-nils
{: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 (defn init-wasm-module
[module] [module]
(let [default-fn (unchecked-get module "default") (let [default-fn (unchecked-get module "default")

View File

@@ -9,6 +9,7 @@
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.logging :as log] [app.common.logging :as log]
[app.common.types.text :as txt]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.config :as cf] [app.config :as cf]
[app.main.fonts :as fonts] [app.main.fonts :as fonts]
@@ -49,10 +50,13 @@
:builtin)) :builtin))
(defn- font-db-data (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) (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 (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] (defn- font-id->uuid [font-id]
(case (font-backend font-id) (case (font-backend font-id)
@@ -63,22 +67,22 @@
:builtin :builtin
uuid/zero)) 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) (case (font-backend font-id)
:google :google
font-id font-id
:custom :custom
(let [font-uuid (custom-font-id->uuid font-id) (let [font-uuid (custom-font-id->uuid font-id)
matching-font (d/seek (fn [[_ font]] matching-font (some (fn [[_ font]]
(let [variant-id (or (:font-variant-id font) (dm/str (:font-style font) "-" (:font-weight font)))] (and (= (:font-id font) font-uuid)
(and (= (:font-id font) font-uuid) (= (str (:font-weight font)) (str font-weight))
(or (nil? font-variant-id) font))
(= variant-id font-variant-id))))) (seq @fonts))]
(seq @fonts))]
(when matching-font (when matching-font
(:ttf-file-id (second matching-font)))) (:ttf-file-id matching-font)))
:builtin :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)))) (:ttf-url variant))))
(defn update-text-layout (defn update-text-layout
@@ -100,6 +104,7 @@
ptr (h/call wasm/internal-module "_alloc_bytes" size) ptr (h/call wasm/internal-module "_alloc_bytes" size)
heap (gobj/get ^js wasm/internal-module "HEAPU8") heap (gobj/get ^js wasm/internal-module "HEAPU8")
mem (js/Uint8Array. (.-buffer heap) ptr size)] mem (js/Uint8Array. (.-buffer heap) ptr size)]
(.set mem (js/Uint8Array. font-array-buffer)) (.set mem (js/Uint8Array. font-array-buffer))
(h/call wasm/internal-module "_store_font" (h/call wasm/internal-module "_store_font"
(aget shape-id-buffer 0) (aget shape-id-buffer 0)
@@ -134,17 +139,17 @@
(rx/empty))))}) (rx/empty))))})
(defn- google-font-ttf-url (defn- google-font-ttf-url
[font-id font-variant-id] [font-id font-variant-id font-weight font-style]
(let [variant (font-db-data font-id font-variant-id)] (let [variant (font-db-data font-id font-variant-id font-weight font-style)]
(if-let [ttf-url (:ttf-url variant)] (if-let [ttf-url (:ttf-url variant)]
(str/replace ttf-url "https://fonts.gstatic.com/s/" (u/join cf/public-uri "/internal/gfonts/font/")) (str/replace ttf-url "https://fonts.gstatic.com/s/" (u/join cf/public-uri "/internal/gfonts/font/"))
nil))) nil)))
(defn- font-id->ttf-url (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) (case (font-backend font-id)
:google :google
(google-font-ttf-url font-id font-variant-id) (google-font-ttf-url font-id font-variant-id font-weight font-style)
:custom :custom
(dm/str (u/join cf/public-uri "assets/by-id/" asset-id)) (dm/str (u/join cf/public-uri "assets/by-id/" asset-id))
:builtin :builtin
@@ -153,7 +158,7 @@
(defn- store-font-id (defn- store-font-id
[shape-id font-data asset-id emoji? fallback?] [shape-id font-data asset-id emoji? fallback?]
(when asset-id (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)) id-buffer (uuid/get-u32 (:wasm-id font-data))
font-data (assoc font-data :family-id-buffer id-buffer) font-data (assoc font-data :family-id-buffer id-buffer)
font-stored? (not= 0 (h/call wasm/internal-module "_is_font_uploaded" font-stored? (not= 0 (h/call wasm/internal-module "_is_font_uploaded"
@@ -187,6 +192,30 @@
(catch :default _e (catch :default _e
uuid/zero))) 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 (defn serialize-font-size
[font-size] [font-size]
(cond (cond
@@ -244,26 +273,41 @@
(string? letter-spacing) (string? letter-spacing)
(or (d/parse-double letter-spacing) default-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 (defn store-font
[shape-id font] [shape-id font]
(let [font-id (get font :font-id) (let [font-id (get font :font-id)
font-variant-id (get font :font-variant-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) emoji? (get font :is-emoji false)
fallback? (get font :is-fallback 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) 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) weight (serialize-font-weight raw-weight)
style (serialize-font-style (cond style (cond
(str/includes? font-variant-id "italic") "italic" (str/includes? (or normalized-variant-id "") "italic") "italic"
(str/includes? raw-weight "italic") "italic" (str/includes? raw-weight "italic") "italic"
:else "normal")) :else font-style-fallback)
asset-id (font-id->asset-id font-id font-variant-id) 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-data {:wasm-id wasm-id
:font-id font-id :font-id font-id
:font-variant-id font-variant-id :font-variant-id variant-id
:style style :style (serialize-font-style style)
:style-name style
:weight weight}] :weight weight}]
(store-font-id shape-id font-data asset-id emoji? fallback?))) (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. ;; FIXME: This is a temporary function to load the fallback fonts for the editor.
@@ -273,6 +317,29 @@
(doseq [font fonts] (doseq [font fonts]
(fonts/ensure-loaded! (:font-id font) (:font-variant-id font)))) (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 (defn store-fonts
[shape-id fonts] [shape-id fonts]
(keep (fn [font] (store-font shape-id font)) fonts)) (keep (fn [font] (store-font shape-id font)) fonts))

View File

@@ -80,7 +80,7 @@
font-size (f/serialize-font-size font-size) font-size (f/serialize-font-size font-size)
line-height (f/serialize-line-height (get span :line-height) paragraph-line-height) 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 (get span :font-weight paragraph-font-weight)
font-weight (f/serialize-font-weight font-weight) font-weight (f/serialize-font-weight font-weight)
@@ -142,7 +142,9 @@
;; buffer has the following format: ;; buffer has the following format:
;; [<num-spans> <paragraph_attributes> <spans_attributes> <text>] ;; [<num-spans> <paragraph_attributes> <spans_attributes> <text>]
[spans paragraph 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) fills-size (* types.fills.impl/FILL-U8-SIZE MAX-TEXT-FILLS)
metadata-size (+ PARAGRAPH-ATTR-U8-SIZE metadata-size (+ PARAGRAPH-ATTR-U8-SIZE
(* num-spans (+ SPAN-ATTR-U8-SIZE fills-size))) (* num-spans (+ SPAN-ATTR-U8-SIZE fills-size)))
@@ -157,8 +159,8 @@
(-> offset (-> offset
(mem/write-u32 dview num-spans) (mem/write-u32 dview num-spans)
(write-paragraph dview paragraph) (write-paragraph dview normalized-paragraph)
(write-spans dview spans paragraph) (write-spans dview normalized-spans normalized-paragraph)
(mem/write-buffer heapu8 text-buffer)) (mem/write-buffer heapu8 text-buffer))
(h/call wasm/internal-module "_set_shape_text_content"))) (h/call wasm/internal-module "_set_shape_text_content")))

View File

@@ -45,4 +45,29 @@
:center (gpt/point cx cy) :center (gpt/point cx cy)
:transform (gmt/matrix a b c d e f)})) :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.common.types.shape.layout :as ctl]
[app.main.refs :as refs] [app.main.refs :as refs]
[app.render-wasm.api :as api] [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] [app.render-wasm.wasm :as wasm]
[beicon.v2.core :as rx] [beicon.v2.core :as rx]
[cljs.core :as c] [cljs.core :as c]
@@ -130,7 +130,11 @@
(defn- set-wasm-attr! (defn- set-wasm-attr!
[shape k] [shape k]
(when wasm/context-initialized? (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)] id (get shape :id)]
(case k (case k
:parent-id :parent-id
@@ -163,8 +167,7 @@
(api/set-shape-transform v) (api/set-shape-transform v)
:fills :fills
(let [fills (svg-fills/resolve-shape-fills shape)] (api/set-shape-fills id v false)
(into [] (api/set-shape-fills id fills false)))
:strokes :strokes
(into [] (api/set-shape-strokes id v false)) (into [] (api/set-shape-strokes id v false))
@@ -222,12 +225,16 @@
v]) v])
:svg-attrs :svg-attrs
(when (cfh/path-shape? shape) (do
(api/set-shape-svg-attrs v)) (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 :masked-group
(when (cfh/mask-shape? shape) (when (cfh/group-shape? shape)
(api/set-masked (:masked-group shape))) (api/set-masked (boolean (:masked-group shape))))
:content :content
(cond (cond
@@ -262,7 +269,7 @@
:layout-item-min-w :layout-item-min-w
:layout-item-absolute :layout-item-absolute
:layout-item-z-index) :layout-item-z-index)
(api/set-layout-child shape) (api/set-layout-data shape)
:layout-grid-rows :layout-grid-rows
(api/set-grid-layout-rows v) (api/set-grid-layout-rows v)
@@ -291,7 +298,8 @@
(api/set-grid-layout-data shape) (api/set-grid-layout-data shape)
(ctl/flex-layout? shape) (ctl/flex-layout? shape)
(api/set-flex-layout shape))) (api/set-flex-layout shape))
(api/set-layout-data shape))
;; Property not in WASM ;; Property not in WASM
nil)))) nil))))
@@ -322,7 +330,7 @@
(rx/subs! #(api/request-render "set-wasm-attrs")))) (rx/subs! #(api/request-render "set-wasm-attrs"))))
;; `conj` empty set initialization ;; `conj` empty set initialization
(def conj* (fnil conj #{})) (def conj* (fnil conj (d/ordered-set)))
(defn- impl-assoc (defn- impl-assoc
[self k v] [self k v]

View File

@@ -74,6 +74,30 @@
:width (max 0.01 (or (dm/get-prop shape :width) 1)) :width (max 0.01 (or (dm/get-prop shape :width) 1))
:height (max 0.01 (or (dm/get-prop shape :height) 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 (defn- normalize-point
[pt units shape] [pt units shape]
(if (= units "userspaceonuse") (if (= units "userspaceonuse")
@@ -81,9 +105,16 @@
width (max 0.01 (dm/get-prop rect :width)) width (max 0.01 (dm/get-prop rect :width))
height (max 0.01 (dm/get-prop rect :height)) height (max 0.01 (dm/get-prop rect :height))
origin-x (or (dm/get-prop rect :x) (dm/get-prop rect :x1) 0) 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)] origin-y (or (dm/get-prop rect :y) (dm/get-prop rect :y1) 0)
(gpt/point (/ (- (dm/get-prop pt :x) origin-x) width) svg-transform (:svg-transform shape)
(/ (- (dm/get-prop pt :y) origin-y) height))) 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)) pt))
(defn- normalize-attrs (defn- normalize-attrs
@@ -257,18 +288,25 @@
(parse-gradient-stop node)))) (parse-gradient-stop node))))
vec)] vec)]
(when (seq stops) (when (seq stops)
(let [[center radius-point] (let [[center point-x point-y]
(let [points (apply-gradient-transform [(gpt/point cx cy) (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)] transform)]
(map #(normalize-point % units shape) points)) (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 {:type :radial
:start-x (dm/get-prop center :x) :start-x (dm/get-prop center :x)
:start-y (dm/get-prop center :y) :start-y (dm/get-prop center :y)
:end-x (dm/get-prop radius-point :x) :end-x (dm/get-prop radius-point :x)
:end-y (dm/get-prop radius-point :y) :end-y (dm/get-prop radius-point :y)
:width radius :width width
:stops stops})))) :stops stops}))))
(defn- svg-gradient->fill (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'))))

View File

@@ -9,6 +9,8 @@
(defonce internal-frame-id nil) (defonce internal-frame-id nil)
(defonce internal-module #js {}) (defonce internal-module #js {})
(defonce gl-context-handle nil)
(defonce gl-context nil)
(defonce serializers (defonce serializers
#js {:blur-type shared/RawBlurType #js {:blur-type shared/RawBlurType
:blend-mode shared/RawBlendMode :blend-mode shared/RawBlendMode
@@ -44,3 +46,6 @@
:fill-rule shared/RawFillRule}) :fill-rule shared/RawFillRule})
(defonce context-initialized? false) (defonce context-initialized? false)
(defonce context-lost? (atom false))
(defonce context-lost-handler nil)
(defonce context-lost-canvas nil)

View File

@@ -48,6 +48,9 @@
"This function strips units from attr values and un-scapes font-family" "This function strips units from attr values and un-scapes font-family"
[k v] [k v]
(cond (cond
(= v "mixed")
:multiple
(and (or (= k :font-size) (and (or (= k :font-size)
(= k :letter-spacing)) (= k :letter-spacing))
(= (str/slice v -2) "px")) (= (str/slice v -2) "px"))
@@ -184,19 +187,23 @@
style-value (normalize-style-value style-name v)] style-value (normalize-style-value style-name v)]
(assoc acc style-name style-value)))) {} style-defaults))) (assoc acc style-name style-value)))) {} style-defaults)))
(def mixed-values #{:mixed :multiple "mixed" "multiple"})
(defn get-styles-from-style-declaration (defn get-styles-from-style-declaration
"Returns a ClojureScript object compatible with text nodes" "Returns a ClojureScript object compatible with text nodes"
[style-declaration] [style-declaration & {:keys [removed-mixed] :or {removed-mixed false}}]
(reduce (reduce
(fn [acc k] (fn [acc k]
(if (contains? mapping k) (if (contains? mapping k)
(let [style-name (get-style-name-as-css-variable k) (let [style-name (get-style-name-as-css-variable k)
[_ style-decode] (get mapping k) [_ style-decode] (get mapping k)
style-value (.getPropertyValue style-declaration style-name)] style-value (.getPropertyValue style-declaration style-name)]
(assoc acc k (style-decode style-value))) (when (or (not removed-mixed) (not (contains? mixed-values style-value)))
(assoc acc k (style-decode style-value))))
(let [style-name (get-style-name k) (let [style-name (get-style-name k)
style-value (normalize-attr-value k (.getPropertyValue style-declaration style-name))] style-value (normalize-attr-value k (.getPropertyValue style-declaration style-name))]
(assoc acc k style-value)))) {} txt/text-style-attrs)) (when (or (not removed-mixed) (not (contains? mixed-values style-value)))
(assoc acc k style-value))))) {} txt/text-style-attrs))
(defn get-styles-from-event (defn get-styles-from-event
"Returns a ClojureScript object compatible with text nodes" "Returns a ClojureScript object compatible with text nodes"

View File

@@ -257,7 +257,7 @@
(filter (if clip-children? (filter (if clip-children?
(comp overlaps-parent? :clip-parents) (comp overlaps-parent? :clip-parents)
(constantly true))) (constantly true)))
(map :id)) (keep :id))
result))) result)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@@ -42,6 +42,37 @@
(deftest skips-when-no-svg-fill (deftest skips-when-no-svg-fill
(is (nil? (svg-fills/svg-fill->fills {:svg-attrs {:fill "none"}})))) (is (nil? (svg-fills/svg-fill->fills {:svg-attrs {:fill "none"}}))))
(def elliptical-shape
{:selrect {:x 0 :y 0 :width 200 :height 100}
:svg-attrs {:style {:fill "url(#grad-ellipse)"}}
:svg-defs {"grad-ellipse"
{:tag :radialGradient
:attrs {:id "grad-ellipse"
:gradientUnits "userSpaceOnUse"
:cx "50"
:cy "50"
:r "50"
:gradientTransform "matrix(2 0 0 1 0 0)"}
:content [{:tag :stop
:attrs {:offset "0"
:style "stop-color:#000000;stop-opacity:1"}}
{:tag :stop
:attrs {:offset "1"
:style "stop-color:#ffffff;stop-opacity:1"}}]}}})
(deftest builds-elliptical-radial-gradient-with-transform
(let [fills (svg-fills/svg-fill->fills elliptical-shape)
gradient (get-in (first fills) [:fill-color-gradient])]
(testing "ellipse from gradientTransform is preserved"
(is (= 1 (count fills)))
(is (= :radial (:type gradient)))
(is (= 0.5 (:start-x gradient)))
(is (= 0.5 (:start-y gradient)))
(is (= 0.5 (:end-x gradient)))
(is (= 1.0 (:end-y gradient)))
;; Scaling the X axis in the gradientTransform should reflect on width.
(is (= 1.0 (:width gradient))))))
(deftest resolve-shape-fills-prefers-existing-fills (deftest resolve-shape-fills-prefers-existing-fills
(let [fills [{:fill-color "#ff00ff" :fill-opacity 0.75}] (let [fills [{:fill-color "#ff00ff" :fill-opacity 0.75}]
resolved (svg-fills/resolve-shape-fills {:fills fills})] resolved (svg-fills/resolve-shape-fills {:fills fills})]

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 frontend-tests.svg-filters-test
(:require
[app.render-wasm.svg-filters :as svg-filters]
[cljs.test :refer [deftest is testing]]))
(def sample-filter-shape
{:svg-attrs {:filter "url(#simple-filter)"}
:svg-defs {"simple-filter"
{:tag :filter
:content [{:tag :feOffset :attrs {:dx "2" :dy "3"}}
{:tag :feGaussianBlur :attrs {:stdDeviation "4"}}]}}})
(deftest derives-blur-and-shadow-from-svg-filter
(let [shape (svg-filters/apply-svg-filters sample-filter-shape)
blur (:blur shape)
shadow (:shadow shape)]
(testing "layer blur derived from feGaussianBlur"
(is (= :layer-blur (:type blur)))
(is (= 4.0 (:value blur))))
(testing "drop shadow derived from filter chain"
(is (= [{:style :drop-shadow
:offset-x 2.0
:offset-y 3.0
:blur 8.0
:spread 0
:hidden false
:color {:color "#000000" :opacity 1}}]
(map #(dissoc % :id) shadow))))
(testing "svg attrs remain intact"
(is (= "url(#simple-filter)" (get-in shape [:svg-attrs :filter]))))))
(deftest keeps-existing-native-filters
(let [existing {:blur {:id :existing :type :layer-blur :value 1.0}
:shadow [{:id :shadow :style :drop-shadow}]}
shape (svg-filters/apply-svg-filters (merge sample-filter-shape existing))]
(is (= (:blur existing) (:blur shape)))
(is (= (:shadow existing) (:shadow shape)))))
(deftest skips-when-no-filter-definition
(let [shape {:svg-attrs {:fill "#fff"}}
result (svg-filters/apply-svg-filters shape)]
(is (= shape result))))

View File

@@ -15,7 +15,7 @@
*/ */
export function addEventListeners(target, object, options) { export function addEventListeners(target, object, options) {
Object.entries(object).forEach(([type, listener]) => Object.entries(object).forEach(([type, listener]) =>
target.addEventListener(type, listener, options) target.addEventListener(type, listener, options),
); );
} }
@@ -27,6 +27,6 @@ export function addEventListeners(target, object, options) {
*/ */
export function removeEventListeners(target, object) { export function removeEventListeners(target, object) {
Object.entries(object).forEach(([type, listener]) => Object.entries(object).forEach(([type, listener]) =>
target.removeEventListener(type, listener) target.removeEventListener(type, listener),
); );
} }

View File

@@ -26,6 +26,7 @@ import LayoutType from "./layout/LayoutType.js";
* @typedef {Object} TextEditorOptions * @typedef {Object} TextEditorOptions
* @property {CSSStyleDeclaration|Object.<string,*>} [styleDefaults] * @property {CSSStyleDeclaration|Object.<string,*>} [styleDefaults]
* @property {SelectionControllerDebug} [debug] * @property {SelectionControllerDebug} [debug]
* @property {boolean} [shouldUpdatePositionOnScroll=false]
* @property {boolean} [allowHTMLPaste=false] * @property {boolean} [allowHTMLPaste=false]
*/ */
@@ -92,6 +93,21 @@ export class TextEditor extends EventTarget {
*/ */
#canvas = null; #canvas = null;
/**
* Text editor options.
*
* @type {TextEditorOptions}
*/
#options = {};
/**
* A boolean indicating that this instance was
* disposed or not.
*
* @type {boolean}
*/
#isDisposed = false;
/** /**
* Constructor. * Constructor.
* *
@@ -101,9 +117,9 @@ export class TextEditor extends EventTarget {
*/ */
constructor(element, canvas, options) { constructor(element, canvas, options) {
super(); super();
if (!(element instanceof HTMLElement)) if (!(element instanceof HTMLElement)) {
throw new TypeError("Invalid text editor element"); throw new TypeError("Invalid text editor element");
}
this.#element = element; this.#element = element;
this.#canvas = canvas; this.#canvas = canvas;
this.#events = { this.#events = {
@@ -119,6 +135,7 @@ export class TextEditor extends EventTarget {
keydown: this.#onKeyDown, keydown: this.#onKeyDown,
}; };
this.#styleDefaults = options?.styleDefaults; this.#styleDefaults = options?.styleDefaults;
this.#options = options;
this.#setup(options); this.#setup(options);
} }
@@ -150,14 +167,18 @@ export class TextEditor extends EventTarget {
/** /**
* Setups the root element. * Setups the root element.
*
* @param {TextEditorOptions} options
*/ */
#setupRoot() { #setupRoot(options) {
this.#root = createEmptyRoot(this.#styleDefaults); this.#root = createEmptyRoot(this.#styleDefaults);
this.#element.appendChild(this.#root); this.#element.appendChild(this.#root);
} }
/** /**
* Setups event listeners. * Setups event listeners.
*
* @param {TextEditorOptions} options
*/ */
#setupListeners(options) { #setupListeners(options) {
this.#changeController.addEventListener("change", this.#onChange); this.#changeController.addEventListener("change", this.#onChange);
@@ -174,18 +195,61 @@ export class TextEditor extends EventTarget {
} }
/** /**
* Setups the elements, the properties and the * Disposes everything.
* initial content.
*/ */
#setup(options) { dispose() {
this.#setupElementProperties(options); if (this.#isDisposed) {
this.#setupRoot(options); return this;
}
this.#isDisposed = true;
// Dispose change controller.
this.#changeController.removeEventListener("change", this.#onChange);
this.#changeController.dispose();
this.#changeController = null;
// Disposes selection controller.
this.#selectionController.removeEventListener(
"stylechange",
this.#onStyleChange,
);
this.#selectionController.dispose();
this.#selectionController = null;
// Disposes the rest of event listeners.
removeEventListeners(this.#element, this.#events);
if (this.#options.shouldUpdatePositionOnScroll) {
window.removeEventListener("scroll", this.#onScroll);
}
// Disposes references to DOM elements.
this.#element = null;
this.#root = null;
return this;
}
/**
* Setups controllers.
*
* @param {TextEditorOptions} options
*/
#setupControllers(options) {
this.#changeController = new ChangeController(this); this.#changeController = new ChangeController(this);
this.#selectionController = new SelectionController( this.#selectionController = new SelectionController(
this, this,
document.getSelection(), document.getSelection(),
options, options,
); );
}
/**
* Setups the elements, the properties and the
* initial content.
*/
#setup(options) {
this.#setupElementProperties(options);
this.#setupRoot(options);
this.#setupControllers(options);
this.#setupListeners(options); this.#setupListeners(options);
} }
@@ -242,7 +306,9 @@ export class TextEditor extends EventTarget {
* @param {CustomEvent} e * @param {CustomEvent} e
* @returns {void} * @returns {void}
*/ */
#onChange = (e) => this.dispatchEvent(new e.constructor(e.type, e)); #onChange = (e) => {
this.dispatchEvent(new e.constructor(e.type, e));
};
/** /**
* Dispatchs a `stylechange` event. * Dispatchs a `stylechange` event.
@@ -421,6 +487,15 @@ export class TextEditor extends EventTarget {
); );
} }
/**
* Indicates that the TextEditor was disposed.
*
* @type {boolean}
*/
get isDisposed() {
return this.#isDisposed;
}
/** /**
* Root element that contains all the paragraphs. * Root element that contains all the paragraphs.
* *
@@ -478,6 +553,15 @@ export class TextEditor extends EventTarget {
return this.#selectionController.currentStyle; return this.#selectionController.currentStyle;
} }
/**
* Text editor options
*
* @type {TextEditorOptions}
*/
get options() {
return this.#options;
}
/** /**
* Focus the element * Focus the element
*/ */
@@ -540,7 +624,8 @@ export class TextEditor extends EventTarget {
* Applies the current styles to the selection or * Applies the current styles to the selection or
* the current DOM node at the caret. * the current DOM node at the caret.
* *
* @param {*} styles * @param {Object.<string, *>} styles
* @returns {TextEditor}
*/ */
applyStylesToSelection(styles) { applyStylesToSelection(styles) {
this.#selectionController.startMutation(); this.#selectionController.startMutation();
@@ -553,6 +638,8 @@ export class TextEditor extends EventTarget {
/** /**
* Selects all content. * Selects all content.
*
* @returns {TextEditor}
*/ */
selectAll() { selectAll() {
this.#selectionController.selectAll(); this.#selectionController.selectAll();
@@ -562,30 +649,12 @@ export class TextEditor extends EventTarget {
/** /**
* Moves cursor to end. * Moves cursor to end.
* *
* @returns * @returns {TextEditor}
*/ */
cursorToEnd() { cursorToEnd() {
this.#selectionController.cursorToEnd(); this.#selectionController.cursorToEnd();
return this; return this;
} }
/**
* Disposes everything.
*/
dispose() {
this.#changeController.removeEventListener("change", this.#onChange);
this.#changeController.dispose();
this.#changeController = null;
this.#selectionController.removeEventListener(
"stylechange",
this.#onStyleChange,
);
this.#selectionController.dispose();
this.#selectionController = null;
removeEventListeners(this.#element, this.#events);
this.#element = null;
this.#root = null;
}
} }
/** /**
@@ -595,8 +664,16 @@ export class TextEditor extends EventTarget {
* @param {boolean} allowHTMLPaste * @param {boolean} allowHTMLPaste
* @returns {Root} * @returns {Root}
*/ */
export function createRootFromHTML(html, style = undefined, allowHTMLPaste = undefined) { export function createRootFromHTML(
const fragment = mapContentFragmentFromHTML(html, style || undefined, allowHTMLPaste || undefined); html,
style = undefined,
allowHTMLPaste = undefined,
) {
const fragment = mapContentFragmentFromHTML(
html,
style || undefined,
allowHTMLPaste || undefined,
);
const root = createRoot([], style); const root = createRoot([], style);
root.replaceChildren(fragment); root.replaceChildren(fragment);
resetInertElement(); resetInertElement();
@@ -615,47 +692,98 @@ export function createRootFromString(string) {
return root; return root;
} }
export function isEditor(instance) { /**
* Returns true if the passed object is a TextEditor
* instance.
*
* @param {*} instance
* @returns {boolean}
*/
export function isTextEditor(instance) {
return instance instanceof TextEditor; return instance instanceof TextEditor;
} }
/* Convenience function based API for Text Editor */ /**
* Returns the root element of a TextEditor
* instance.
*
* @param {TextEditor} instance
* @returns {HTMLDivElement}
*/
export function getRoot(instance) { export function getRoot(instance) {
if (isEditor(instance)) { if (isTextEditor(instance)) {
return instance.root; return instance.root;
} else {
return null;
} }
return null;
} }
/**
* Sets the root of the text editor.
*
* @param {TextEditor} instance
* @param {HTMLDivElement} root
* @returns {TextEditor}
*/
export function setRoot(instance, root) { export function setRoot(instance, root) {
if (isEditor(instance)) { if (isTextEditor(instance)) {
instance.root = root; instance.root = root;
} }
return instance; return instance;
} }
/**
* Creates a new TextEditor instance.
*
* @param {HTMLDivElement} element
* @param {HTMLCanvasElement} canvas
* @param {TextEditorOptions} options
* @returns {TextEditor}
*/
export function create(element, canvas, options) { export function create(element, canvas, options) {
return new TextEditor(element, canvas, { ...options }); return new TextEditor(element, canvas, { ...options });
} }
/**
* Returns the current style of the TextEditor instance.
*
* @param {TextEditor} instance
* @returns {CSSStyleDeclaration|undefined}
*/
export function getCurrentStyle(instance) { export function getCurrentStyle(instance) {
if (isEditor(instance)) { if (isTextEditor(instance)) {
return instance.currentStyle; return instance.currentStyle;
} }
return null;
} }
/**
* Applies the specified styles to the TextEditor
* passed.
*
* @param {TextEditor} instance
* @param {Object.<string, *>} styles
* @returns {TextEditor|null}
*/
export function applyStylesToSelection(instance, styles) { export function applyStylesToSelection(instance, styles) {
if (isEditor(instance)) { if (isTextEditor(instance)) {
return instance.applyStylesToSelection(styles); return instance.applyStylesToSelection(styles);
} }
return null;
} }
/**
* Disposes the current instance resources by nullifying
* every property.
*
* @param {TextEditor} instance
* @returns {TextEditor|null}
*/
export function dispose(instance) { export function dispose(instance) {
if (isEditor(instance)) { if (isTextEditor(instance)) {
instance.dispose(); return instance.dispose();
} }
return null;
} }
export default TextEditor; export default TextEditor;

View File

@@ -10,6 +10,7 @@ import {
mapContentFragmentFromHTML, mapContentFragmentFromHTML,
mapContentFragmentFromString, mapContentFragmentFromString,
} from "../content/dom/Content.js"; } from "../content/dom/Content.js";
import { TextEditor } from "../TextEditor.js";
/** /**
* Returns a DocumentFragment from text/html. * Returns a DocumentFragment from text/html.
@@ -17,7 +18,10 @@ import {
* @param {DataTransfer} clipboardData * @param {DataTransfer} clipboardData
* @returns {DocumentFragment} * @returns {DocumentFragment}
*/ */
function getFormattedFragmentFromClipboardData(selectionController, clipboardData) { function getFormattedFragmentFromClipboardData(
selectionController,
clipboardData,
) {
return mapContentFragmentFromHTML( return mapContentFragmentFromHTML(
clipboardData.getData("text/html"), clipboardData.getData("text/html"),
selectionController.currentStyle, selectionController.currentStyle,
@@ -38,19 +42,26 @@ function getPlainFragmentFromClipboardData(selectionController, clipboardData) {
} }
/** /**
* Returns a DocumentFragment (or null) if it contains * Returns a document fragment of html data.
* a compatible clipboardData type.
* *
* @param {DataTransfer} clipboardData * @param {DataTransfer} clipboardData
* @returns {DocumentFragment|null} * @returns {DocumentFragment}
*/ */
function getFragmentFromClipboardData(selectionController, clipboardData) { function getFormattedOrPlainFragmentFromClipboardData(
selectionController,
clipboardData,
) {
if (clipboardData.types.includes("text/html")) { if (clipboardData.types.includes("text/html")) {
return getFormattedFragmentFromClipboardData(selectionController, clipboardData) return getFormattedFragmentFromClipboardData(
selectionController,
clipboardData,
);
} else if (clipboardData.types.includes("text/plain")) { } else if (clipboardData.types.includes("text/plain")) {
return getPlainFragmentFromClipboardData(selectionController, clipboardData) return getPlainFragmentFromClipboardData(
selectionController,
clipboardData,
);
} }
return null
} }
/** /**
@@ -71,18 +82,40 @@ export function paste(event, editor, selectionController) {
let fragment = null; let fragment = null;
if (editor?.options?.allowHTMLPaste) { if (editor?.options?.allowHTMLPaste) {
fragment = getFragmentFromClipboardData(selectionController, event.clipboardData); fragment = getFormattedOrPlainFragmentFromClipboardData(
event.clipboardData,
);
} else { } else {
fragment = getPlainFragmentFromClipboardData(selectionController, event.clipboardData); fragment = getPlainFragmentFromClipboardData(
selectionController,
event.clipboardData,
);
} }
if (!fragment) { if (!fragment) {
// NOOP
return; return;
} }
if (selectionController.isCollapsed) { if (selectionController.isCollapsed) {
selectionController.insertPaste(fragment); const hasOnlyOneParagraph = fragment.children.length === 1;
const hasOnlyOneTextSpan = fragment.firstElementChild.children.length === 1;
const forceTextSpan =
fragment.firstElementChild.dataset.textSpan === "force";
if (hasOnlyOneParagraph && hasOnlyOneTextSpan && forceTextSpan) {
selectionController.insertIntoFocus(fragment.textContent);
} else {
selectionController.insertPaste(fragment);
}
} else { } else {
selectionController.replaceWithPaste(fragment); const hasOnlyOneParagraph = fragment.children.length === 1;
const hasOnlyOneTextSpan = fragment.firstElementChild.children.length === 1;
const forceTextSpan =
fragment.firstElementChild.dataset.textSpan === "force";
if (hasOnlyOneParagraph && hasOnlyOneTextSpan && forceTextSpan) {
selectionController.replaceText(fragment.textContent);
} else {
selectionController.replaceWithPaste(fragment);
}
} }
} }

View File

@@ -23,7 +23,7 @@ export function deleteContentBackward(event, editor, selectionController) {
// If not is collapsed AKA is a selection, then // If not is collapsed AKA is a selection, then
// we removeSelected. // we removeSelected.
if (!selectionController.isCollapsed) { if (!selectionController.isCollapsed) {
return selectionController.removeSelected({ direction: 'backward' }); return selectionController.removeSelected({ direction: "backward" });
} }
// If we're in a text node and the offset is // If we're in a text node and the offset is
@@ -32,18 +32,18 @@ export function deleteContentBackward(event, editor, selectionController) {
if (selectionController.isTextFocus && selectionController.focusOffset > 0) { if (selectionController.isTextFocus && selectionController.focusOffset > 0) {
return selectionController.removeBackwardText(); return selectionController.removeBackwardText();
// If we're in a text node but we're at the end of the // If we're in a text node but we're at the end of the
// paragraph, we should merge the current paragraph // paragraph, we should merge the current paragraph
// with the following paragraph. // with the following paragraph.
} else if ( } else if (
selectionController.isTextFocus && selectionController.isTextFocus &&
selectionController.focusAtStart selectionController.focusAtStart
) { ) {
return selectionController.mergeBackwardParagraph(); return selectionController.mergeBackwardParagraph();
// If we're at an text span or a line break paragraph // If we're at an text span or a line break paragraph
// and there's more than one paragraph, then we should // and there's more than one paragraph, then we should
// remove the next paragraph. // remove the next paragraph.
} else if ( } else if (
selectionController.isTextSpanFocus || selectionController.isTextSpanFocus ||
selectionController.isLineBreakFocus selectionController.isLineBreakFocus

View File

@@ -28,22 +28,21 @@ export function deleteContentForward(event, editor, selectionController) {
// If we're in a text node and the offset is // If we're in a text node and the offset is
// greater than 0 (not at the start of the text span) // greater than 0 (not at the start of the text span)
// we simple remove a character from the text. // we simple remove a character from the text.
if (selectionController.isTextFocus if (selectionController.isTextFocus && selectionController.focusAtEnd) {
&& selectionController.focusAtEnd) {
return selectionController.mergeForwardParagraph(); return selectionController.mergeForwardParagraph();
// If we're in a text node but we're at the end of the // If we're in a text node but we're at the end of the
// paragraph, we should merge the current paragraph // paragraph, we should merge the current paragraph
// with the following paragraph. // with the following paragraph.
} else if ( } else if (
selectionController.isTextFocus && selectionController.isTextFocus &&
selectionController.focusOffset >= 0 selectionController.focusOffset >= 0
) { ) {
return selectionController.removeForwardText(); return selectionController.removeForwardText();
// If we're at a text span or a line break paragraph // If we're at a text span or a line break paragraph
// and there's more than one paragraph, then we should // and there's more than one paragraph, then we should
// remove the next paragraph. // remove the next paragraph.
} else if ( } else if (
(selectionController.isTextSpanFocus || (selectionController.isTextSpanFocus ||
selectionController.isLineBreakFocus) && selectionController.isLineBreakFocus) &&

View File

@@ -1,11 +1,17 @@
import { describe, test, expect } from 'vitest' import { describe, test, expect } from "vitest";
import { insertInto, removeBackward, removeForward, replaceWith } from './Text'; import { insertInto, removeBackward, removeForward, replaceWith } from "./Text";
describe("Text", () => { describe("Text", () => {
test("* should throw when passed wrong parameters", () => { test("* should throw when passed wrong parameters", () => {
expect(() => insertInto(Infinity, Infinity, Infinity)).toThrowError('Invalid string'); expect(() => insertInto(Infinity, Infinity, Infinity)).toThrowError(
expect(() => insertInto('Hello', Infinity, Infinity)).toThrowError('Invalid offset'); "Invalid string",
expect(() => insertInto('Hello', 0, Infinity)).toThrowError('Invalid string'); );
expect(() => insertInto("Hello", Infinity, Infinity)).toThrowError(
"Invalid offset",
);
expect(() => insertInto("Hello", 0, Infinity)).toThrowError(
"Invalid string",
);
}); });
test("`insertInto` should insert a string into an offset", () => { test("`insertInto` should insert a string into an offset", () => {
@@ -13,7 +19,9 @@ describe("Text", () => {
}); });
test("`replaceWith` should replace a string into a string", () => { test("`replaceWith` should replace a string into a string", () => {
expect(replaceWith("Hello, Something!", 7, 16, "World")).toBe("Hello, World!"); expect(replaceWith("Hello, Something!", 7, 16, "World")).toBe(
"Hello, World!",
);
}); });
test("`removeBackward` should remove string backward from start (offset 0)", () => { test("`removeBackward` should remove string backward from start (offset 0)", () => {
@@ -26,13 +34,13 @@ describe("Text", () => {
test("`removeBackward` should remove string backward from end", () => { test("`removeBackward` should remove string backward from end", () => {
expect(removeBackward("Hello, World!", "Hello, World!".length)).toBe( expect(removeBackward("Hello, World!", "Hello, World!".length)).toBe(
"Hello, World" "Hello, World",
); );
}); });
test("`removeForward` should remove string forward from end", () => { test("`removeForward` should remove string forward from end", () => {
expect(removeForward("Hello, World!", "Hello, World!".length)).toBe( expect(removeForward("Hello, World!", "Hello, World!".length)).toBe(
"Hello, World!" "Hello, World!",
); );
}); });

View File

@@ -24,7 +24,7 @@ function getContext() {
if (!context) { if (!context) {
context = canvas.getContext("2d"); context = canvas.getContext("2d");
} }
return context return context;
} }
/** /**

View File

@@ -232,12 +232,12 @@ export function mapContentFragmentFromString(string, styleDefaults) {
if (line === "") { if (line === "") {
fragment.appendChild(createEmptyParagraph(styleDefaults)); fragment.appendChild(createEmptyParagraph(styleDefaults));
} else { } else {
fragment.appendChild( const textSpan = createTextSpan(new Text(line), styleDefaults);
createParagraph( const paragraph = createParagraph([textSpan], styleDefaults);
[createTextSpan(new Text(line), styleDefaults)], if (lines.length === 1) {
styleDefaults, paragraph.dataset.textSpan = "force";
), }
); fragment.appendChild(paragraph);
} }
} }
return fragment; return fragment;

View File

@@ -112,7 +112,11 @@ describe("Paragraph", () => {
const helloTextSpan = createTextSpan(new Text("Hello, ")); const helloTextSpan = createTextSpan(new Text("Hello, "));
const worldTextSpan = createTextSpan(new Text("World")); const worldTextSpan = createTextSpan(new Text("World"));
const exclTextSpan = createTextSpan(new Text("!")); const exclTextSpan = createTextSpan(new Text("!"));
const paragraph = createParagraph([helloTextSpan, worldTextSpan, exclTextSpan]); const paragraph = createParagraph([
helloTextSpan,
worldTextSpan,
exclTextSpan,
]);
const newParagraph = splitParagraphAtNode(paragraph, 1); const newParagraph = splitParagraphAtNode(paragraph, 1);
expect(newParagraph).toBeInstanceOf(HTMLDivElement); expect(newParagraph).toBeInstanceOf(HTMLDivElement);
expect(newParagraph.nodeName).toBe(TAG); expect(newParagraph.nodeName).toBe(TAG);

View File

@@ -1,5 +1,11 @@
import { describe, test, expect } from "vitest"; import { describe, test, expect } from "vitest";
import { createEmptyRoot, createRoot, setRootStyles, TAG, TYPE } from "./Root.js"; import {
createEmptyRoot,
createRoot,
setRootStyles,
TAG,
TYPE,
} from "./Root.js";
/* @vitest-environment jsdom */ /* @vitest-environment jsdom */
describe("Root", () => { describe("Root", () => {

View File

@@ -6,6 +6,7 @@
* Copyright (c) KALEIDOS INC * Copyright (c) KALEIDOS INC
*/ */
import StyleDeclaration from "../../controllers/StyleDeclaration.js";
import { getFills } from "./Color.js"; import { getFills } from "./Color.js";
const DEFAULT_FONT_SIZE = "16px"; const DEFAULT_FONT_SIZE = "16px";
@@ -338,13 +339,13 @@ export function setStylesFromObject(element, allowedStyles, styleObject) {
continue; continue;
} }
let styleValue = styleObject[styleName]; let styleValue = styleObject[styleName];
if (!styleValue) continue;
if (styleName === "font-family") { if (styleName === "font-family") {
styleValue = sanitizeFontFamily(styleValue); styleValue = sanitizeFontFamily(styleValue);
} }
if (styleValue) { setStyle(element, styleName, styleValue, styleUnit);
setStyle(element, styleName, styleValue, styleUnit);
}
} }
return element; return element;
} }
@@ -386,7 +387,10 @@ export function setStylesFromDeclaration(
* @returns {HTMLElement} * @returns {HTMLElement}
*/ */
export function setStyles(element, allowedStyles, styleObjectOrDeclaration) { export function setStyles(element, allowedStyles, styleObjectOrDeclaration) {
if (styleObjectOrDeclaration instanceof CSSStyleDeclaration) { if (
styleObjectOrDeclaration instanceof CSSStyleDeclaration ||
styleObjectOrDeclaration instanceof StyleDeclaration
) {
return setStylesFromDeclaration( return setStylesFromDeclaration(
element, element,
allowedStyles, allowedStyles,
@@ -426,13 +430,15 @@ export function mergeStyles(allowedStyles, styleDeclaration, newStyles) {
const mergedStyles = {}; const mergedStyles = {};
for (const [styleName, styleUnit] of allowedStyles) { for (const [styleName, styleUnit] of allowedStyles) {
if (styleName in newStyles) { if (styleName in newStyles) {
mergedStyles[styleName] = newStyles[styleName]; const styleValue = newStyles[styleName];
mergedStyles[styleName] = styleValue;
} else { } else {
mergedStyles[styleName] = getStyleFromDeclaration( const styleValue = getStyleFromDeclaration(
styleDeclaration, styleDeclaration,
styleName, styleName,
styleUnit, styleUnit,
); );
mergedStyles[styleName] = styleValue;
} }
} }
return mergedStyles; return mergedStyles;

View File

@@ -22,8 +22,7 @@ import { isRoot } from "./Root.js";
*/ */
export function isTextNode(node) { export function isTextNode(node) {
if (!node) throw new TypeError("Invalid text node"); if (!node) throw new TypeError("Invalid text node");
return node.nodeType === Node.TEXT_NODE return node.nodeType === Node.TEXT_NODE || isLineBreak(node);
|| isLineBreak(node);
} }
/** /**
@@ -33,8 +32,7 @@ export function isTextNode(node) {
* @returns {boolean} * @returns {boolean}
*/ */
export function isEmptyTextNode(node) { export function isEmptyTextNode(node) {
return node.nodeType === Node.TEXT_NODE return node.nodeType === Node.TEXT_NODE && node.nodeValue === "";
&& node.nodeValue === "";
} }
/** /**

View File

@@ -6,6 +6,8 @@
* Copyright (c) KALEIDOS INC * Copyright (c) KALEIDOS INC
*/ */
import SafeGuard from "../../controllers/SafeGuard.js";
/** /**
* Iterator direction. * Iterator direction.
* *
@@ -56,7 +58,7 @@ export class TextNodeIterator {
startNode, startNode,
rootNode, rootNode,
skipNodes = new Set(), skipNodes = new Set(),
direction = TextNodeIteratorDirection.FORWARD direction = TextNodeIteratorDirection.FORWARD,
) { ) {
if (startNode === rootNode) { if (startNode === rootNode) {
return TextNodeIterator.findDown( return TextNodeIterator.findDown(
@@ -65,7 +67,7 @@ export class TextNodeIterator {
: startNode.lastChild, : startNode.lastChild,
rootNode, rootNode,
skipNodes, skipNodes,
direction direction,
); );
} }
@@ -93,7 +95,7 @@ export class TextNodeIterator {
: currentNode.lastChild, : currentNode.lastChild,
rootNode, rootNode,
skipNodes, skipNodes,
direction direction,
); );
} }
currentNode = currentNode =
@@ -117,7 +119,7 @@ export class TextNodeIterator {
startNode, startNode,
rootNode, rootNode,
backTrack = new Set(), backTrack = new Set(),
direction = TextNodeIteratorDirection.FORWARD direction = TextNodeIteratorDirection.FORWARD,
) { ) {
backTrack.add(startNode); backTrack.add(startNode);
if (TextNodeIterator.isTextNode(startNode)) { if (TextNodeIterator.isTextNode(startNode)) {
@@ -125,14 +127,14 @@ export class TextNodeIterator {
startNode.parentNode, startNode.parentNode,
rootNode, rootNode,
backTrack, backTrack,
direction direction,
); );
} else if (TextNodeIterator.isContainerNode(startNode)) { } else if (TextNodeIterator.isContainerNode(startNode)) {
const found = TextNodeIterator.findDown( const found = TextNodeIterator.findDown(
startNode, startNode,
rootNode, rootNode,
backTrack, backTrack,
direction direction,
); );
if (found) { if (found) {
return found; return found;
@@ -142,7 +144,7 @@ export class TextNodeIterator {
startNode.parentNode, startNode.parentNode,
rootNode, rootNode,
backTrack, backTrack,
direction direction,
); );
} }
} }
@@ -212,7 +214,7 @@ export class TextNodeIterator {
this.#currentNode, this.#currentNode,
this.#rootNode, this.#rootNode,
new Set(), new Set(),
TextNodeIteratorDirection.FORWARD TextNodeIteratorDirection.FORWARD,
); );
if (!nextNode) { if (!nextNode) {
@@ -235,7 +237,7 @@ export class TextNodeIterator {
this.#currentNode, this.#currentNode,
this.#rootNode, this.#rootNode,
new Set(), new Set(),
TextNodeIteratorDirection.BACKWARD TextNodeIteratorDirection.BACKWARD,
); );
if (!previousNode) { if (!previousNode) {
@@ -245,6 +247,49 @@ export class TextNodeIterator {
this.#currentNode = previousNode; this.#currentNode = previousNode;
return this.#currentNode; return this.#currentNode;
} }
/**
* Returns an array of text nodes.
*
* @param {TextNode} startNode
* @param {TextNode} endNode
* @returns {Array<TextNode>}
*/
collectFrom(startNode, endNode) {
const nodes = [];
for (const node of this.iterateFrom(startNode, endNode)) {
nodes.push(node);
}
return nodes;
}
/**
* Iterates over a list of nodes.
*
* @param {TextNode} startNode
* @param {TextNode} endNode
* @yields {TextNode}
*/
*iterateFrom(startNode, endNode) {
const comparedPosition = startNode.compareDocumentPosition(endNode);
this.#currentNode = startNode;
SafeGuard.start();
while (this.#currentNode !== endNode) {
yield this.#currentNode;
SafeGuard.update();
if (comparedPosition === Node.DOCUMENT_POSITION_PRECEDING) {
if (!this.previousNode()) {
break;
}
} else if (comparedPosition === Node.DOCUMENT_POSITION_FOLLOWING) {
if (!this.nextNode()) {
break;
}
} else {
break;
}
}
}
} }
export default TextNodeIterator; export default TextNodeIterator;

View File

@@ -38,7 +38,7 @@ export class ChangeController extends EventTarget {
* @param {number} [time=500] * @param {number} [time=500]
*/ */
constructor(time = 500) { constructor(time = 500) {
super() super();
if (typeof time === "number" && (!Number.isInteger(time) || time <= 0)) { if (typeof time === "number" && (!Number.isInteger(time) || time <= 0)) {
throw new TypeError("Invalid time"); throw new TypeError("Invalid time");
} }

View File

@@ -24,11 +24,24 @@ export function start() {
*/ */
export function update() { export function update() {
if (Date.now - startTime >= SAFE_GUARD_TIME) { if (Date.now - startTime >= SAFE_GUARD_TIME) {
throw new Error('Safe guard timeout'); throw new Error("Safe guard timeout");
} }
} }
let timeoutId = 0;
export function throwAfter(error, timeout = SAFE_GUARD_TIME) {
timeoutId = setTimeout(() => {
throw error;
}, timeout);
}
export function throwCancel() {
clearTimeout(timeoutId);
}
export default { export default {
start, start,
update, update,
} throwAfter,
throwCancel,
};

View File

@@ -54,6 +54,7 @@ import { isRoot, setRootStyles } from "../content/dom/Root.js";
import { SelectionDirection } from "./SelectionDirection.js"; import { SelectionDirection } from "./SelectionDirection.js";
import SafeGuard from "./SafeGuard.js"; import SafeGuard from "./SafeGuard.js";
import { sanitizeFontFamily } from "../content/dom/Style.js"; import { sanitizeFontFamily } from "../content/dom/Style.js";
import StyleDeclaration from "./StyleDeclaration.js";
/** /**
* Supported options for the SelectionController. * Supported options for the SelectionController.
@@ -64,39 +65,7 @@ import { sanitizeFontFamily } from "../content/dom/Style.js";
/** /**
* SelectionController uses the same concepts used by the Selection API but extending it to support * SelectionController uses the same concepts used by the Selection API but extending it to support
* our own internal model based on paragraphs (in drafconst textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([ * our own internal model based on paragraphs (in draft.js they were called blocks) and text spans.
createParagraph([createTextSpan(new Text("Hello, "))]),
createEmptyParagraph(),
createParagraph([createTextSpan(new Text("World!"))]),
]);
const root = textEditorMock.root;
const selection = document.getSelection();
const selectionController = new SelectionController(
textEditorMock,
selection
);
focus(
selection,
textEditorMock,
root.childNodes.item(2).firstChild.firstChild,
0
);
selectionController.mergeBackwardParagraph();
expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement);
expect(textEditorMock.root.children.length).toBe(2);
expect(textEditorMock.root.dataset.itype).toBe("root");
expect(textEditorMock.root.firstChild).toBeInstanceOf(HTMLDivElement);
expect(textEditorMock.root.firstChild.dataset.itype).toBe("paragraph");
expect(textEditorMock.root.firstChild.firstChild).toBeInstanceOf(
HTMLSpanElement
);
expect(textEditorMock.root.firstChild.firstChild.dataset.itype).toBe(
"span"
);
expect(textEditorMock.root.textContent).toBe("Hello, World!");
expect(textEditorMock.root.firstChild.textContent).toBe("Hello, ");
expect(textEditorMock.root.lastChild.textContent).toBe("World!");
t.js they were called blocks) and text spans.
*/ */
export class SelectionController extends EventTarget { export class SelectionController extends EventTarget {
/** /**
@@ -164,21 +133,12 @@ export class SelectionController extends EventTarget {
#textNodeIterator = null; #textNodeIterator = null;
/** /**
* CSSStyleDeclaration that we can mutate * StyleDeclaration that we can mutate
* to handle style changes. * to handle style changes.
* *
* @type {CSSStyleDeclaration} * @type {StyleDeclaration}
*/ */
#currentStyle = null; #currentStyle = new StyleDeclaration();
/**
* Element used to have a custom CSSStyleDeclaration
* that we can modify to handle style changes when the
* selection is changed.
*
* @type {HTMLDivElement}
*/
#inertElement = null;
/** /**
* @type {SelectionControllerDebug} * @type {SelectionControllerDebug}
@@ -275,19 +235,68 @@ export class SelectionController extends EventTarget {
* *
* @param {HTMLElement} element * @param {HTMLElement} element
*/ */
#applyStylesToCurrentStyle(element) { #applyStylesFromElementToCurrentStyle(element) {
for (let index = 0; index < element.style.length; index++) { for (let index = 0; index < element.style.length; index++) {
const styleName = element.style.item(index); const styleName = element.style.item(index);
if (styleName === "--fills") {
continue;
}
let styleValue = element.style.getPropertyValue(styleName);
if (styleName === "font-family") {
styleValue = sanitizeFontFamily(styleValue);
}
this.#currentStyle.setProperty(styleName, styleValue);
}
}
/**
* Applies some styles to the currentStyle
* CSSStyleDeclaration
*
* @param {HTMLElement} element
*/
#mergeStylesFromElementToCurrentStyle(element) {
for (let index = 0; index < element.style.length; index++) {
const styleName = element.style.item(index);
let styleValue = element.style.getPropertyValue(styleName); let styleValue = element.style.getPropertyValue(styleName);
if (styleName === "font-family") { if (styleName === "font-family") {
styleValue = sanitizeFontFamily(styleValue); styleValue = sanitizeFontFamily(styleValue);
} }
this.#currentStyle.mergeProperty(styleName, styleValue);
this.#currentStyle.setProperty(styleName, styleValue);
} }
} }
/**
* Updates current styles based on the currently selected text spans.
*
* @param {HTMLSpanElement} startNode
* @param {HTMLSpanElement} endNode
*/
#updateCurrentStyleFrom(startNode, endNode) {
this.#applyDefaultStylesToCurrentStyle();
const root = startNode.parentElement.parentElement.parentElement;
this.#applyStylesFromElementToCurrentStyle(root);
// FIXME: I don't like this approximation. Having to iterate nodes twice
// is bad for performance. I think we need another way of "computing"
// the cascade.
for (const textNode of this.#textNodeIterator.iterateFrom(
startNode,
endNode,
)) {
const paragraph = textNode.parentElement.parentElement;
this.#applyStylesFromElementToCurrentStyle(paragraph);
}
for (const textNode of this.#textNodeIterator.iterateFrom(
startNode,
endNode,
)) {
const textSpan = textNode.parentElement;
this.#mergeStylesFromElementToCurrentStyle(textSpan);
}
return this;
}
/** /**
* Updates current styles based on the currently selected text span. * Updates current styles based on the currently selected text span.
* *
@@ -297,20 +306,14 @@ export class SelectionController extends EventTarget {
#updateCurrentStyle(textSpan) { #updateCurrentStyle(textSpan) {
this.#applyDefaultStylesToCurrentStyle(); this.#applyDefaultStylesToCurrentStyle();
const root = textSpan.parentElement.parentElement; const root = textSpan.parentElement.parentElement;
this.#applyStylesToCurrentStyle(root); this.#applyStylesFromElementToCurrentStyle(root);
const paragraph = textSpan.parentElement; const paragraph = textSpan.parentElement;
this.#applyStylesToCurrentStyle(paragraph); this.#applyStylesFromElementToCurrentStyle(paragraph);
this.#applyStylesToCurrentStyle(textSpan); this.#applyStylesFromElementToCurrentStyle(textSpan);
return this; return this;
} }
/** #updateState() {
* This is called on every `selectionchange` because it is dispatched
* only by the `document` object.
*
* @param {Event} e
*/
#onSelectionChange = (e) => {
// If we're outside the contenteditable element, then // If we're outside the contenteditable element, then
// we return. // we return.
if (!this.hasFocus) { if (!this.hasFocus) {
@@ -320,17 +323,23 @@ export class SelectionController extends EventTarget {
let focusNodeChanges = false; let focusNodeChanges = false;
let anchorNodeChanges = false; let anchorNodeChanges = false;
if (this.#focusNode !== this.#selection.focusNode) { if (
this.#focusNode !== this.#selection.focusNode ||
this.#focusOffset !== this.#selection.focusOffset
) {
this.#focusNode = this.#selection.focusNode; this.#focusNode = this.#selection.focusNode;
this.#focusOffset = this.#selection.focusOffset;
focusNodeChanges = true; focusNodeChanges = true;
} }
this.#focusOffset = this.#selection.focusOffset;
if (this.#anchorNode !== this.#selection.anchorNode) { if (
this.#anchorNode !== this.#selection.anchorNode ||
this.#anchorOffset !== this.#selection.anchorOffset
) {
this.#anchorNode = this.#selection.anchorNode; this.#anchorNode = this.#selection.anchorNode;
this.#anchorOffset = this.#selection.anchorOffset;
anchorNodeChanges = true; anchorNodeChanges = true;
} }
this.#anchorOffset = this.#selection.anchorOffset;
// We need to handle multi selection from firefox // We need to handle multi selection from firefox
// and remove all the old ranges and just keep the // and remove all the old ranges and just keep the
@@ -359,7 +368,7 @@ export class SelectionController extends EventTarget {
// If focus node changed, we need to retrieve all the // If focus node changed, we need to retrieve all the
// styles of the current text span and dispatch an event // styles of the current text span and dispatch an event
// to notify that the styles have changed. // to notify that the styles have changed.
if (focusNodeChanges) { if (focusNodeChanges || anchorNodeChanges) {
this.#notifyStyleChange(); this.#notifyStyleChange();
} }
@@ -372,43 +381,42 @@ export class SelectionController extends EventTarget {
if (this.#debug) { if (this.#debug) {
this.#debug.update(this); this.#debug.update(this);
} }
}; }
/**
* This is called on every `selectionchange` because it is dispatched
* only by the `document` object.
*
* @param {Event} e
*/
#onSelectionChange = (_) => this.#updateState();
/** /**
* Notifies that the styles have changed. * Notifies that the styles have changed.
*/ */
#notifyStyleChange() { #notifyStyleChange() {
const textSpan = this.focusTextSpan; if (this.#selection.isCollapsed) {
if (textSpan) { // CARET
this.#updateCurrentStyle(textSpan); const textSpan =
this.dispatchEvent( this.focusTextSpan ??
new CustomEvent("stylechange", {
detail: this.#currentStyle,
}),
);
} else {
const firstTextSpan =
this.#textEditor.root?.firstElementChild?.firstElementChild; this.#textEditor.root?.firstElementChild?.firstElementChild;
if (firstTextSpan) {
this.#updateCurrentStyle(firstTextSpan); this.#updateCurrentStyle(textSpan);
this.dispatchEvent( } else {
new CustomEvent("stylechange", { // SELECTION.
detail: this.#currentStyle, this.#updateCurrentStyleFrom(this.#anchorNode, this.#focusNode);
}),
);
}
} }
this.dispatchEvent(
new CustomEvent("stylechange", {
detail: this.#currentStyle,
}),
);
} }
/** /**
* Setups * Setups
*/ */
#setup() { #setup() {
// This element is not attached to the DOM
// so it doesn't trigger style or layout calculations.
// That's why it's called "inertElement".
this.#inertElement = document.createElement("div");
this.#currentStyle = this.#inertElement.style;
this.#applyDefaultStylesToCurrentStyle(); this.#applyDefaultStylesToCurrentStyle();
if (this.#selection.rangeCount > 0) { if (this.#selection.rangeCount > 0) {
@@ -428,6 +436,22 @@ export class SelectionController extends EventTarget {
document.addEventListener("selectionchange", this.#onSelectionChange); document.addEventListener("selectionchange", this.#onSelectionChange);
} }
/**
* Disposes the current resources.
*/
dispose() {
document.removeEventListener("selectionchange", this.#onSelectionChange);
this.#textEditor = null;
this.#ranges.clear();
this.#ranges = null;
this.#range = null;
this.#selection = null;
this.#focusNode = null;
this.#anchorNode = null;
this.#mutations.dispose();
this.#mutations = null;
}
/** /**
* Returns a Range-like object. * Returns a Range-like object.
* *
@@ -480,19 +504,12 @@ export class SelectionController extends EventTarget {
if (!this.#savedSelection) return false; if (!this.#savedSelection) return false;
if (this.#savedSelection.anchorNode && this.#savedSelection.focusNode) { if (this.#savedSelection.anchorNode && this.#savedSelection.focusNode) {
if (this.#savedSelection.anchorNode === this.#savedSelection.focusNode) { this.#selection.setBaseAndExtent(
this.#selection.setPosition( this.#savedSelection.anchorNode,
this.#savedSelection.focusNode, this.#savedSelection.anchorOffset,
this.#savedSelection.focusOffset, this.#savedSelection.focusNode,
); this.#savedSelection.focusOffset,
} else { );
this.#selection.setBaseAndExtent(
this.#savedSelection.anchorNode,
this.#savedSelection.anchorOffset,
this.#savedSelection.focusNode,
this.#savedSelection.focusOffset,
);
}
} }
this.#savedSelection = null; this.#savedSelection = null;
return true; return true;
@@ -502,6 +519,8 @@ export class SelectionController extends EventTarget {
* Marks the start of a mutation. * Marks the start of a mutation.
* *
* Clears all the mutations kept in CommandMutations. * Clears all the mutations kept in CommandMutations.
*
* @returns {boolean}
*/ */
startMutation() { startMutation() {
this.#mutations.clear(); this.#mutations.clear();
@@ -512,7 +531,7 @@ export class SelectionController extends EventTarget {
/** /**
* Marks the end of a mutation. * Marks the end of a mutation.
* *
* @returns * @returns {CommandMutations}
*/ */
endMutation() { endMutation() {
return this.#mutations; return this.#mutations;
@@ -520,6 +539,8 @@ export class SelectionController extends EventTarget {
/** /**
* Selects all content. * Selects all content.
*
* @returns {SelectionController}
*/ */
selectAll() { selectAll() {
if (this.#textEditor.isEmpty) { if (this.#textEditor.isEmpty) {
@@ -558,23 +579,15 @@ export class SelectionController extends EventTarget {
this.#selection.removeAllRanges(); this.#selection.removeAllRanges();
this.#selection.addRange(range); this.#selection.addRange(range);
// Ensure internal state is synchronized this.#updateState();
this.#focusNode = this.#selection.focusNode;
this.#focusOffset = this.#selection.focusOffset;
this.#anchorNode = this.#selection.anchorNode;
this.#anchorOffset = this.#selection.anchorOffset;
this.#range = range;
this.#ranges.clear();
this.#ranges.add(range);
// Notify style changes
this.#notifyStyleChange();
return this; return this;
} }
/** /**
* Moves cursor to end. * Moves cursor to end.
*
* @returns {SelectionController}
*/ */
cursorToEnd() { cursorToEnd() {
const range = document.createRange(); //Create a range (a range is a like the selection but invisible) const range = document.createRange(); //Create a range (a range is a like the selection but invisible)
@@ -662,22 +675,6 @@ export class SelectionController extends EventTarget {
} }
} }
/**
* Disposes the current resources.
*/
dispose() {
document.removeEventListener("selectionchange", this.#onSelectionChange);
this.#textEditor = null;
this.#ranges.clear();
this.#ranges = null;
this.#range = null;
this.#selection = null;
this.#focusNode = null;
this.#anchorNode = null;
this.#mutations.dispose();
this.#mutations = null;
}
/** /**
* Returns the current selection. * Returns the current selection.
* *
@@ -1114,8 +1111,8 @@ export class SelectionController extends EventTarget {
return isParagraphEnd(this.focusNode, this.focusOffset); return isParagraphEnd(this.focusNode, this.focusOffset);
} }
#getFragmentInlineTextNode(fragment) { #getFragmentTextSpanTextNode(fragment) {
if (isInline(fragment.firstElementChild.lastChild)) { if (isTextSpan(fragment.firstElementChild.lastChild)) {
return fragment.firstElementChild.firstElementChild.lastChild; return fragment.firstElementChild.firstElementChild.lastChild;
} }
return fragment.firstElementChild.lastChild; return fragment.firstElementChild.lastChild;
@@ -1131,11 +1128,12 @@ export class SelectionController extends EventTarget {
* @param {DocumentFragment} fragment * @param {DocumentFragment} fragment
*/ */
insertPaste(fragment) { insertPaste(fragment) {
if ( const hasOnlyOneParagraph = fragment.children.length === 1;
fragment.children.length === 1 && const forceTextSpan =
fragment.firstElementChild?.dataset?.textSpan === "force" fragment.firstElementChild?.dataset?.textSpan === "force";
) { if (hasOnlyOneParagraph && forceTextSpan) {
const collapseNode = fragment.firstElementChild.firstChild; // first text span
const collapseNode = fragment.firstElementChild.firstElementChild;
if (this.isTextSpanStart) { if (this.isTextSpanStart) {
this.focusTextSpan.before(...fragment.firstElementChild.children); this.focusTextSpan.before(...fragment.firstElementChild.children);
} else if (this.isTextSpanEnd) { } else if (this.isTextSpanEnd) {
@@ -1147,7 +1145,9 @@ export class SelectionController extends EventTarget {
newTextSpan, newTextSpan,
); );
} }
return this.collapse(collapseNode, collapseNode.nodeValue?.length || 0); // collapseNode could be a <br>, that's why we need to
// make `nodeValue` as optional.
return this.collapse(collapseNode, collapseNode?.nodeValue?.length || 0);
} }
const collapseNode = this.#getFragmentParagraphTextNode(fragment); const collapseNode = this.#getFragmentParagraphTextNode(fragment);
if (this.isParagraphStart) { if (this.isParagraphStart) {
@@ -1393,9 +1393,16 @@ export class SelectionController extends EventTarget {
this.focusOffset, this.focusOffset,
newText, newText,
); );
this.collapse(this.focusNode, this.focusOffset + newText.length);
} else if (this.isLineBreakFocus) { } else if (this.isLineBreakFocus) {
const textNode = new Text(newText); const textNode = new Text(newText);
this.focusNode.replaceWith(textNode); // the focus node is a <span>.
if (isTextSpan(this.focusNode)) {
this.focusNode.firstElementChild.replaceWith(textNode);
// the focus node is a <br>.
} else {
this.focusNode.replaceWith(textNode);
}
this.collapse(textNode, newText.length); this.collapse(textNode, newText.length);
} else { } else {
throw new Error("Unknown node type"); throw new Error("Unknown node type");
@@ -1932,11 +1939,21 @@ export class SelectionController extends EventTarget {
const textSpan = this.startTextSpan; const textSpan = this.startTextSpan;
const midText = startNode.splitText(startOffset); const midText = startNode.splitText(startOffset);
const endText = midText.splitText(endOffset - startOffset); const endText = midText.splitText(endOffset - startOffset);
const midTextSpan = createTextSpanFrom(textSpan, midText, newStyles);
textSpan.after(midTextSpan); // Only create text span if midText is not empty
if (endText.length > 0) { if (midText.nodeValue && midText.nodeValue.length > 0) {
const endTextSpan = createTextSpan(endText, textSpan.style); const midTextSpan = createTextSpanFrom(textSpan, midText, newStyles);
midTextSpan.after(endTextSpan); textSpan.after(midTextSpan);
if (endText.length > 0) {
const endTextSpan = createTextSpan(endText, textSpan.style);
midTextSpan.after(endTextSpan);
}
} else {
// If midText is empty, just create endTextSpan if needed
if (endText.length > 0) {
const endTextSpan = createTextSpan(endText, textSpan.style);
textSpan.after(endTextSpan);
}
} }
// NOTE: This is necessary because sometimes // NOTE: This is necessary because sometimes
@@ -1953,7 +1970,7 @@ export class SelectionController extends EventTarget {
// the styles are applied to the current caret // the styles are applied to the current caret
else if ( else if (
this.startOffset === this.endOffset && this.startOffset === this.endOffset &&
this.endOffset === endNode.nodeValue.length this.endOffset === endNode.nodeValue?.length
) { ) {
const newTextSpan = createVoidTextSpan(newStyles); const newTextSpan = createVoidTextSpan(newStyles);
this.endTextSpan.after(newTextSpan); this.endTextSpan.after(newTextSpan);
@@ -1963,6 +1980,10 @@ export class SelectionController extends EventTarget {
else { else {
const paragraph = this.startParagraph; const paragraph = this.startParagraph;
setParagraphStyles(paragraph, newStyles); setParagraphStyles(paragraph, newStyles);
// Apply styles to child text spans.
for (const textSpan of paragraph.children) {
setTextSpanStyles(textSpan, newStyles);
}
} }
return this.#notifyStyleChange(); return this.#notifyStyleChange();
@@ -1984,7 +2005,8 @@ export class SelectionController extends EventTarget {
// new text span. // new text span.
if ( if (
this.#textNodeIterator.currentNode === startNode && this.#textNodeIterator.currentNode === startNode &&
startOffset > 0 startOffset > 0 &&
startOffset < (startNode.nodeValue?.length || 0)
) { ) {
const newTextSpan = splitTextSpan(textSpan, startOffset); const newTextSpan = splitTextSpan(textSpan, startOffset);
setTextSpanStyles(newTextSpan, newStyles); setTextSpanStyles(newTextSpan, newStyles);
@@ -1999,14 +2021,15 @@ export class SelectionController extends EventTarget {
(this.#textNodeIterator.currentNode !== startNode && (this.#textNodeIterator.currentNode !== startNode &&
this.#textNodeIterator.currentNode !== endNode) || this.#textNodeIterator.currentNode !== endNode) ||
(this.#textNodeIterator.currentNode === endNode && (this.#textNodeIterator.currentNode === endNode &&
endOffset === endNode.nodeValue.length) endOffset === endNode.nodeValue?.length)
) { ) {
setTextSpanStyles(textSpan, newStyles); setTextSpanStyles(textSpan, newStyles);
// If we're at end node // If we're at end node
} else if ( } else if (
this.#textNodeIterator.currentNode === endNode && this.#textNodeIterator.currentNode === endNode &&
endOffset < endNode.nodeValue.length endOffset < endNode.nodeValue?.length &&
endOffset > 0
) { ) {
const newTextSpan = splitTextSpan(textSpan, endOffset); const newTextSpan = splitTextSpan(textSpan, endOffset);
setTextSpanStyles(textSpan, newStyles); setTextSpanStyles(textSpan, newStyles);

View File

@@ -278,9 +278,9 @@ describe("SelectionController", () => {
expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe( expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe(
"Hello", "Hello",
); );
expect( expect(textEditorMock.root.lastChild.firstChild.firstChild.nodeValue).toBe(
textEditorMock.root.lastChild.firstChild.firstChild.nodeValue, ", World!",
).toBe(", World!"); );
}); });
test("`insertPaste` should insert a paragraph from a pasted fragment (at middle)", () => { test("`insertPaste` should insert a paragraph from a pasted fragment (at middle)", () => {
@@ -292,7 +292,12 @@ describe("SelectionController", () => {
textEditorMock, textEditorMock,
selection, selection,
); );
focus(selection, textEditorMock, root.firstChild.firstChild.firstChild, "Lorem ".length); focus(
selection,
textEditorMock,
root.firstChild.firstChild.firstChild,
"Lorem ".length,
);
const paragraph = createParagraph([createTextSpan(new Text("ipsum "))]); const paragraph = createParagraph([createTextSpan(new Text("ipsum "))]);
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
fragment.append(paragraph); fragment.append(paragraph);
@@ -315,9 +320,9 @@ describe("SelectionController", () => {
expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe( expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe(
"Lorem ", "Lorem ",
); );
expect(textEditorMock.root.children.item(1).firstChild.firstChild.nodeValue).toBe( expect(
"ipsum ", textEditorMock.root.children.item(1).firstChild.firstChild.nodeValue,
); ).toBe("ipsum ");
expect(textEditorMock.root.lastChild.firstChild.firstChild.nodeValue).toBe( expect(textEditorMock.root.lastChild.firstChild.firstChild.nodeValue).toBe(
"dolor", "dolor",
); );
@@ -359,25 +364,21 @@ describe("SelectionController", () => {
expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe( expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe(
"Hello", "Hello",
); );
expect( expect(textEditorMock.root.lastChild.firstChild.firstChild.nodeValue).toBe(
textEditorMock.root.lastChild.firstChild.firstChild.nodeValue, ", World!",
).toBe(", World!"); );
}); });
test("`insertPaste` should insert a text span from a pasted fragment (at start)", () => { test("`insertPaste` should insert a text span from a pasted fragment (at start)", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithText(", World!"); const textEditorMock =
TextEditorMock.createTextEditorMockWithText(", World!");
const root = textEditorMock.root; const root = textEditorMock.root;
const selection = document.getSelection(); const selection = document.getSelection();
const selectionController = new SelectionController( const selectionController = new SelectionController(
textEditorMock, textEditorMock,
selection, selection,
); );
focus( focus(selection, textEditorMock, root.firstChild.firstChild.firstChild, 0);
selection,
textEditorMock,
root.firstChild.firstChild.firstChild,
0,
);
const paragraph = createParagraph([createTextSpan(new Text("Hello"))]); const paragraph = createParagraph([createTextSpan(new Text("Hello"))]);
paragraph.dataset.textSpan = "force"; paragraph.dataset.textSpan = "force";
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
@@ -415,7 +416,12 @@ describe("SelectionController", () => {
textEditorMock, textEditorMock,
selection, selection,
); );
focus(selection, textEditorMock, root.firstChild.firstChild.firstChild, "Lorem ".length); focus(
selection,
textEditorMock,
root.firstChild.firstChild.firstChild,
"Lorem ".length,
);
const paragraph = createParagraph([createTextSpan(new Text("ipsum "))]); const paragraph = createParagraph([createTextSpan(new Text("ipsum "))]);
paragraph.dataset.textSpan = "force"; paragraph.dataset.textSpan = "force";
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
@@ -439,9 +445,9 @@ describe("SelectionController", () => {
expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe( expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe(
"Lorem ", "Lorem ",
); );
expect(textEditorMock.root.firstChild.children.item(1).firstChild.nodeValue).toBe( expect(
"ipsum ", textEditorMock.root.firstChild.children.item(1).firstChild.nodeValue,
); ).toBe("ipsum ");
expect( expect(
textEditorMock.root.firstChild.children.item(2).firstChild.nodeValue, textEditorMock.root.firstChild.children.item(2).firstChild.nodeValue,
).toBe("dolor"); ).toBe("dolor");
@@ -461,9 +467,7 @@ describe("SelectionController", () => {
root.firstChild.firstChild.firstChild, root.firstChild.firstChild.firstChild,
"Hello".length, "Hello".length,
); );
const paragraph = createParagraph([ const paragraph = createParagraph([createTextSpan(new Text(", World!"))]);
createTextSpan(new Text(", World!"))
]);
paragraph.dataset.textSpan = "force"; paragraph.dataset.textSpan = "force";
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
fragment.append(paragraph); fragment.append(paragraph);
@@ -486,9 +490,9 @@ describe("SelectionController", () => {
expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe( expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe(
"Hello", "Hello",
); );
expect(textEditorMock.root.firstChild.children.item(1).firstChild.nodeValue).toBe( expect(
", World!", textEditorMock.root.firstChild.children.item(1).firstChild.nodeValue,
); ).toBe(", World!");
}); });
test("`removeBackwardText` should remove text in backward direction (backspace)", () => { test("`removeBackwardText` should remove text in backward direction (backspace)", () => {

View File

@@ -0,0 +1,113 @@
export class StyleDeclaration {
static Property = class Property {
static NULL = '["~#\'",null]';
static Default = new Property("", "", "");
name;
value = "";
priority = "";
constructor(name, value = "", priority = "") {
this.name = name;
this.value = value ?? "";
this.priority = priority ?? "";
}
};
#items = new Map();
get cssFloat() {
throw new Error("Not implemented");
}
get cssText() {
throw new Error("Not implemented");
}
get parentRule() {
throw new Error("Not implemented");
}
get length() {
return this.#items.size;
}
#getProperty(name) {
return this.#items.get(name) ?? StyleDeclaration.Property.Default;
}
getPropertyPriority(name) {
const { priority } = this.#getProperty(name);
return priority ?? "";
}
getPropertyValue(name) {
const { value } = this.#getProperty(name);
return value ?? "";
}
item(index) {
return Array.from(this.#items).at(index).name;
}
removeProperty(name) {
const value = this.getPropertyValue(name);
this.#items.delete(name);
return value;
}
setProperty(name, value, priority) {
this.#items.set(name, new StyleDeclaration.Property(name, value, priority));
}
/** Non compatible methods */
#isQuotedValue(a, b) {
if (a.startsWith('"') && b.startsWith('"')) {
return a === b;
} else if (a.startsWith('"') && !b.startsWith('"')) {
return a.slice(1, -1) === b;
} else if (!a.startsWith('"') && b.startsWith('"')) {
return a === b.slice(1, -1);
}
return a === b;
}
mergeProperty(name, value) {
const currentValue = this.getPropertyValue(name);
if (this.#isQuotedValue(currentValue, value)) {
return this.setProperty(name, value);
} else if (
currentValue === "" &&
value === StyleDeclaration.Property.NULL
) {
return this.setProperty(name, value);
} else if (currentValue === "" && ["initial", "none"].includes(value)) {
return this.setProperty(name, value);
} else if (currentValue !== value && name === "--fills") {
return this.setProperty(name, value);
} else if (currentValue !== value) {
return this.setProperty(name, "mixed");
}
}
fromCSSStyleDeclaration(cssStyleDeclaration) {
for (let index = 0; index < cssStyleDeclaration.length; index++) {
const name = cssStyleDeclaration.item(index);
const value = cssStyleDeclaration.getPropertyValue(name);
const priority = cssStyleDeclaration.getPropertyPriority(name);
this.setProperty(name, value, priority);
}
}
toObject() {
return Object.fromEntries(
Array.from(this.#items.entries(), ([name, property]) => [
name,
property.value,
]),
);
}
}
export default StyleDeclaration;

View File

@@ -0,0 +1,32 @@
import { describe, test, expect } from "vitest";
import { StyleDeclaration } from "./StyleDeclaration.js";
describe("StyleDeclaration", () => {
test("Create a new StyleDeclaration", () => {
const styleDeclaration = new StyleDeclaration();
expect(styleDeclaration).toBeInstanceOf(StyleDeclaration);
});
test("Uninmplemented getters should throw", () => {
expect(() => styleDeclaration.cssFloat).toThrow();
expect(() => styleDeclaration.cssText).toThrow();
expect(() => styleDeclaration.parentRule).toThrow();
});
test("Set property", () => {
const styleDeclaration = new StyleDeclaration();
styleDeclaration.setProperty("line-height", "1.2");
expect(styleDeclaration.getPropertyValue("line-height")).toBe("1.2");
expect(styleDeclaration.getPropertyPriority("line-height")).toBe("");
});
test("Remove property", () => {
const styleDeclaration = new StyleDeclaration();
styleDeclaration.setProperty("line-height", "1.2");
expect(styleDeclaration.getPropertyValue("line-height")).toBe("1.2");
expect(styleDeclaration.getPropertyPriority("line-height")).toBe("");
styleDeclaration.removeProperty("line-height");
expect(styleDeclaration.getPropertyValue("line-height")).toBe("");
expect(styleDeclaration.getPropertyPriority("line-height")).toBe("");
});
});

View File

@@ -43,33 +43,38 @@ export class SelectionControllerDebug {
this.#elements.isParagraphStart.checked = this.#elements.isParagraphStart.checked =
selectionController.isParagraphStart; selectionController.isParagraphStart;
this.#elements.isParagraphEnd.checked = selectionController.isParagraphEnd; this.#elements.isParagraphEnd.checked = selectionController.isParagraphEnd;
this.#elements.isTextSpanStart.checked = selectionController.isTextSpanStart; this.#elements.isTextSpanStart.checked =
selectionController.isTextSpanStart;
this.#elements.isTextSpanEnd.checked = selectionController.isTextSpanEnd; this.#elements.isTextSpanEnd.checked = selectionController.isTextSpanEnd;
this.#elements.isTextAnchor.checked = selectionController.isTextAnchor; this.#elements.isTextAnchor.checked = selectionController.isTextAnchor;
this.#elements.isTextFocus.checked = selectionController.isTextFocus; this.#elements.isTextFocus.checked = selectionController.isTextFocus;
this.#elements.focusNode.value = this.getNodeDescription( this.#elements.focusNode.value = this.getNodeDescription(
selectionController.focusNode, selectionController.focusNode,
selectionController.focusOffset selectionController.focusOffset,
); );
this.#elements.focusOffset.value = selectionController.focusOffset; this.#elements.focusOffset.value = selectionController.focusOffset;
this.#elements.anchorNode.value = this.getNodeDescription( this.#elements.anchorNode.value = this.getNodeDescription(
selectionController.anchorNode, selectionController.anchorNode,
selectionController.anchorOffset selectionController.anchorOffset,
); );
this.#elements.anchorOffset.value = selectionController.anchorOffset; this.#elements.anchorOffset.value = selectionController.anchorOffset;
this.#elements.focusTextSpan.value = this.getNodeDescription( this.#elements.focusTextSpan.value = this.getNodeDescription(
selectionController.focusTextSpan selectionController.focusTextSpan,
); );
this.#elements.anchorTextSpan.value = this.getNodeDescription( this.#elements.anchorTextSpan.value = this.getNodeDescription(
selectionController.anchorTextSpan selectionController.anchorTextSpan,
); );
this.#elements.focusParagraph.value = this.getNodeDescription( this.#elements.focusParagraph.value = this.getNodeDescription(
selectionController.focusParagraph selectionController.focusParagraph,
); );
this.#elements.anchorParagraph.value = this.getNodeDescription( this.#elements.anchorParagraph.value = this.getNodeDescription(
selectionController.anchorParagraph selectionController.anchorParagraph,
);
this.#elements.startContainer.value = this.getNodeDescription(
selectionController.startContainer,
);
this.#elements.endContainer.value = this.getNodeDescription(
selectionController.endContainer,
); );
this.#elements.startContainer.value = this.getNodeDescription(selectionController.startContainer);
this.#elements.endContainer.value = this.getNodeDescription(selectionController.endContainer);
} }
} }

View File

@@ -39,10 +39,7 @@ export class Point {
} }
polar(angle, length = 1.0) { polar(angle, length = 1.0) {
return this.set( return this.set(Math.cos(angle) * length, Math.sin(angle) * length);
Math.cos(angle) * length,
Math.sin(angle) * length
);
} }
add({ x, y }) { add({ x, y }) {
@@ -119,10 +116,7 @@ export class Point {
export class Rect { export class Rect {
static create(x, y, width, height) { static create(x, y, width, height) {
return new Rect( return new Rect(new Point(width, height), new Point(x, y));
new Point(width, height),
new Point(x, y),
);
} }
#size; #size;
@@ -228,10 +222,7 @@ export class Rect {
} }
clone() { clone() {
return new Rect( return new Rect(this.#size.clone(), this.#position.clone());
this.#size.clone(),
this.#position.clone(),
);
} }
toFixed(fractionDigits = 0) { toFixed(fractionDigits = 0) {

View File

@@ -82,13 +82,13 @@ export class Shape {
} }
get rotation() { get rotation() {
return this.#rotation return this.#rotation;
} }
set rotation(newRotation) { set rotation(newRotation) {
if (!Number.isFinite(newRotation)) { if (!Number.isFinite(newRotation)) {
throw new TypeError('Invalid rotation') throw new TypeError("Invalid rotation");
} }
this.#rotation = newRotation this.#rotation = newRotation;
} }
} }

View File

@@ -6,8 +6,7 @@ export function fromStyle(style) {
const entry = Object.entries(this).find(([name, value]) => const entry = Object.entries(this).find(([name, value]) =>
name === fromStyleValue(style) ? value : 0, name === fromStyleValue(style) ? value : 0,
); );
if (!entry) if (!entry) return;
return;
const [name] = entry; const [name] = entry;
return name; return name;

View File

@@ -1,4 +1,4 @@
import { Point } from './geom'; import { Point } from "./geom";
export class Viewport { export class Viewport {
#zoom; #zoom;
@@ -38,7 +38,7 @@ export class Viewport {
} }
pan(dx, dy) { pan(dx, dy) {
this.#position.x += dx / this.#zoom this.#position.x += dx / this.#zoom;
this.#position.y += dy / this.#zoom this.#position.y += dy / this.#zoom;
} }
} }

View File

@@ -1,6 +1,9 @@
import { createRoot } from "../editor/content/dom/Root.js"; import { createRoot } from "../editor/content/dom/Root.js";
import { createParagraph } from "../editor/content/dom/Paragraph.js"; import { createParagraph } from "../editor/content/dom/Paragraph.js";
import { createEmptyTextSpan, createTextSpan } from "../editor/content/dom/TextSpan.js"; import {
createEmptyTextSpan,
createTextSpan,
} from "../editor/content/dom/TextSpan.js";
import { createLineBreak } from "../editor/content/dom/LineBreak.js"; import { createLineBreak } from "../editor/content/dom/LineBreak.js";
export class TextEditorMock extends EventTarget { export class TextEditorMock extends EventTarget {
@@ -38,14 +41,14 @@ export class TextEditorMock extends EventTarget {
static createTextEditorMockWithRoot(root) { static createTextEditorMockWithRoot(root) {
const container = TextEditorMock.getTemplate(); const container = TextEditorMock.getTemplate();
const selectionImposterElement = container.querySelector( const selectionImposterElement = container.querySelector(
".text-editor-selection-imposter" ".text-editor-selection-imposter",
); );
const textEditorMock = new TextEditorMock( const textEditorMock = new TextEditorMock(
container.querySelector(".text-editor-content"), container.querySelector(".text-editor-content"),
{ {
root, root,
selectionImposterElement, selectionImposterElement,
} },
); );
return textEditorMock; return textEditorMock;
} }
@@ -86,8 +89,8 @@ export class TextEditorMock extends EventTarget {
return this.createTextEditorMockWithParagraphs([ return this.createTextEditorMockWithParagraphs([
createParagraph([ createParagraph([
text.length === 0 text.length === 0
? createEmptyTextSpan() ? createEmptyTextSpan()
: createTextSpan(new Text(text)) : createTextSpan(new Text(text)),
]), ]),
]); ]);
} }
@@ -100,7 +103,9 @@ export class TextEditorMock extends EventTarget {
* @returns * @returns
*/ */
static createTextEditorMockWithParagraph(textSpans) { static createTextEditorMockWithParagraph(textSpans) {
return this.createTextEditorMockWithParagraphs([createParagraph(textSpans)]); return this.createTextEditorMockWithParagraphs([
createParagraph(textSpans),
]);
} }
#element = null; #element = null;

View File

@@ -1,30 +1,28 @@
import path from "node:path"; import path from "node:path";
import fs from 'node:fs/promises'; import fs from "node:fs/promises";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import { coverageConfigDefaults } from "vitest/config"; import { coverageConfigDefaults } from "vitest/config";
async function waitFor(timeInMillis) { async function waitFor(timeInMillis) {
return new Promise(resolve => return new Promise((resolve) => setTimeout((_) => resolve(), timeInMillis));
setTimeout(_ => resolve(), timeInMillis)
);
} }
const wasmWatcherPlugin = (options = {}) => { const wasmWatcherPlugin = (options = {}) => {
return { return {
name: "vite-wasm-watcher-plugin", name: "vite-wasm-watcher-plugin",
configureServer(server) { configureServer(server) {
server.watcher.add("../resources/public/js/render_wasm.wasm") server.watcher.add("../resources/public/js/render_wasm.wasm");
server.watcher.add("../resources/public/js/render_wasm.js") server.watcher.add("../resources/public/js/render_wasm.js");
server.watcher.on("change", async (file) => { server.watcher.on("change", async (file) => {
if (file.includes("../resources/")) { if (file.includes("../resources/")) {
// If we copy the files immediately, we end // If we copy the files immediately, we end
// up with an empty .js file (I don't know why). // up with an empty .js file (I don't know why).
await waitFor(100) await waitFor(100);
// copy files. // copy files.
await fs.copyFile( await fs.copyFile(
path.resolve(file), path.resolve(file),
path.resolve('./src/wasm/', path.basename(file)) path.resolve("./src/wasm/", path.basename(file)),
) );
console.log(`${file} changed`); console.log(`${file} changed`);
} }
}); });
@@ -49,9 +47,7 @@ const wasmWatcherPlugin = (options = {}) => {
}; };
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [wasmWatcherPlugin()],
wasmWatcherPlugin()
],
root: "./src", root: "./src",
resolve: { resolve: {
alias: { alias: {

View File

Binary file not shown.

View File

@@ -163,6 +163,19 @@ pub extern "C" fn render_sync() {
pub extern "C" fn render_sync_shape(a: u32, b: u32, c: u32, d: u32) { pub extern "C" fn render_sync_shape(a: u32, b: u32, c: u32, d: u32) {
with_state_mut!(state, { with_state_mut!(state, {
let id = uuid_from_u32_quartet(a, b, c, d); let id = uuid_from_u32_quartet(a, b, c, d);
state.use_shape(id);
// look for an existing root shape, and create it if missing
let mut was_root_missing = false;
if !state.shapes.has(&Uuid::nil()) {
state.shapes.add_shape(Uuid::nil());
was_root_missing = true;
}
if was_root_missing {
state.set_parent_for_current_shape(Uuid::nil());
}
state.rebuild_tiles_from(Some(&id)); state.rebuild_tiles_from(Some(&id));
state state
.render_sync_shape(&id, performance::get_time()) .render_sync_shape(&id, performance::get_time())
@@ -217,20 +230,70 @@ pub extern "C" fn resize_viewbox(width: i32, height: i32) {
#[no_mangle] #[no_mangle]
pub extern "C" fn set_view(zoom: f32, x: f32, y: f32) { pub extern "C" fn set_view(zoom: f32, x: f32, y: f32) {
with_state_mut!(state, { with_state_mut!(state, {
performance::begin_measure!("set_view");
let render_state = state.render_state_mut(); let render_state = state.render_state_mut();
render_state.set_view(zoom, x, y); render_state.set_view(zoom, x, y);
performance::end_measure!("set_view");
});
}
#[cfg(feature = "profile-macros")]
static mut VIEW_INTERACTION_START: i32 = 0;
#[no_mangle]
pub extern "C" fn set_view_start() {
with_state_mut!(state, {
#[cfg(feature = "profile-macros")]
unsafe {
VIEW_INTERACTION_START = performance::get_time();
}
performance::begin_measure!("set_view_start");
state.render_state.options.set_fast_mode(true);
performance::end_measure!("set_view_start");
}); });
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn set_view_end() { pub extern "C" fn set_view_end() {
with_state_mut!(state, { with_state_mut!(state, {
// We can have renders in progress let _end_start = performance::begin_timed_log!("set_view_end");
performance::begin_measure!("set_view_end");
state.render_state.options.set_fast_mode(false);
state.render_state.cancel_animation_frame(); state.render_state.cancel_animation_frame();
if state.render_state.options.is_profile_rebuild_tiles() {
state.rebuild_tiles(); let zoom_changed = state.render_state.zoom_changed();
// Only rebuild tile indices when zoom has changed.
// During pan-only operations, shapes stay in the same tiles
// because tile_size = 1/scale * TILE_SIZE (depends only on zoom).
if zoom_changed {
let _rebuild_start = performance::begin_timed_log!("rebuild_tiles");
performance::begin_measure!("set_view_end::rebuild_tiles");
if state.render_state.options.is_profile_rebuild_tiles() {
state.rebuild_tiles();
} else {
state.rebuild_tiles_shallow();
}
performance::end_measure!("set_view_end::rebuild_tiles");
performance::end_timed_log!("rebuild_tiles", _rebuild_start);
} else { } else {
state.rebuild_tiles_shallow(); // During pan, we only clear the tile index without
// invalidating cached textures, which is more efficient.
let _clear_start = performance::begin_timed_log!("clear_tile_index");
performance::begin_measure!("set_view_end::clear_tile_index");
state.clear_tile_index();
performance::end_measure!("set_view_end::clear_tile_index");
performance::end_timed_log!("clear_tile_index", _clear_start);
}
performance::end_measure!("set_view_end");
performance::end_timed_log!("set_view_end", _end_start);
#[cfg(feature = "profile-macros")]
{
let total_time = performance::get_time() - unsafe { VIEW_INTERACTION_START };
performance::console_log!(
"[PERF] view_interaction (zoom_changed={}): {}ms",
zoom_changed,
total_time
);
} }
}); });
} }
@@ -248,7 +311,7 @@ pub extern "C" fn set_focus_mode() {
let entries: Vec<Uuid> = bytes let entries: Vec<Uuid> = bytes
.chunks(size_of::<<Uuid as SerializableResult>::BytesType>()) .chunks(size_of::<<Uuid as SerializableResult>::BytesType>())
.map(|data| Uuid::from_bytes(data.try_into().unwrap())) .map(|data| Uuid::try_from(data).unwrap())
.collect(); .collect();
with_state_mut!(state, { with_state_mut!(state, {
@@ -330,6 +393,10 @@ fn set_children_set(entries: Vec<Uuid>) {
parent_id = Some(shape.id); parent_id = Some(shape.id);
(_, deleted) = shape.compute_children_differences(&entries); (_, deleted) = shape.compute_children_differences(&entries);
shape.children = entries.clone(); shape.children = entries.clone();
for id in entries {
state.touch_shape(id);
}
}); });
with_state_mut!(state, { with_state_mut!(state, {
@@ -339,6 +406,7 @@ fn set_children_set(entries: Vec<Uuid>) {
for id in deleted { for id in deleted {
state.delete_shape_children(parent_id, id); state.delete_shape_children(parent_id, id);
state.touch_shape(id);
} }
}); });
} }
@@ -463,7 +531,7 @@ pub extern "C" fn set_children() {
let entries: Vec<Uuid> = bytes let entries: Vec<Uuid> = bytes
.chunks(size_of::<<Uuid as SerializableResult>::BytesType>()) .chunks(size_of::<<Uuid as SerializableResult>::BytesType>())
.map(|data| Uuid::from_bytes(data.try_into().unwrap())) .map(|data| Uuid::try_from(data).unwrap())
.collect(); .collect();
set_children_set(entries); set_children_set(entries);
@@ -619,7 +687,7 @@ pub extern "C" fn propagate_modifiers(pixel_precision: bool) -> *mut u8 {
let entries: Vec<_> = bytes let entries: Vec<_> = bytes
.chunks(size_of::<<TransformEntry as SerializableResult>::BytesType>()) .chunks(size_of::<<TransformEntry as SerializableResult>::BytesType>())
.map(|data| TransformEntry::from_bytes(data.try_into().unwrap())) .map(|data| TransformEntry::try_from(data).unwrap())
.collect(); .collect();
with_state!(state, { with_state!(state, {
@@ -634,7 +702,7 @@ pub extern "C" fn set_modifiers() {
let entries: Vec<_> = bytes let entries: Vec<_> = bytes
.chunks(size_of::<<TransformEntry as SerializableResult>::BytesType>()) .chunks(size_of::<<TransformEntry as SerializableResult>::BytesType>())
.map(|data| TransformEntry::from_bytes(data.try_into().unwrap())) .map(|data| TransformEntry::try_from(data).unwrap())
.collect(); .collect();
let mut modifiers = HashMap::new(); let mut modifiers = HashMap::new();
@@ -650,6 +718,26 @@ pub extern "C" fn set_modifiers() {
}); });
} }
#[no_mangle]
pub extern "C" fn start_temp_objects() {
unsafe {
#[allow(static_mut_refs)]
let mut state = STATE.take().expect("Got an invalid state pointer");
state = Box::new(state.start_temp_objects());
STATE = Some(state);
}
}
#[no_mangle]
pub extern "C" fn end_temp_objects() {
unsafe {
#[allow(static_mut_refs)]
let mut state = STATE.take().expect("Got an invalid state pointer");
state = Box::new(state.end_temp_objects());
STATE = Some(state);
}
}
fn main() { fn main() {
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
init_gl!(); init_gl!();

View File

@@ -57,10 +57,8 @@ pub fn bytes_or_empty() -> Vec<u8> {
guard.take().unwrap_or_default() guard.take().unwrap_or_default()
} }
pub trait SerializableResult { pub trait SerializableResult: From<Self::BytesType> + Into<Self::BytesType> {
type BytesType; type BytesType;
fn from_bytes(bytes: Self::BytesType) -> Self;
fn as_bytes(&self) -> Self::BytesType;
fn clone_to_slice(&self, slice: &mut [u8]); fn clone_to_slice(&self, slice: &mut [u8]);
} }

View File

@@ -1,2 +1,3 @@
pub const DEBUG_VISIBLE: u32 = 0x01; pub const DEBUG_VISIBLE: u32 = 0x01;
pub const PROFILE_REBUILD_TILES: u32 = 0x02; pub const PROFILE_REBUILD_TILES: u32 = 0x02;
pub const FAST_MODE: u32 = 0x04;

View File

@@ -1,7 +1,3 @@
#[allow(unused_imports)]
#[cfg(target_arch = "wasm32")]
use crate::get_now;
#[allow(dead_code)] #[allow(dead_code)]
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
pub fn get_time() -> i32 { pub fn get_time() -> i32 {
@@ -15,6 +11,68 @@ pub fn get_time() -> i32 {
now.elapsed().as_millis() as i32 now.elapsed().as_millis() as i32
} }
/// Log a message to the browser console (only when profile-macros feature is enabled)
#[macro_export]
macro_rules! console_log {
($($arg:tt)*) => {
#[cfg(all(feature = "profile-macros", target_arch = "wasm32"))]
{
use $crate::run_script;
run_script!(format!("console.log('{}')", format!($($arg)*)));
}
#[cfg(all(feature = "profile-macros", not(target_arch = "wasm32")))]
{
println!($($arg)*);
}
};
}
/// Begin a timed section with logging (only when profile-macros feature is enabled)
/// Returns the start time - store it and pass to end_timed_log!
#[macro_export]
macro_rules! begin_timed_log {
($name:expr) => {{
#[cfg(feature = "profile-macros")]
{
$crate::performance::get_time()
}
#[cfg(not(feature = "profile-macros"))]
{
0.0
}
}};
}
/// End a timed section and log the duration (only when profile-macros feature is enabled)
#[macro_export]
macro_rules! end_timed_log {
($name:expr, $start:expr) => {{
#[cfg(all(feature = "profile-macros", target_arch = "wasm32"))]
{
let duration = $crate::performance::get_time() - $start;
use $crate::run_script;
run_script!(format!(
"console.log('[PERF] {}: {:.2}ms')",
$name, duration
));
}
#[cfg(all(feature = "profile-macros", not(target_arch = "wasm32")))]
{
let duration = $crate::performance::get_time() - $start;
println!("[PERF] {}: {:.2}ms", $name, duration);
}
}};
}
#[allow(unused_imports)]
pub use console_log;
#[allow(unused_imports)]
pub use begin_timed_log;
#[allow(unused_imports)]
pub use end_timed_log;
#[macro_export] #[macro_export]
macro_rules! mark { macro_rules! mark {
($name:expr) => { ($name:expr) => {

View File

@@ -9,7 +9,8 @@ mod options;
mod shadows; mod shadows;
mod strokes; mod strokes;
mod surfaces; mod surfaces;
mod text; pub mod text;
mod ui; mod ui;
use skia_safe::{self as skia, Matrix, RRect, Rect}; use skia_safe::{self as skia, Matrix, RRect, Rect};
@@ -34,9 +35,9 @@ pub use fonts::*;
pub use images::*; pub use images::*;
// This is the extra are used for tile rendering. // This is the extra are used for tile rendering.
const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 1; const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 2;
const MAX_BLOCKING_TIME_MS: i32 = 32; const MAX_BLOCKING_TIME_MS: i32 = 32;
const NODE_BATCH_THRESHOLD: i32 = 10; const NODE_BATCH_THRESHOLD: i32 = 3;
type ClipStack = Vec<(Rect, Option<Corners>, Matrix)>; type ClipStack = Vec<(Rect, Option<Corners>, Matrix)>;
@@ -141,7 +142,20 @@ impl NodeRenderState {
match &element.shape_type { match &element.shape_type {
Type::Frame(_) => { Type::Frame(_) => {
let bounds = element.get_selrect_shadow_bounds(shadow); let mut bounds = element.get_selrect_shadow_bounds(shadow);
let blur_inset = (shadow.blur * 2.).max(0.0);
if blur_inset > 0.0 {
let max_inset_x = (bounds.width() * 0.5).max(0.0);
let max_inset_y = (bounds.height() * 0.5).max(0.0);
// Clamp the inset so we never shrink more than half of the width/height;
// otherwise the rect could end up inverted on small frames.
let inset_x = blur_inset.min(max_inset_x);
let inset_y = blur_inset.min(max_inset_y);
if inset_x > 0.0 || inset_y > 0.0 {
bounds.inset((inset_x, inset_y));
}
}
let mut transform = element.transform; let mut transform = element.transform;
transform.post_translate(element.center()); transform.post_translate(element.center());
transform.pre_translate(-element.center()); transform.pre_translate(-element.center());
@@ -915,6 +929,8 @@ impl RenderState {
} }
pub fn render_from_cache(&mut self, shapes: ShapesPoolRef) { pub fn render_from_cache(&mut self, shapes: ShapesPoolRef) {
let _start = performance::begin_timed_log!("render_from_cache");
performance::begin_measure!("render_from_cache");
let scale = self.get_cached_scale(); let scale = self.get_cached_scale();
if let Some(snapshot) = &self.cached_target_snapshot { if let Some(snapshot) = &self.cached_target_snapshot {
let canvas = self.surfaces.canvas(SurfaceId::Target); let canvas = self.surfaces.canvas(SurfaceId::Target);
@@ -952,6 +968,8 @@ impl RenderState {
self.flush_and_submit(); self.flush_and_submit();
} }
performance::end_measure!("render_from_cache");
performance::end_timed_log!("render_from_cache", _start);
} }
pub fn start_render_loop( pub fn start_render_loop(
@@ -961,6 +979,7 @@ impl RenderState {
timestamp: i32, timestamp: i32,
sync_render: bool, sync_render: bool,
) -> Result<(), String> { ) -> Result<(), String> {
let _start = performance::begin_timed_log!("start_render_loop");
let scale = self.get_scale(); let scale = self.get_scale();
self.tile_viewbox.update(self.viewbox, scale); self.tile_viewbox.update(self.viewbox, scale);
@@ -980,21 +999,24 @@ impl RenderState {
let viewbox_cache_size = get_cache_size(self.viewbox, scale); let viewbox_cache_size = get_cache_size(self.viewbox, scale);
let cached_viewbox_cache_size = get_cache_size(self.cached_viewbox, scale); let cached_viewbox_cache_size = get_cache_size(self.cached_viewbox, scale);
if viewbox_cache_size != cached_viewbox_cache_size { // Only resize cache if the new size is larger than the cached size
self.surfaces.resize_cache( // This avoids unnecessary surface recreations when the cache size decreases
&mut self.gpu_state, if viewbox_cache_size.width > cached_viewbox_cache_size.width
viewbox_cache_size, || viewbox_cache_size.height > cached_viewbox_cache_size.height
VIEWPORT_INTEREST_AREA_THRESHOLD, {
); self.surfaces
.resize_cache(viewbox_cache_size, VIEWPORT_INTEREST_AREA_THRESHOLD);
} }
// FIXME - review debug // FIXME - review debug
// debug::render_debug_tiles_for_viewbox(self); // debug::render_debug_tiles_for_viewbox(self);
let _tile_start = performance::begin_timed_log!("tile_cache_update");
performance::begin_measure!("tile_cache"); performance::begin_measure!("tile_cache");
self.pending_tiles self.pending_tiles
.update(&self.tile_viewbox, &self.surfaces); .update(&self.tile_viewbox, &self.surfaces);
performance::end_measure!("tile_cache"); performance::end_measure!("tile_cache");
performance::end_timed_log!("tile_cache_update", _tile_start);
self.pending_nodes.clear(); self.pending_nodes.clear();
if self.pending_nodes.capacity() < tree.len() { if self.pending_nodes.capacity() < tree.len() {
@@ -1018,6 +1040,7 @@ impl RenderState {
} }
performance::end_measure!("start_render_loop"); performance::end_measure!("start_render_loop");
performance::end_timed_log!("start_render_loop", _start);
Ok(()) Ok(())
} }
@@ -1466,8 +1489,11 @@ impl RenderState {
.surfaces .surfaces
.get_render_context_translation(self.render_area, scale); .get_render_context_translation(self.render_area, scale);
// Skip expensive drop shadow rendering in fast mode (during pan/zoom)
let skip_shadows = self.options.is_fast_mode();
// For text shapes, render drop shadow using text rendering logic // For text shapes, render drop shadow using text rendering logic
if !matches!(element.shape_type, Type::Text(_)) { if !skip_shadows && !matches!(element.shape_type, Type::Text(_)) {
// Shadow rendering technique: Two-pass approach for proper opacity handling // Shadow rendering technique: Two-pass approach for proper opacity handling
// //
// The shadow rendering uses a two-pass technique to ensure that overlapping // The shadow rendering uses a two-pass technique to ensure that overlapping
@@ -1721,6 +1747,7 @@ impl RenderState {
allow_stop: bool, allow_stop: bool,
) -> Result<(), String> { ) -> Result<(), String> {
let mut should_stop = false; let mut should_stop = false;
while !should_stop { while !should_stop {
if let Some(current_tile) = self.current_tile { if let Some(current_tile) = self.current_tile {
if self.surfaces.has_cached_tile_surface(current_tile) { if self.surfaces.has_cached_tile_surface(current_tile) {
@@ -1794,17 +1821,21 @@ impl RenderState {
if !self.surfaces.has_cached_tile_surface(next_tile) { if !self.surfaces.has_cached_tile_surface(next_tile) {
if let Some(ids) = self.tiles.get_shapes_at(next_tile) { if let Some(ids) = self.tiles.get_shapes_at(next_tile) {
let root_ids_map: std::collections::HashMap<Uuid, usize> = root_ids
.iter()
.enumerate()
.map(|(i, id)| (*id, i))
.collect();
// We only need first level shapes // We only need first level shapes
let mut valid_ids: Vec<Uuid> = ids let mut valid_ids: Vec<Uuid> = ids
.iter() .iter()
.filter(|id| root_ids.contains(id)) .filter(|id| root_ids_map.contains_key(id))
.copied() .copied()
.collect(); .collect();
// These shapes for the tile should be ordered as they are in the parent node // These shapes for the tile should be ordered as they are in the parent node
valid_ids.sort_by_key(|id| { valid_ids.sort_by_key(|id| root_ids_map.get(id).unwrap_or(&usize::MAX));
root_ids.iter().position(|x| x == id).unwrap_or(usize::MAX)
});
self.pending_nodes.extend(valid_ids.into_iter().map(|id| { self.pending_nodes.extend(valid_ids.into_iter().map(|id| {
NodeRenderState { NodeRenderState {
@@ -1821,6 +1852,7 @@ impl RenderState {
should_stop = true; should_stop = true;
} }
} }
self.render_in_progress = false; self.render_in_progress = false;
self.surfaces.gc(); self.surfaces.gc();
@@ -1930,6 +1962,17 @@ impl RenderState {
performance::end_measure!("rebuild_tiles_shallow"); performance::end_measure!("rebuild_tiles_shallow");
} }
/// Clears the tile index without invalidating cached tile textures.
/// This is useful when tile positions don't change (e.g., during pan operations)
/// but the tile index needs to be synchronized. The cached tile textures remain
/// valid since they don't depend on the current view position, only on zoom level.
/// This is much more efficient than clearing the entire cache surface.
pub fn clear_tile_index(&mut self) {
performance::begin_measure!("clear_tile_index");
self.surfaces.clear_tiles();
performance::end_measure!("clear_tile_index");
}
pub fn rebuild_tiles_from(&mut self, tree: ShapesPoolRef, base_id: Option<&Uuid>) { pub fn rebuild_tiles_from(&mut self, tree: ShapesPoolRef, base_id: Option<&Uuid>) {
performance::begin_measure!("rebuild_tiles"); performance::begin_measure!("rebuild_tiles");
@@ -2035,6 +2078,10 @@ impl RenderState {
self.cached_viewbox.zoom() * self.options.dpr() self.cached_viewbox.zoom() * self.options.dpr()
} }
pub fn zoom_changed(&self) -> bool {
(self.viewbox.zoom - self.cached_viewbox.zoom).abs() > f32::EPSILON
}
pub fn mark_touched(&mut self, uuid: Uuid) { pub fn mark_touched(&mut self, uuid: Uuid) {
self.touched_ids.insert(uuid); self.touched_ids.insert(uuid);
} }

View File

@@ -15,6 +15,19 @@ impl RenderOptions {
self.flags & options::PROFILE_REBUILD_TILES == options::PROFILE_REBUILD_TILES self.flags & options::PROFILE_REBUILD_TILES == options::PROFILE_REBUILD_TILES
} }
/// Use fast mode to enable / disable expensive operations
pub fn is_fast_mode(&self) -> bool {
self.flags & options::FAST_MODE == options::FAST_MODE
}
pub fn set_fast_mode(&mut self, enabled: bool) {
if enabled {
self.flags |= options::FAST_MODE;
} else {
self.flags &= !options::FAST_MODE;
}
}
pub fn dpr(&self) -> f32 { pub fn dpr(&self) -> f32 {
self.dpr.unwrap_or(1.0) self.dpr.unwrap_or(1.0)
} }

View File

@@ -8,8 +8,8 @@ use super::{gpu_state::GpuState, tiles::Tile, tiles::TileViewbox, tiles::TILE_SI
use base64::{engine::general_purpose, Engine as _}; use base64::{engine::general_purpose, Engine as _};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
const TEXTURES_CACHE_CAPACITY: usize = 512; const TEXTURES_CACHE_CAPACITY: usize = 1024;
const TEXTURES_BATCH_DELETE: usize = 32; const TEXTURES_BATCH_DELETE: usize = 256;
// This is the amount of extra space we're going to give to all the surfaces to render shapes. // This is the amount of extra space we're going to give to all the surfaces to render shapes.
// If it's too big it could affect performance. // If it's too big it could affect performance.
const TILE_SIZE_MULTIPLIER: i32 = 2; const TILE_SIZE_MULTIPLIER: i32 = 2;
@@ -108,6 +108,10 @@ impl Surfaces {
} }
} }
pub fn clear_tiles(&mut self) {
self.tiles.clear();
}
pub fn resize(&mut self, gpu_state: &mut GpuState, new_width: i32, new_height: i32) { pub fn resize(&mut self, gpu_state: &mut GpuState, new_width: i32, new_height: i32) {
self.reset_from_target(gpu_state.create_target_surface(new_width, new_height)); self.reset_from_target(gpu_state.create_target_surface(new_width, new_height));
} }
@@ -248,13 +252,8 @@ impl Surfaces {
// The rest are tile size surfaces // The rest are tile size surfaces
} }
pub fn resize_cache( pub fn resize_cache(&mut self, cache_dims: skia::ISize, interest_area_threshold: i32) {
&mut self, self.cache = self.target.new_surface_with_dimensions(cache_dims).unwrap();
gpu_state: &mut GpuState,
cache_dims: skia::ISize,
interest_area_threshold: i32,
) {
self.cache = gpu_state.create_surface_with_isize("cache".to_string(), cache_dims);
self.cache.canvas().reset_matrix(); self.cache.canvas().reset_matrix();
self.cache.canvas().translate(( self.cache.canvas().translate((
(interest_area_threshold as f32 * TILE_SIZE), (interest_area_threshold as f32 * TILE_SIZE),

View File

@@ -2,18 +2,15 @@ use super::{filters, RenderState, Shape, SurfaceId};
use crate::{ use crate::{
math::Rect, math::Rect,
shapes::{ shapes::{
merge_fills, set_paint_fill, ParagraphBuilderGroup, Stroke, StrokeKind, TextContent, calculate_position_data, calculate_text_layout_data, merge_fills, set_paint_fill,
VerticalAlign, ParagraphBuilderGroup, Stroke, StrokeKind, TextContent,
}, },
utils::{get_fallback_fonts, get_font_collection}, utils::{get_fallback_fonts, get_font_collection},
}; };
use skia_safe::{ use skia_safe::{
self as skia, self as skia,
canvas::SaveLayerRec, canvas::SaveLayerRec,
textlayout::{ textlayout::{ParagraphBuilder, StyleMetrics, TextDecoration, TextStyle},
LineMetrics, Paragraph, ParagraphBuilder, RectHeightStyle, RectWidthStyle, StyleMetrics,
TextDecoration, TextStyle,
},
Canvas, ImageFilter, Paint, Path, Canvas, ImageFilter, Paint, Path,
}; };
@@ -241,46 +238,24 @@ fn draw_text(
paragraph_builder_groups: &mut [Vec<ParagraphBuilder>], paragraph_builder_groups: &mut [Vec<ParagraphBuilder>],
) { ) {
let text_content = shape.get_text_content(); let text_content = shape.get_text_content();
let selrect_width = shape.selrect().width(); let layout_info =
let text_width = text_content.get_width(selrect_width); calculate_text_layout_data(shape, text_content, paragraph_builder_groups, true);
let text_height = text_content.get_height(selrect_width);
let selrect_height = shape.selrect().height();
let mut global_offset_y = match shape.vertical_align() {
VerticalAlign::Center => (selrect_height - text_height) / 2.0,
VerticalAlign::Bottom => selrect_height - text_height,
_ => 0.0,
};
let layer_rec = SaveLayerRec::default(); let layer_rec = SaveLayerRec::default();
canvas.save_layer(&layer_rec); canvas.save_layer(&layer_rec);
let mut normalized_line_height = text_content.normalized_line_height();
for paragraph_builder_group in paragraph_builder_groups { for para in &layout_info.paragraphs {
let mut group_offset_y = global_offset_y; para.paragraph.paint(canvas, (para.x, para.y));
let group_len = paragraph_builder_group.len(); for deco in &para.decorations {
draw_text_decorations(
for (paragraph_index, paragraph_builder) in paragraph_builder_group.iter_mut().enumerate() { canvas,
let mut paragraph = paragraph_builder.build(); &deco.text_style,
paragraph.layout(text_width); Some(deco.y),
let xy = (shape.selrect().x(), shape.selrect().y() + group_offset_y); deco.thickness,
paragraph.paint(canvas, xy); deco.left,
deco.width,
let line_metrics = paragraph.get_line_metrics(); );
if paragraph_index == group_len - 1 {
if line_metrics.is_empty() {
group_offset_y += normalized_line_height;
} else {
normalized_line_height = paragraph.ideographic_baseline();
group_offset_y += paragraph.ideographic_baseline() * line_metrics.len() as f32;
}
}
for line_metrics in paragraph.get_line_metrics().iter() {
render_text_decoration(canvas, &paragraph, paragraph_builder, line_metrics, xy);
}
} }
global_offset_y = group_offset_y;
} }
} }
@@ -305,7 +280,7 @@ fn draw_text_decorations(
} }
} }
fn calculate_decoration_metrics( pub fn calculate_decoration_metrics(
style_metrics: &Vec<(usize, &StyleMetrics)>, style_metrics: &Vec<(usize, &StyleMetrics)>,
line_baseline: f32, line_baseline: f32,
) -> (f32, Option<f32>, f32, Option<f32>) { ) -> (f32, Option<f32>, f32, Option<f32>) {
@@ -355,106 +330,6 @@ fn calculate_decoration_metrics(
) )
} }
fn render_text_decoration(
canvas: &Canvas,
skia_paragraph: &Paragraph,
builder: &mut ParagraphBuilder,
line_metrics: &LineMetrics,
xy: (f32, f32),
) {
let style_metrics: Vec<_> = line_metrics
.get_style_metrics(line_metrics.start_index..line_metrics.end_index)
.into_iter()
.collect();
let mut current_x_offset = 0.0;
let total_chars = line_metrics.end_index - line_metrics.start_index;
let line_start_offset = line_metrics.left as f32;
if total_chars == 0 || style_metrics.is_empty() {
return;
}
let line_baseline = xy.1 + line_metrics.baseline as f32;
let full_text = builder.get_text();
// Calculate decoration metrics
let (max_underline_thickness, underline_y, max_strike_thickness, strike_y) =
calculate_decoration_metrics(&style_metrics, line_baseline);
// Draw decorations per segment (text span)
for (i, (style_start, style_metric)) in style_metrics.iter().enumerate() {
let text_style = &style_metric.text_style;
let style_end = style_metrics
.get(i + 1)
.map(|(next_i, _)| *next_i)
.unwrap_or(line_metrics.end_index);
let seg_start = (*style_start).max(line_metrics.start_index);
let seg_end = style_end.min(line_metrics.end_index);
if seg_start >= seg_end {
continue;
}
let start_byte = full_text
.char_indices()
.nth(seg_start)
.map(|(i, _)| i)
.unwrap_or(0);
let end_byte = full_text
.char_indices()
.nth(seg_end)
.map(|(i, _)| i)
.unwrap_or(full_text.len());
let segment_text = &full_text[start_byte..end_byte];
let rects = skia_paragraph.get_rects_for_range(
seg_start..seg_end,
RectHeightStyle::Tight,
RectWidthStyle::Tight,
);
let (segment_width, actual_x_offset) = if !rects.is_empty() {
let total_width: f32 = rects.iter().map(|r| r.rect.width()).sum();
let skia_x_offset = rects
.first()
.map(|r| r.rect.left - line_start_offset)
.unwrap_or(0.0);
(total_width, skia_x_offset)
} else {
let font = skia_paragraph.get_font_at(seg_start);
let measured_width = font.measure_text(segment_text, None).0;
(measured_width, current_x_offset)
};
let text_left = xy.0 + line_start_offset + actual_x_offset;
let text_width = segment_width;
// Underline
if text_style.decoration().ty == TextDecoration::UNDERLINE {
draw_text_decorations(
canvas,
text_style,
underline_y,
max_underline_thickness,
text_left,
text_width,
);
}
// Strikethrough
if text_style.decoration().ty == TextDecoration::LINE_THROUGH {
draw_text_decorations(
canvas,
text_style,
strike_y,
max_strike_thickness,
text_left,
text_width,
);
}
current_x_offset += segment_width;
}
}
#[allow(dead_code)] #[allow(dead_code)]
fn calculate_total_paragraphs_height(paragraphs: &mut [ParagraphBuilder], width: f32) -> f32 { fn calculate_total_paragraphs_height(paragraphs: &mut [ParagraphBuilder], width: f32) -> f32 {
paragraphs paragraphs
@@ -504,6 +379,29 @@ pub fn render_as_path(
} }
} }
#[allow(dead_code)]
pub fn render_position_data(
render_state: &mut RenderState,
surface_id: SurfaceId,
shape: &Shape,
text_content: &TextContent,
) {
let position_data = calculate_position_data(shape, text_content, false);
let mut paint = skia::Paint::default();
paint.set_style(skia::PaintStyle::Stroke);
paint.set_color(skia::Color::from_argb(255, 255, 0, 0));
paint.set_stroke_width(2.);
for pd in position_data {
let rect = Rect::from_xywh(pd.x, pd.y, pd.width, pd.height);
render_state
.surfaces
.canvas(surface_id)
.draw_rect(rect, &paint);
}
}
// How to use it? // How to use it?
// Type::Text(text_content) => { // Type::Text(text_content) => {
// self.surfaces // self.surfaces

View File

@@ -277,7 +277,6 @@ fn propagate_reflow(
}; };
let shapes = &state.shapes; let shapes = &state.shapes;
let mut reflow_parent = false;
if reflown.contains(id) { if reflown.contains(id) {
return; return;
@@ -290,19 +289,14 @@ fn propagate_reflow(
let mut skip_reflow = false; let mut skip_reflow = false;
if shape.is_layout_horizontal_fill() || shape.is_layout_vertical_fill() { if shape.is_layout_horizontal_fill() || shape.is_layout_vertical_fill() {
if let Some(parent_id) = shape.parent_id { if let Some(parent_id) = shape.parent_id {
if !reflown.contains(&parent_id) { if parent_id != Uuid::nil() && !reflown.contains(&parent_id) {
// If this is a fill layout but the parent has not been reflown yet // If this is a fill layout but the parent has not been reflown yet
// we wait for the next iteration for reflow // we wait for the next iteration for reflow
skip_reflow = true; skip_reflow = true;
reflow_parent = true;
} }
} }
} }
if shape.is_layout_vertical_auto() || shape.is_layout_horizontal_auto() {
reflow_parent = true;
}
if !skip_reflow { if !skip_reflow {
layout_reflows.push(*id); layout_reflows.push(*id);
} }
@@ -312,32 +306,26 @@ fn propagate_reflow(
if let Some(child) = shapes.get(&children_ids[0]) { if let Some(child) = shapes.get(&children_ids[0]) {
let child_bounds = bounds.find(child); let child_bounds = bounds.find(child);
bounds.insert(shape.id, child_bounds); bounds.insert(shape.id, child_bounds);
reflow_parent = true;
} }
reflown.insert(*id); reflown.insert(*id);
} }
Type::Group(_) => { Type::Group(_) => {
if let Some(shape_bounds) = calculate_group_bounds(shape, shapes, bounds) { if let Some(shape_bounds) = calculate_group_bounds(shape, shapes, bounds) {
bounds.insert(shape.id, shape_bounds); bounds.insert(shape.id, shape_bounds);
reflow_parent = true;
} }
reflown.insert(*id); reflown.insert(*id);
} }
Type::Bool(_) => { Type::Bool(_) => {
if let Some(shape_bounds) = calculate_bool_bounds(shape, shapes, bounds, modifiers) { if let Some(shape_bounds) = calculate_bool_bounds(shape, shapes, bounds, modifiers) {
bounds.insert(shape.id, shape_bounds); bounds.insert(shape.id, shape_bounds);
reflow_parent = true;
} }
reflown.insert(*id); reflown.insert(*id);
} }
_ => { _ => {}
// Other shapes don't have to be reflown
reflow_parent = true;
}
} }
if let Some(parent) = shape.parent_id.and_then(|id| shapes.get(&id)) { if let Some(parent) = shape.parent_id.and_then(|id| shapes.get(&id)) {
if reflow_parent && (parent.has_layout() || parent.is_group_like()) { if parent.has_layout() || parent.is_group_like() {
entries.push_back(Modifier::reflow(parent.id)); entries.push_back(Modifier::reflow(parent.id));
} }
} }
@@ -384,7 +372,7 @@ pub fn propagate_modifiers(
if math::identitish(&entry.transform) { if math::identitish(&entry.transform) {
Modifier::Reflow(entry.id) Modifier::Reflow(entry.id)
} else { } else {
Modifier::Transform(entry.clone()) Modifier::Transform(*entry)
} }
}) })
.collect(); .collect();

View File

@@ -53,15 +53,6 @@ struct LayoutAxis {
is_auto_across: bool, is_auto_across: bool,
} }
impl LayoutAxis {
fn main_space(&self) -> f32 {
self.main_size - self.padding_main_start - self.padding_main_end
}
fn across_space(&self) -> f32 {
self.across_size - self.padding_across_start - self.padding_across_end
}
}
impl LayoutAxis { impl LayoutAxis {
fn new( fn new(
shape: &Shape, shape: &Shape,
@@ -101,6 +92,13 @@ impl LayoutAxis {
} }
} }
} }
fn main_space(&self) -> f32 {
self.main_size - self.padding_main_start - self.padding_main_end
}
fn across_space(&self) -> f32 {
self.across_size - self.padding_across_start - self.padding_across_end
}
} }
#[derive(Debug, Copy, Clone)] #[derive(Debug, Copy, Clone)]
@@ -346,6 +344,7 @@ fn distribute_fill_across_space(layout_axis: &LayoutAxis, tracks: &mut [TrackDat
let mut size = let mut size =
track.across_size - child.margin_across_start - child.margin_across_end; track.across_size - child.margin_across_start - child.margin_across_end;
size = size.clamp(child.min_across_size, child.max_across_size); size = size.clamp(child.min_across_size, child.max_across_size);
size = f32::min(size, layout_axis.across_space());
child.across_size = size; child.across_size = size;
} }
} }
@@ -547,14 +546,22 @@ fn child_position(
align_self: Some(align_self), align_self: Some(align_self),
.. ..
}) => match align_self { }) => match align_self {
AlignSelf::Center => (track.across_size - child_axis.across_size) / 2.0, AlignSelf::Center => {
(track.across_size - child_axis.across_size + child_axis.margin_across_start
- child_axis.margin_across_end)
/ 2.0
}
AlignSelf::End => { AlignSelf::End => {
track.across_size - child_axis.across_size - child_axis.margin_across_end track.across_size - child_axis.across_size - child_axis.margin_across_end
} }
_ => child_axis.margin_across_start, _ => child_axis.margin_across_start,
}, },
_ => match layout_data.align_items { _ => match layout_data.align_items {
AlignItems::Center => (track.across_size - child_axis.across_size) / 2.0, AlignItems::Center => {
(track.across_size - child_axis.across_size + child_axis.margin_across_start
- child_axis.margin_across_end)
/ 2.0
}
AlignItems::End => { AlignItems::End => {
track.across_size - child_axis.across_size - child_axis.margin_across_end track.across_size - child_axis.across_size - child_axis.margin_across_end
} }
@@ -580,7 +587,11 @@ pub fn reflow_flex_layout(
let tracks = calculate_track_data(shape, layout_data, flex_data, layout_bounds, shapes, bounds); let tracks = calculate_track_data(shape, layout_data, flex_data, layout_bounds, shapes, bounds);
for track in tracks.iter() { for track in tracks.iter() {
let total_shapes_size = track.shapes.iter().map(|s| s.main_size).sum::<f32>(); let total_shapes_size = track
.shapes
.iter()
.map(|s| s.main_size + s.margin_main_start + s.margin_main_end)
.sum::<f32>();
let mut shape_anchor = first_anchor(layout_data, &layout_axis, track, total_shapes_size); let mut shape_anchor = first_anchor(layout_data, &layout_axis, track, total_shapes_size);
for child_axis in track.shapes.iter() { for child_axis in track.shapes.iter() {
@@ -624,6 +635,9 @@ pub fn reflow_flex_layout(
} }
result.push_back(Modifier::transform_propagate(child.id, transform)); result.push_back(Modifier::transform_propagate(child.id, transform));
if child.has_layout() {
result.push_back(Modifier::reflow(child.id));
}
shape_anchor = next_anchor( shape_anchor = next_anchor(
layout_data, layout_data,
@@ -654,7 +668,11 @@ pub fn reflow_flex_layout(
.iter() .iter()
.map(|track| { .map(|track| {
let nshapes = usize::max(track.shapes.len(), 1); let nshapes = usize::max(track.shapes.len(), 1);
track.shapes.iter().map(|s| s.main_size).sum::<f32>() track
.shapes
.iter()
.map(|s| s.margin_main_start + s.margin_main_end + s.main_size)
.sum::<f32>()
+ (nshapes as f32 - 1.0) * layout_axis.gap_main + (nshapes as f32 - 1.0) * layout_axis.gap_main
}) })
.reduce(f32::max) .reduce(f32::max)

View File

@@ -792,6 +792,9 @@ pub fn reflow_grid_layout(
} }
result.push_back(Modifier::transform_propagate(child.id, transform)); result.push_back(Modifier::transform_propagate(child.id, transform));
if child.has_layout() {
result.push_back(Modifier::reflow(child.id));
}
} }
if shape.is_layout_horizontal_auto() || shape.is_layout_vertical_auto() { if shape.is_layout_horizontal_auto() || shape.is_layout_vertical_auto() {

View File

@@ -63,10 +63,50 @@ fn make_corner(
Segment::CurveTo((h1, h2, to)) Segment::CurveTo((h1, h2, to))
} }
// Calculates the minimum of five f32 values
fn min_5(a: f32, b: f32, c: f32, d: f32, e: f32) -> f32 {
f32::min(a, f32::min(b, f32::min(c, f32::min(d, e))))
}
/*
https://www.w3.org/TR/css-backgrounds-3/#corner-overlap
> Corner curves must not overlap: When the sum of any two adjacent border radii exceeds the size of the border box,
> UAs must proportionally reduce the used values of all border radii until none of them overlap.
> The algorithm for reducing radii is as follows: Let f = min(Li/Si), where i ∈ {top, right, bottom, left}, Si is
> the sum of the two corresponding radii of the corners on side i, and Ltop = Lbottom = the width of the box, and
> Lleft = Lright = the height of the box. If f < 1, then all corner radii are reduced by multiplying them by f.
*/
fn fix_radius(
r1: math::Point,
r2: math::Point,
r3: math::Point,
r4: math::Point,
width: f32,
height: f32,
) -> (math::Point, math::Point, math::Point, math::Point) {
let f = min_5(
1.0,
width / (r1.x + r2.x),
height / (r2.y + r3.y),
width / (r3.x + r4.x),
height / (r4.y + r1.y),
);
if f < 1.0 {
(r1 * f, r2 * f, r3 * f, r4 * f)
} else {
(r1, r2, r3, r4)
}
}
pub fn rect_segments(shape: &Shape, corners: Option<Corners>) -> Vec<Segment> { pub fn rect_segments(shape: &Shape, corners: Option<Corners>) -> Vec<Segment> {
let sr = shape.selrect; let sr = shape.selrect;
let segments = if let Some([r1, r2, r3, r4]) = corners { let segments = if let Some([r1, r2, r3, r4]) = corners {
let (r1, r2, r3, r4) = fix_radius(r1, r2, r3, r4, sr.width(), sr.height());
let p1 = (sr.x(), sr.y() + r1.y); let p1 = (sr.x(), sr.y() + r1.y);
let p2 = (sr.x() + r1.x, sr.y()); let p2 = (sr.x() + r1.x, sr.y());
let p3 = (sr.x() + sr.width() - r2.x, sr.y()); let p3 = (sr.x() + sr.width() - r2.x, sr.y());

View File

@@ -1,3 +1,4 @@
use crate::render::text::calculate_decoration_metrics;
use crate::{ use crate::{
math::{Bounds, Matrix, Rect}, math::{Bounds, Matrix, Rect},
render::{default_font, DEFAULT_EMOJI_FONT}, render::{default_font, DEFAULT_EMOJI_FONT},
@@ -185,6 +186,17 @@ impl TextContentLayout {
} }
} }
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct TextDecorationSegment {
pub kind: skia::textlayout::TextDecoration,
pub text_style: skia::textlayout::TextStyle,
pub y: f32,
pub thickness: f32,
pub left: f32,
pub width: f32,
}
/* /*
* Check if the current x,y (in paragraph relative coordinates) is inside * Check if the current x,y (in paragraph relative coordinates) is inside
* the paragraph * the paragraph
@@ -204,6 +216,48 @@ fn intersects(paragraph: &skia_safe::textlayout::Paragraph, x: f32, y: f32) -> b
rects.iter().any(|r| r.rect.contains(&Point::new(x, y))) rects.iter().any(|r| r.rect.contains(&Point::new(x, y)))
} }
// Performs a text auto layout without width limits.
// This should be the same as text_auto_layout.
pub fn build_paragraphs_from_paragraph_builders(
paragraph_builders: &mut [ParagraphBuilderGroup],
width: f32,
) -> Vec<Vec<skia::textlayout::Paragraph>> {
let paragraphs = paragraph_builders
.iter_mut()
.map(|builders| {
builders
.iter_mut()
.map(|builder| {
let mut paragraph = builder.build();
// For auto-width, always layout with infinite width first to get intrinsic width
paragraph.layout(width);
paragraph
})
.collect()
})
.collect();
paragraphs
}
/// Calculate the normalized line height from paragraph builders
pub fn calculate_normalized_line_height(
paragraph_builders: &mut [ParagraphBuilderGroup],
width: f32,
) -> f32 {
let mut normalized_line_height = 0.0;
for paragraph_builder_group in paragraph_builders.iter_mut() {
for paragraph_builder in paragraph_builder_group.iter_mut() {
let mut paragraph = paragraph_builder.build();
paragraph.layout(width);
let baseline = paragraph.ideographic_baseline();
if baseline > normalized_line_height {
normalized_line_height = baseline;
}
}
}
normalized_line_height
}
#[derive(Debug, PartialEq, Clone)] #[derive(Debug, PartialEq, Clone)]
pub struct TextContent { pub struct TextContent {
pub paragraphs: Vec<Paragraph>, pub paragraphs: Vec<Paragraph>,
@@ -440,59 +494,15 @@ impl TextContent {
paragraph_group paragraph_group
} }
/// Performs a text auto layout without width limits.
/// This should be the same as text_auto_layout.
fn build_paragraphs_from_paragraph_builders(
&self,
paragraph_builders: &mut [ParagraphBuilderGroup],
width: f32,
) -> Vec<Vec<skia::textlayout::Paragraph>> {
let paragraphs = paragraph_builders
.iter_mut()
.map(|builders| {
builders
.iter_mut()
.map(|builder| {
let mut paragraph = builder.build();
// For auto-width, always layout with infinite width first to get intrinsic width
paragraph.layout(width);
paragraph
})
.collect()
})
.collect();
paragraphs
}
/// Calculate the normalized line height from paragraph builders
fn calculate_normalized_line_height(
&self,
paragraph_builders: &mut [ParagraphBuilderGroup],
width: f32,
) -> f32 {
let mut normalized_line_height = 0.0;
for paragraph_builder_group in paragraph_builders.iter_mut() {
for paragraph_builder in paragraph_builder_group.iter_mut() {
let mut paragraph = paragraph_builder.build();
paragraph.layout(width);
let baseline = paragraph.ideographic_baseline();
if baseline > normalized_line_height {
normalized_line_height = baseline;
}
}
}
normalized_line_height
}
/// Performs an Auto Width text layout. /// Performs an Auto Width text layout.
fn text_layout_auto_width(&self) -> TextContentLayoutResult { fn text_layout_auto_width(&self) -> TextContentLayoutResult {
let mut paragraph_builders = self.paragraph_builder_group_from_text(None); let mut paragraph_builders = self.paragraph_builder_group_from_text(None);
let normalized_line_height = let normalized_line_height =
self.calculate_normalized_line_height(&mut paragraph_builders, f32::MAX); calculate_normalized_line_height(&mut paragraph_builders, f32::MAX);
let paragraphs = let paragraphs =
self.build_paragraphs_from_paragraph_builders(&mut paragraph_builders, f32::MAX); build_paragraphs_from_paragraph_builders(&mut paragraph_builders, f32::MAX);
let (width, height) = let (width, height) =
paragraphs paragraphs
@@ -521,10 +531,9 @@ impl TextContent {
let mut paragraph_builders = self.paragraph_builder_group_from_text(None); let mut paragraph_builders = self.paragraph_builder_group_from_text(None);
let normalized_line_height = let normalized_line_height =
self.calculate_normalized_line_height(&mut paragraph_builders, width); calculate_normalized_line_height(&mut paragraph_builders, width);
let paragraphs = let paragraphs = build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width);
self.build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width);
let height = paragraphs let height = paragraphs
.iter() .iter()
.flatten() .flatten()
@@ -546,10 +555,9 @@ impl TextContent {
let mut paragraph_builders = self.paragraph_builder_group_from_text(None); let mut paragraph_builders = self.paragraph_builder_group_from_text(None);
let normalized_line_height = let normalized_line_height =
self.calculate_normalized_line_height(&mut paragraph_builders, width); calculate_normalized_line_height(&mut paragraph_builders, width);
let paragraphs = let paragraphs = build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width);
self.build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width);
let paragraph_height = paragraphs let paragraph_height = paragraphs
.iter() .iter()
.flatten() .flatten()
@@ -576,8 +584,7 @@ impl TextContent {
pub fn get_height(&self, width: f32) -> f32 { pub fn get_height(&self, width: f32) -> f32 {
let mut paragraph_builders = self.paragraph_builder_group_from_text(None); let mut paragraph_builders = self.paragraph_builder_group_from_text(None);
let paragraphs = let paragraphs = build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width);
self.build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width);
let paragraph_height = paragraphs let paragraph_height = paragraphs
.iter() .iter()
.flatten() .flatten()
@@ -733,8 +740,7 @@ impl TextContent {
let width = self.width(); let width = self.width();
let mut paragraph_builders = self.paragraph_builder_group_from_text(None); let mut paragraph_builders = self.paragraph_builder_group_from_text(None);
let paragraphs = let paragraphs = build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width);
self.build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width);
paragraphs paragraphs
.iter() .iter()
@@ -863,17 +869,17 @@ impl Paragraph {
#[derive(Debug, PartialEq, Clone)] #[derive(Debug, PartialEq, Clone)]
pub struct TextSpan { pub struct TextSpan {
text: String, pub text: String,
font_family: FontFamily, pub font_family: FontFamily,
font_size: f32, pub font_size: f32,
line_height: f32, pub line_height: f32,
letter_spacing: f32, pub letter_spacing: f32,
font_weight: i32, pub font_weight: i32,
font_variant_id: Uuid, pub font_variant_id: Uuid,
text_decoration: Option<TextDecoration>, pub text_decoration: Option<TextDecoration>,
text_transform: Option<TextTransform>, pub text_transform: Option<TextTransform>,
text_direction: TextDirection, pub text_direction: TextDirection,
fills: Vec<shapes::Fill>, pub fills: Vec<shapes::Fill>,
} }
impl TextSpan { impl TextSpan {
@@ -1045,3 +1051,251 @@ impl TextSpan {
}) })
} }
} }
#[allow(dead_code)]
#[derive(Debug, Copy, Clone)]
pub struct PositionData {
pub paragraph: u32,
pub span: u32,
pub start_pos: u32,
pub end_pos: u32,
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
pub direction: u32,
}
#[allow(dead_code)]
#[derive(Debug)]
pub struct ParagraphLayout {
pub paragraph: skia::textlayout::Paragraph,
pub x: f32,
pub y: f32,
pub spans: Vec<crate::shapes::TextSpan>,
pub decorations: Vec<TextDecorationSegment>,
}
#[allow(dead_code)]
#[derive(Debug)]
pub struct TextLayoutData {
pub position_data: Vec<PositionData>,
pub content_rect: Rect,
pub paragraphs: Vec<ParagraphLayout>,
}
fn direction_to_int(direction: TextDirection) -> u32 {
match direction {
TextDirection::RTL => 0,
TextDirection::LTR => 1,
}
}
pub fn calculate_text_layout_data(
shape: &Shape,
text_content: &TextContent,
paragraph_builder_groups: &mut [ParagraphBuilderGroup],
skip_position_data: bool,
) -> TextLayoutData {
let selrect_width = shape.selrect().width();
let text_width = text_content.get_width(selrect_width);
let selrect_height = shape.selrect().height();
let x = shape.selrect.x();
let base_y = shape.selrect.y();
let mut position_data: Vec<PositionData> = Vec::new();
let mut previous_line_height = text_content.normalized_line_height();
let text_paragraphs = text_content.paragraphs();
// 1. Calculate paragraph heights
let mut paragraph_heights: Vec<f32> = Vec::new();
for paragraph_builder_group in paragraph_builder_groups.iter_mut() {
let group_len = paragraph_builder_group.len();
let mut paragraph_offset_y = previous_line_height;
for (builder_index, paragraph_builder) in paragraph_builder_group.iter_mut().enumerate() {
let mut skia_paragraph = paragraph_builder.build();
skia_paragraph.layout(text_width);
if builder_index == group_len - 1 {
if skia_paragraph.get_line_metrics().is_empty() {
paragraph_offset_y = skia_paragraph.ideographic_baseline();
} else {
paragraph_offset_y = skia_paragraph.height();
}
}
if builder_index == 0 {
paragraph_heights.push(skia_paragraph.height());
}
}
previous_line_height = paragraph_offset_y;
}
// 2. Calculate vertical offset and build paragraphs with positions
let total_text_height: f32 = paragraph_heights.iter().sum();
let vertical_offset = match shape.vertical_align() {
VerticalAlign::Center => (selrect_height - total_text_height) / 2.0,
VerticalAlign::Bottom => selrect_height - total_text_height,
_ => 0.0,
};
let mut paragraph_layouts: Vec<ParagraphLayout> = Vec::new();
let mut y_accum = base_y + vertical_offset;
for (i, paragraph_builder_group) in paragraph_builder_groups.iter_mut().enumerate() {
// For each paragraph in the group (e.g., fill, stroke, etc.)
for paragraph_builder in paragraph_builder_group.iter_mut() {
let mut skia_paragraph = paragraph_builder.build();
skia_paragraph.layout(text_width);
let spans = if let Some(text_para) = text_paragraphs.get(i) {
text_para.children().to_vec()
} else {
Vec::new()
};
// Calculate text decorations for this paragraph
let mut decorations = Vec::new();
let line_metrics = skia_paragraph.get_line_metrics();
for line in &line_metrics {
let style_metrics: Vec<_> = line
.get_style_metrics(line.start_index..line.end_index)
.into_iter()
.collect();
let line_baseline = y_accum + line.baseline as f32;
let (max_underline_thickness, underline_y, max_strike_thickness, strike_y) =
calculate_decoration_metrics(&style_metrics, line_baseline);
for (i, (style_start, style_metric)) in style_metrics.iter().enumerate() {
let text_style = &style_metric.text_style;
let style_end = style_metrics
.get(i + 1)
.map(|(next_i, _)| *next_i)
.unwrap_or(line.end_index);
let seg_start = (*style_start).max(line.start_index);
let seg_end = style_end.min(line.end_index);
if seg_start >= seg_end {
continue;
}
let rects = skia_paragraph.get_rects_for_range(
seg_start..seg_end,
skia::textlayout::RectHeightStyle::Tight,
skia::textlayout::RectWidthStyle::Tight,
);
let (segment_width, actual_x_offset) = if !rects.is_empty() {
let total_width: f32 = rects.iter().map(|r| r.rect.width()).sum();
let skia_x_offset = rects
.first()
.map(|r| r.rect.left - line.left as f32)
.unwrap_or(0.0);
(total_width, skia_x_offset)
} else {
(0.0, 0.0)
};
let text_left = x + line.left as f32 + actual_x_offset;
let text_width = segment_width;
use skia::textlayout::TextDecoration;
if text_style.decoration().ty == TextDecoration::UNDERLINE {
decorations.push(TextDecorationSegment {
kind: TextDecoration::UNDERLINE,
text_style: (*text_style).clone(),
y: underline_y.unwrap_or(line_baseline),
thickness: max_underline_thickness,
left: text_left,
width: text_width,
});
}
if text_style.decoration().ty == TextDecoration::LINE_THROUGH {
decorations.push(TextDecorationSegment {
kind: TextDecoration::LINE_THROUGH,
text_style: (*text_style).clone(),
y: strike_y.unwrap_or(line_baseline),
thickness: max_strike_thickness,
left: text_left,
width: text_width,
});
}
}
}
paragraph_layouts.push(ParagraphLayout {
paragraph: skia_paragraph,
x,
y: y_accum,
spans: spans.clone(),
decorations,
});
}
y_accum += paragraph_heights[i];
}
// Calculate position data from paragraph_layouts
if !skip_position_data {
for (paragraph_index, para_layout) in paragraph_layouts.iter().enumerate() {
let current_y = para_layout.y;
let text_paragraph = text_paragraphs.get(paragraph_index);
if let Some(text_para) = text_paragraph {
let mut span_ranges: Vec<(usize, usize, usize)> = vec![];
let mut cur = 0;
for (span_index, span) in text_para.children().iter().enumerate() {
let text: String = span.apply_text_transform();
span_ranges.push((cur, cur + text.len(), span_index));
cur += text.len();
}
for (start, end, span_index) in span_ranges {
let rects = para_layout.paragraph.get_rects_for_range(
start..end,
RectHeightStyle::Tight,
RectWidthStyle::Tight,
);
for textbox in rects {
let direction = textbox.direct;
let mut rect = textbox.rect;
let cy = rect.top + rect.height() / 2.0;
let start_pos = para_layout
.paragraph
.get_glyph_position_at_coordinate((rect.left + 0.1, cy))
.position as usize;
let end_pos = para_layout
.paragraph
.get_glyph_position_at_coordinate((rect.right - 0.1, cy))
.position as usize;
let start_pos = start_pos.saturating_sub(start);
let end_pos = end_pos.saturating_sub(start);
rect.offset((x, current_y));
position_data.push(PositionData {
paragraph: paragraph_index as u32,
span: span_index as u32,
start_pos: start_pos as u32,
end_pos: end_pos as u32,
x: rect.x(),
y: rect.y(),
width: rect.width(),
height: rect.height(),
direction: direction_to_int(direction),
});
}
}
}
}
}
let content_rect = Rect::from_xywh(x, base_y + vertical_offset, text_width, total_text_height);
TextLayoutData {
position_data,
content_rect,
paragraphs: paragraph_layouts,
}
}
pub fn calculate_position_data(
shape: &Shape,
text_content: &TextContent,
skip_position_data: bool,
) -> Vec<PositionData> {
let mut text_content = text_content.clone();
text_content.update_layout(shape.selrect);
let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None);
let layout_info = calculate_text_layout_data(
shape,
&text_content,
&mut paragraph_builders,
skip_position_data,
);
layout_info.position_data
}

View File

@@ -23,13 +23,13 @@ impl Modifier {
} }
} }
#[derive(PartialEq, Debug, Clone)] #[derive(PartialEq, Debug, Clone, Copy)]
pub enum TransformEntrySource { pub enum TransformEntrySource {
Input, Input,
Propagate, Propagate,
} }
#[derive(PartialEq, Debug, Clone)] #[derive(PartialEq, Debug, Clone, Copy)]
#[repr(C)] #[repr(C)]
pub struct TransformEntry { pub struct TransformEntry {
pub id: Uuid, pub id: Uuid,
@@ -65,10 +65,8 @@ impl TransformEntry {
} }
} }
impl SerializableResult for TransformEntry { impl From<[u8; 40]> for TransformEntry {
type BytesType = [u8; 40]; fn from(bytes: [u8; 40]) -> Self {
fn from_bytes(bytes: Self::BytesType) -> Self {
let id = uuid_from_u32_quartet( let id = uuid_from_u32_quartet(
u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]), u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]),
u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]), u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]),
@@ -89,29 +87,46 @@ impl SerializableResult for TransformEntry {
); );
TransformEntry::from_input(id, transform) TransformEntry::from_input(id, transform)
} }
}
fn as_bytes(&self) -> Self::BytesType { impl TryFrom<&[u8]> for TransformEntry {
let mut result: Self::BytesType = [0; 40]; type Error = String;
let (a, b, c, d) = uuid_to_u32_quartet(&self.id); fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> {
let bytes: [u8; 40] = bytes
.try_into()
.map_err(|_| "Invalid transform entry bytes".to_string())?;
Ok(TransformEntry::from(bytes))
}
}
impl From<TransformEntry> for [u8; 40] {
fn from(value: TransformEntry) -> Self {
let mut result = [0; 40];
let (a, b, c, d) = uuid_to_u32_quartet(&value.id);
result[0..4].clone_from_slice(&a.to_le_bytes()); result[0..4].clone_from_slice(&a.to_le_bytes());
result[4..8].clone_from_slice(&b.to_le_bytes()); result[4..8].clone_from_slice(&b.to_le_bytes());
result[8..12].clone_from_slice(&c.to_le_bytes()); result[8..12].clone_from_slice(&c.to_le_bytes());
result[12..16].clone_from_slice(&d.to_le_bytes()); result[12..16].clone_from_slice(&d.to_le_bytes());
result[16..20].clone_from_slice(&self.transform[0].to_le_bytes()); result[16..20].clone_from_slice(&value.transform[0].to_le_bytes());
result[20..24].clone_from_slice(&self.transform[3].to_le_bytes()); result[20..24].clone_from_slice(&value.transform[3].to_le_bytes());
result[24..28].clone_from_slice(&self.transform[1].to_le_bytes()); result[24..28].clone_from_slice(&value.transform[1].to_le_bytes());
result[28..32].clone_from_slice(&self.transform[4].to_le_bytes()); result[28..32].clone_from_slice(&value.transform[4].to_le_bytes());
result[32..36].clone_from_slice(&self.transform[2].to_le_bytes()); result[32..36].clone_from_slice(&value.transform[2].to_le_bytes());
result[36..40].clone_from_slice(&self.transform[5].to_le_bytes()); result[36..40].clone_from_slice(&value.transform[5].to_le_bytes());
result result
} }
}
impl SerializableResult for TransformEntry {
type BytesType = [u8; 40];
// The generic trait doesn't know the size of the array. This is why the // The generic trait doesn't know the size of the array. This is why the
// clone needs to be here even if it could be generic. // clone needs to be here even if it could be generic.
fn clone_to_slice(&self, slice: &mut [u8]) { fn clone_to_slice(&self, slice: &mut [u8]) {
slice.clone_from_slice(&self.as_bytes()); let bytes = Self::BytesType::from(*self);
slice.clone_from_slice(&bytes);
} }
} }
@@ -198,8 +213,8 @@ mod tests {
Matrix::new_all(1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 0.0, 0.0, 1.0), Matrix::new_all(1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 0.0, 0.0, 1.0),
); );
let bytes = entry.as_bytes(); let bytes: [u8; 40] = entry.into();
assert_eq!(entry, TransformEntry::from_bytes(bytes)); assert_eq!(entry, TransformEntry::from(bytes));
} }
} }

View File

@@ -24,6 +24,7 @@ pub(crate) struct State<'a> {
pub current_id: Option<Uuid>, pub current_id: Option<Uuid>,
pub current_browser: u8, pub current_browser: u8,
pub shapes: ShapesPool<'a>, pub shapes: ShapesPool<'a>,
pub saved_shapes: Option<ShapesPool<'a>>,
} }
impl<'a> State<'a> { impl<'a> State<'a> {
@@ -34,9 +35,32 @@ impl<'a> State<'a> {
current_id: None, current_id: None,
current_browser: 0, current_browser: 0,
shapes: ShapesPool::new(), shapes: ShapesPool::new(),
// TODO: Maybe this can be moved to a different object
saved_shapes: None,
} }
} }
// Creates a new temporary shapes pool.
// Will panic if a previous temporary pool exists.
pub fn start_temp_objects(mut self) -> Self {
if self.saved_shapes.is_some() {
panic!("Tried to start a temp objects while the previous have not been restored");
}
self.saved_shapes = Some(self.shapes);
self.shapes = ShapesPool::new();
self
}
// Disposes of the temporary shapes pool restoring the normal pool
// Will panic if a there is no temporary pool.
pub fn end_temp_objects(mut self) -> Self {
self.shapes = self
.saved_shapes
.expect("Tried to end temp objects but not content to be restored is present");
self.saved_shapes = None;
self
}
pub fn resize(&mut self, width: i32, height: i32) { pub fn resize(&mut self, width: i32, height: i32) {
self.render_state.resize(width, height); self.render_state.resize(width, height);
} }
@@ -173,6 +197,10 @@ impl<'a> State<'a> {
self.render_state.rebuild_tiles_shallow(&self.shapes); self.render_state.rebuild_tiles_shallow(&self.shapes);
} }
pub fn clear_tile_index(&mut self) {
self.render_state.clear_tile_index();
}
pub fn rebuild_tiles(&mut self) { pub fn rebuild_tiles(&mut self) {
self.render_state.rebuild_tiles_from(&self.shapes, None); self.render_state.rebuild_tiles_from(&self.shapes, None);
} }

View File

@@ -88,6 +88,7 @@ impl TileViewbox {
} }
pub fn is_visible(&self, tile: &Tile) -> bool { pub fn is_visible(&self, tile: &Tile) -> bool {
// TO CHECK self.interest_rect.contains(tile)
self.visible_rect.contains(tile) self.visible_rect.contains(tile)
} }
} }

View File

@@ -49,10 +49,8 @@ impl fmt::Display for Uuid {
} }
} }
impl SerializableResult for Uuid { impl From<[u8; 16]> for Uuid {
type BytesType = [u8; 16]; fn from(bytes: [u8; 16]) -> Self {
fn from_bytes(bytes: Self::BytesType) -> Self {
Self(*uuid_from_u32_quartet( Self(*uuid_from_u32_quartet(
u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]), u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]),
u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]), u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]),
@@ -60,10 +58,22 @@ impl SerializableResult for Uuid {
u32::from_le_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]), u32::from_le_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]),
)) ))
} }
}
fn as_bytes(&self) -> Self::BytesType { impl TryFrom<&[u8]> for Uuid {
let mut result: Self::BytesType = [0; 16]; type Error = String;
let (a, b, c, d) = uuid_to_u32_quartet(self); fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> {
let bytes: [u8; 16] = bytes
.try_into()
.map_err(|_| "Invalid UUID bytes".to_string())?;
Ok(Self::from(bytes))
}
}
impl From<Uuid> for [u8; 16] {
fn from(value: Uuid) -> Self {
let mut result = [0; 16];
let (a, b, c, d) = uuid_to_u32_quartet(&value);
result[0..4].clone_from_slice(&a.to_le_bytes()); result[0..4].clone_from_slice(&a.to_le_bytes());
result[4..8].clone_from_slice(&b.to_le_bytes()); result[4..8].clone_from_slice(&b.to_le_bytes());
result[8..12].clone_from_slice(&c.to_le_bytes()); result[8..12].clone_from_slice(&c.to_le_bytes());
@@ -71,10 +81,15 @@ impl SerializableResult for Uuid {
result result
} }
}
impl SerializableResult for Uuid {
type BytesType = [u8; 16];
// The generic trait doesn't know the size of the array. This is why the // The generic trait doesn't know the size of the array. This is why the
// clone needs to be here even if it could be generic. // clone needs to be here even if it could be generic.
fn clone_to_slice(&self, slice: &mut [u8]) { fn clone_to_slice(&self, slice: &mut [u8]) {
slice.clone_from_slice(&self.as_bytes()); let bytes = Self::BytesType::from(*self);
slice.clone_from_slice(&bytes);
} }
} }

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