diff --git a/CHANGES.md b/CHANGES.md index 14b3f683d7..fd520908f1 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -73,6 +73,9 @@ - Fix incorrect handling of input values on layout gap and padding inputs [Github #8113](https://github.com/penpot/penpot/issues/8113) - Fix several race conditions on path editor [Github #8187](https://github.com/penpot/penpot/pull/8187) - Fix app freeze when introducing an error on a very long token name [Taiga #13214](https://tree.taiga.io/project/penpot/issue/13214) +- Fix import a file with shadow tokens [Taiga #13229](https://tree.taiga.io/project/penpot/issue/13229) +- Fix allow spaces on token description [Taiga #13184](https://tree.taiga.io/project/penpot/issue/13184) +- Fix error when creating a token with an invalid name [Taiga #13219](https://tree.taiga.io/project/penpot/issue/13219) ## 2.12.1 diff --git a/common/src/app/common/logic/libraries.cljc b/common/src/app/common/logic/libraries.cljc index 93d6b5008e..c47f7e3878 100644 --- a/common/src/app/common/logic/libraries.cljc +++ b/common/src/app/common/logic/libraries.cljc @@ -2016,7 +2016,9 @@ (let [;; We need to sync only the position relative to the origin of the component. ;; (see update-attrs for a full explanation) previous-shape (reposition-shape previous-shape prev-root current-root) - touched (get previous-shape :touched #{})] + touched (get previous-shape :touched #{}) + text-auto? (and (cfh/text-shape? current-shape) + (contains? #{:auto-height :auto-width} (:grow-type current-shape)))] (loop [attrs updatable-attrs roperations [{:type :set-touched :touched (:touched previous-shape)}] @@ -2025,6 +2027,10 @@ (let [attr-group (get ctk/sync-attrs attr) skip-operations? (or + ;; For auto text, avoid copying geometry-driven attrs on switch. + (and text-auto? + (contains? #{:points :selrect :width :height :position-data} attr)) + ;; If the attribute is not valid for the destiny, don't copy it (not (cts/is-allowed-switch-keep-attr? attr (:type current-shape))) diff --git a/common/src/app/common/types/token.cljc b/common/src/app/common/types/token.cljc index 00c5ecf1ba..94bf808ae0 100644 --- a/common/src/app/common/types/token.cljc +++ b/common/src/app/common/types/token.cljc @@ -109,9 +109,12 @@ (def token-types (into #{} (keys token-type->dtcg-token-type))) +(def token-name-validation-regex + #"^[a-zA-Z0-9_-][a-zA-Z0-9$_-]*(\.[a-zA-Z0-9$_-]+)*$") + (def token-name-ref [:re {:title "TokenNameRef" :gen/gen sg/text} - #"^[a-zA-Z0-9_-][a-zA-Z0-9$_-]*(\.[a-zA-Z0-9$_-]+)*$"]) + token-name-validation-regex]) (def ^:private schema:color [:map diff --git a/common/src/app/common/types/tokens_lib.cljc b/common/src/app/common/types/tokens_lib.cljc index 89d9eb2032..5e8e18e14a 100644 --- a/common/src/app/common/types/tokens_lib.cljc +++ b/common/src/app/common/types/tokens_lib.cljc @@ -1467,11 +1467,12 @@ Will return a value that matches this schema: (def ^:private schema:dtcg-node [:schema {:registry {::simple-value - [:or :string :int :double] + [:or :string :int :double ::sm/boolean] ::value [:or [:ref ::simple-value] [:vector ::simple-value] + [:vector [:map-of :string ::simple-value]] [:map-of :string [:or [:ref ::simple-value] [:vector ::simple-value]]]]}} diff --git a/frontend/playwright/ui/specs/text-editor-v2.spec.js b/frontend/playwright/ui/specs/text-editor-v2.spec.js index cc6061f192..9651c8e4b4 100644 --- a/frontend/playwright/ui/specs/text-editor-v2.spec.js +++ b/frontend/playwright/ui/specs/text-editor-v2.spec.js @@ -110,7 +110,7 @@ test("Update an already created text shape by prepending text", async ({ await workspace.textEditor.stopEditing(); }); -test("Update an already created text shape by inserting text in between", async ({ +test.skip("Update an already created text shape by inserting text in between", async ({ page, }) => { const workspace = new WorkspacePage(page, { @@ -151,7 +151,7 @@ test("Update a new text shape appending text by pasting text", async ({ await workspace.textEditor.stopEditing(); }); -test("Update a new text shape prepending text by pasting text", async ({ +test.skip("Update a new text shape prepending text by pasting text", async ({ page, context, }) => { diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index 52059269df..0f89e0bffb 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -46,7 +46,9 @@ [app.main.data.workspace.thumbnails :as dwt] [app.main.data.workspace.transforms :as dwtr] [app.main.data.workspace.undo :as dwu] + [app.main.data.workspace.wasm-text :as dwwt] [app.main.data.workspace.zoom :as dwz] + [app.main.features :as features] [app.main.features.pointer-map :as fpmap] [app.main.refs :as refs] [app.main.repo :as rp] @@ -1012,6 +1014,13 @@ updated-objects (pcb/get-objects changes) new-children-ids (cfh/get-children-ids-with-self updated-objects (:id new-shape)) + new-text-ids (->> new-children-ids + (keep (fn [id] + (when-let [child (get updated-objects id)] + (when (and (cfh/text-shape? child) + (not= :fixed (:grow-type child))) + id)))) + (vec)) [changes parents-of-swapped] (if keep-touched? @@ -1021,6 +1030,9 @@ (rx/of (dwu/start-undo-transaction undo-id) (dch/commit-changes changes) + (when (and (features/active-feature? state "render-wasm/v1") + (seq new-text-ids)) + (dwwt/resize-wasm-text-all new-text-ids)) (ptk/data-event :layout/update {:ids update-layout-ids :undo-group undo-group}) (dwu/commit-undo-transaction undo-id) (dws/select-shape (:id new-shape) false)))))) diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index c4e018b9e8..c6b8eec71c 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -712,8 +712,7 @@ (ctm/rotation-modifiers shape center angle)) modif-tree - (-> (build-modif-tree ids objects get-modifier) - (gm/set-objects-modifiers objects)) + (build-modif-tree ids objects get-modifier) modifiers (mapv (fn [[id {:keys [modifiers]}]] diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index f2fdf75aa4..54fcf70abc 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -11,7 +11,6 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.files.helpers :as cfh] - [app.common.geom.matrix :as gmt] [app.common.geom.point :as gpt] [app.common.geom.shapes :as gsh] [app.common.math :as mth] @@ -29,10 +28,10 @@ [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.transforms :as dwt] [app.main.data.workspace.undo :as dwu] + [app.main.data.workspace.wasm-text :as dwwt] [app.main.features :as features] [app.main.fonts :as fonts] [app.main.router :as rt] - [app.render-wasm.api :as wasm.api] [app.util.text-editor :as ted] [app.util.text.content.styles :as styles] [app.util.timers :as ts] @@ -52,50 +51,6 @@ (declare v2-update-text-shape-content) (declare v2-update-text-editor-styles) -(defn resize-wasm-text-modifiers - ([shape] - (resize-wasm-text-modifiers shape (:content shape))) - - ([{:keys [id points selrect grow-type] :as shape} content] - (wasm.api/use-shape id) - (wasm.api/set-shape-text-content id content) - (wasm.api/set-shape-text-images id content) - - (let [dimension (wasm.api/get-text-dimensions) - width-scale (if (#{:fixed :auto-height} grow-type) - 1.0 - (/ (:width dimension) (:width selrect))) - height-scale (if (= :fixed grow-type) - 1.0 - (/ (:height dimension) (:height selrect))) - resize-v (gpt/point width-scale height-scale) - origin (first points)] - - {id - {:modifiers - (ctm/resize-modifiers - resize-v - origin - (:transform shape (gmt/matrix)) - (:transform-inverse shape (gmt/matrix)))}}))) - -(defn resize-wasm-text - [id] - (ptk/reify ::resize-wasm-text - ptk/WatchEvent - (watch [_ state _] - (let [objects (dsh/lookup-page-objects state) - shape (get objects id)] - (rx/of (dwm/apply-wasm-modifiers (resize-wasm-text-modifiers shape))))))) - -(defn resize-wasm-text-all - [ids] - (ptk/reify ::resize-wasm-text-all - ptk/WatchEvent - (watch [_ _ _] - (->> (rx/from ids) - (rx/map resize-wasm-text))))) - ;; -- Content helpers (defn- v2-content-has-text? @@ -178,7 +133,7 @@ {:undo-group (when new-shape? id)}) (dwm/apply-wasm-modifiers - (resize-wasm-text-modifiers shape content) + (dwwt/resize-wasm-text-modifiers shape content) {:undo-group (when new-shape? id)}))))) (let [content (d/merge (ted/export-content content) @@ -823,7 +778,7 @@ (when (features/active-feature? state "render-wasm/v1") ;; This delay is to give time for the font to be correctly rendered ;; in wasm. - (cond->> (rx/of (resize-wasm-text id)) + (cond->> (rx/of (dwwt/resize-wasm-text id)) (contains? attrs :font-id) (rx/delay 200))))))) @@ -973,11 +928,11 @@ (if (and (not= :fixed (:grow-type shape)) finalize?) (dwm/apply-wasm-modifiers - (resize-wasm-text-modifiers shape content) + (dwwt/resize-wasm-text-modifiers shape content) {:undo-group (when new-shape? id)}) (dwm/set-wasm-modifiers - (resize-wasm-text-modifiers shape content) + (dwwt/resize-wasm-text-modifiers shape content) {:undo-group (when new-shape? id)}))) (when finalize? diff --git a/frontend/src/app/main/data/workspace/tokens/application.cljs b/frontend/src/app/main/data/workspace/tokens/application.cljs index 7243951d34..ae953e8778 100644 --- a/frontend/src/app/main/data/workspace/tokens/application.cljs +++ b/frontend/src/app/main/data/workspace/tokens/application.cljs @@ -27,9 +27,9 @@ [app.main.data.workspace.colors :as wdc] [app.main.data.workspace.shape-layout :as dwsl] [app.main.data.workspace.shapes :as dwsh] - [app.main.data.workspace.texts :as dwt] [app.main.data.workspace.transforms :as dwtr] [app.main.data.workspace.undo :as dwu] + [app.main.data.workspace.wasm-text :as dwwt] [app.main.features :as features] [app.main.fonts :as fonts] [app.main.store :as st] @@ -304,7 +304,7 @@ (and affects-layout? (features/active-feature? state "render-wasm/v1")) (rx/merge - (rx/of (dwt/resize-wasm-text-all shape-ids)))))))) + (rx/of (dwwt/resize-wasm-text-all shape-ids)))))))) (defn update-line-height ([value shape-ids attributes] (update-line-height value shape-ids attributes nil)) @@ -363,7 +363,7 @@ :page-id page-id})) (features/active-feature? state "render-wasm/v1") (rx/merge - (rx/of (dwt/resize-wasm-text-all shape-ids)))))))) + (rx/of (dwwt/resize-wasm-text-all shape-ids)))))))) (defn- create-font-family-text-attrs [value] @@ -440,7 +440,7 @@ :page-id page-id})) (features/active-feature? state "render-wasm/v1") (rx/merge - (rx/of (dwt/resize-wasm-text-all shape-ids)))))))) + (rx/of (dwwt/resize-wasm-text-all shape-ids)))))))) (defn update-font-weight ([value shape-ids attributes] (update-font-weight value shape-ids attributes nil)) diff --git a/frontend/src/app/main/data/workspace/transforms.cljs b/frontend/src/app/main/data/workspace/transforms.cljs index 21812d7f74..e0e076e6ba 100644 --- a/frontend/src/app/main/data/workspace/transforms.cljs +++ b/frontend/src/app/main/data/workspace/transforms.cljs @@ -406,13 +406,13 @@ (ctm/change-property :grow-type new-grow-type))) modifiers))) - modif-tree - (-> (dwm/build-modif-tree ids objects get-modifier) - (gm/set-objects-modifiers objects))] + modif-tree (dwm/build-modif-tree ids objects get-modifier)] (if (features/active-feature? state "render-wasm/v1") (rx/of (dwm/apply-wasm-modifiers modif-tree {:ignore-snap-pixel true})) - (rx/of (dwm/apply-modifiers* objects modif-tree nil options)))))))) + + (let [modif-tree (gm/set-objects-modifiers modif-tree objects)] + (rx/of (dwm/apply-modifiers* objects modif-tree nil options))))))))) (defn change-orientation "Change orientation of shapes, from the sidebar options form. diff --git a/frontend/src/app/main/data/workspace/wasm_text.cljs b/frontend/src/app/main/data/workspace/wasm_text.cljs new file mode 100644 index 0000000000..2174ba7161 --- /dev/null +++ b/frontend/src/app/main/data/workspace/wasm_text.cljs @@ -0,0 +1,72 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.main.data.workspace.wasm-text + "Helpers/events to resize wasm text shapes without depending on workspace.texts. + + This exists to avoid circular deps: + workspace.texts -> workspace.libraries -> workspace.texts" + (:require + [app.common.files.helpers :as cfh] + [app.common.geom.matrix :as gmt] + [app.common.geom.point :as gpt] + [app.common.types.modifiers :as ctm] + [app.main.data.helpers :as dsh] + [app.main.data.workspace.modifiers :as dwm] + [app.render-wasm.api :as wasm.api] + [beicon.v2.core :as rx] + [potok.v2.core :as ptk])) + +(defn resize-wasm-text-modifiers + ([shape] + (resize-wasm-text-modifiers shape (:content shape))) + + ([{:keys [id points selrect grow-type] :as shape} content] + (wasm.api/use-shape id) + (wasm.api/set-shape-text-content id content) + (wasm.api/set-shape-text-images id content) + + (let [dimension (wasm.api/get-text-dimensions) + width-scale (if (#{:fixed :auto-height} grow-type) + 1.0 + (/ (:width dimension) (:width selrect))) + height-scale (if (= :fixed grow-type) + 1.0 + (/ (:height dimension) (:height selrect))) + resize-v (gpt/point width-scale height-scale) + origin (first points)] + + {id + {:modifiers + (ctm/resize-modifiers + resize-v + origin + (:transform shape (gmt/matrix)) + (:transform-inverse shape (gmt/matrix)))}}))) + +(defn resize-wasm-text + "Resize a single text shape (auto-width/auto-height) by id. + No-op if the id is not a text shape or is :fixed." + [id] + (ptk/reify ::resize-wasm-text + ptk/WatchEvent + (watch [_ state _] + (let [objects (dsh/lookup-page-objects state) + shape (get objects id)] + (if (and (some? shape) + (cfh/text-shape? shape) + (not= :fixed (:grow-type shape))) + (rx/of (dwm/apply-wasm-modifiers (resize-wasm-text-modifiers shape))) + (rx/empty)))))) + +(defn resize-wasm-text-all + "Resize all text shapes (auto-width/auto-height) from a collection of ids." + [ids] + (ptk/reify ::resize-wasm-text-all + ptk/WatchEvent + (watch [_ _ _] + (->> (rx/from ids) + (rx/map resize-wasm-text))))) diff --git a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs index 5435ae0c77..159063210b 100644 --- a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs +++ b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs @@ -36,10 +36,12 @@ (defn- hide-popover [node] - (dom/unset-css-property! node "block-size") - (dom/unset-css-property! node "inset-block-start") - (dom/unset-css-property! node "inset-inline-start") - (.hidePopover ^js node)) + (when (and (some? node) + (fn? (.-hidePopover node))) + (dom/unset-css-property! node "block-size") + (dom/unset-css-property! node "inset-block-start") + (dom/unset-css-property! node "inset-inline-start") + (.hidePopover ^js node))) (defn- calculate-placement-bounding-rect "Given a placement, calcultates the bounding rect for it taking in diff --git a/frontend/src/app/main/ui/forms.cljs b/frontend/src/app/main/ui/forms.cljs index 6e49615470..0fe34d1f25 100644 --- a/frontend/src/app/main/ui/forms.cljs +++ b/frontend/src/app/main/ui/forms.cljs @@ -16,7 +16,7 @@ (def context (mf/create-context nil)) (mf/defc form-input* - [{:keys [name] :rest props}] + [{:keys [name trim] :rest props}] (let [form (mf/use-ctx context) input-name name @@ -33,7 +33,7 @@ (mf/deps input-name) (fn [event] (let [value (-> event dom/get-target dom/get-input-value)] - (fm/on-input-change form input-name value true)))) + (fm/on-input-change form input-name value trim)))) props (mf/spread-props props {:on-change on-change diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs index 0989aab7b2..05ae7da668 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs @@ -15,6 +15,7 @@ [app.main.data.workspace.shortcuts :as sc] [app.main.data.workspace.texts :as dwt] [app.main.data.workspace.undo :as dwu] + [app.main.data.workspace.wasm-text :as dwwt] [app.main.features :as features] [app.main.refs :as refs] [app.main.store :as st] @@ -141,7 +142,7 @@ (dwsh/update-shapes ids #(assoc % :grow-type grow-type))) (when (features/active-feature? @st/state "render-wasm/v1") - (st/emit! (dwt/resize-wasm-text-all ids))) + (st/emit! (dwwt/resize-wasm-text-all ids))) ;; We asynchronously commit so every sychronous event is resolved first and inside the transaction (ts/schedule #(st/emit! (dwu/commit-undo-transaction uid)))) (when (some? on-blur) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs index 8feb36761c..85fd13a828 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs @@ -11,6 +11,7 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.types.color :as cl] + [app.common.types.token :as cto] [app.common.types.tokens-lib :as ctob] [app.main.data.style-dictionary :as sd] [app.main.data.tinycolor :as tinycolor] @@ -51,12 +52,15 @@ ;; Both variants provide identical color-picker and text-input behavior, but ;; differ in how they persist the value within the form’s nested structure. - (defn- resolve-value [tokens prev-token token-name value] - (let [token + (let [valid-token-name? + (and (string? token-name) + (re-matches cto/token-name-validation-regex token-name)) + + token {:value value - :name (if (str/blank? token-name) + :name (if (or (not valid-token-name?) (str/blank? token-name)) "__PENPOT__TOKEN__NAME__PLACEHOLDER__" token-name)} diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs index 80f2d91133..df76d47113 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs @@ -50,9 +50,13 @@ (defn- resolve-value [tokens prev-token token-name value] - (let [token + (let [valid-token-name? + (and (string? token-name) + (re-matches cto/token-name-validation-regex token-name)) + + token {:value (cto/split-font-family value) - :name (if (str/blank? token-name) + :name (if (or (not valid-token-name?) (str/blank? token-name)) "__PENPOT__TOKEN__NAME__PLACEHOLDER__" token-name)} diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs index 0f1b2a79b1..a5c4ddd0dc 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs @@ -8,6 +8,7 @@ (:require [app.common.data :as d] [app.common.files.tokens :as cft] + [app.common.types.token :as cto] [app.common.types.tokens-lib :as ctob] [app.main.data.style-dictionary :as sd] [app.main.data.workspace.tokens.format :as dwtf] @@ -140,9 +141,13 @@ (defn- resolve-value [tokens prev-token token-name value] - (let [token + (let [valid-token-name? + (and (string? token-name) + (re-matches cto/token-name-validation-regex token-name)) + + token {:value value - :name (if (str/blank? token-name) + :name (if (or (not valid-token-name?) (str/blank? token-name)) "__PENPOT__TOKEN__NAME__PLACEHOLDER__" token-name)} tokens diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs index eb602911fa..abbbd17719 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs @@ -270,7 +270,13 @@ :placeholder (tr "workspace.tokens.enter-token-name" token-title) :max-length max-input-length :variant "comfortable" - :auto-focus true}]] + :trim true + :auto-focus true}] + + (when (and warning-name-change? (= action "edit")) + [:div {:class (stl/css :warning-name-change-notification-wrapper)} + [:> context-notification* + {:level :warning :appearance :ghost} (tr "workspace.tokens.warning-name-change")]])] [:div {:class (stl/css :input-row)} (case type diff --git a/frontend/src/app/util/dom.cljs b/frontend/src/app/util/dom.cljs index e22de0dbba..ffa2b8f361 100644 --- a/frontend/src/app/util/dom.cljs +++ b/frontend/src/app/util/dom.cljs @@ -106,17 +106,20 @@ (defn stop-propagation [^js event] - (when event + (when (and (some? event) + (fn? (.-stopPropagation event))) (.stopPropagation event))) (defn stop-immediate-propagation [^js event] - (when event + (when (and (some? event) + (fn? (.-stopImmediatePropagation event))) (.stopImmediatePropagation event))) (defn prevent-default [^js event] - (when event + (when (and (some? event) + (fn? (.-preventDefault event))) (.preventDefault event))) (defn get-target diff --git a/frontend/src/app/util/keyboard.cljs b/frontend/src/app/util/keyboard.cljs index 5ed595a97c..8c43ecdef0 100644 --- a/frontend/src/app/util/keyboard.cljs +++ b/frontend/src/app/util/keyboard.cljs @@ -7,15 +7,16 @@ (ns app.util.keyboard (:require [app.config :as cfg] + [app.util.dom :as dom] [cuerdas.core :as str])) (defrecord KeyboardEvent [type key shift ctrl alt meta mod editing native-event] Object (preventDefault [_] - (.preventDefault native-event)) + (dom/prevent-default native-event)) (stopPropagation [_] - (.stopPropagation native-event))) + (dom/stop-propagation native-event))) (defn keyboard-event? [o] diff --git a/frontend/text-editor/src/editor/TextEditor.js b/frontend/text-editor/src/editor/TextEditor.js index cf7ede4ec6..20828c1264 100644 --- a/frontend/text-editor/src/editor/TextEditor.js +++ b/frontend/text-editor/src/editor/TextEditor.js @@ -405,12 +405,8 @@ export class TextEditor extends EventTarget { if (e.inputType in commands) { const command = commands[e.inputType]; - if (!this.#selectionController.startMutation()) { - return; - } command(e, this, this.#selectionController); - const mutations = this.#selectionController.endMutation(); - this.#notifyLayout(LayoutType.FULL, mutations); + this.#notifyLayout(LayoutType.FULL); } }; @@ -456,19 +452,12 @@ export class TextEditor extends EventTarget { if ((e.ctrlKey || e.metaKey) && e.key === "Backspace") { e.preventDefault(); - - if (!this.#selectionController.startMutation()) { - return; - } - if (this.#selectionController.isCollapsed) { this.#selectionController.removeWordBackward(); } else { this.#selectionController.removeSelected(); } - - const mutations = this.#selectionController.endMutation(); - this.#notifyLayout(LayoutType.FULL, mutations); + this.#notifyLayout(LayoutType.FULL); } }; @@ -476,14 +465,12 @@ export class TextEditor extends EventTarget { * Notifies that the edited texts needs layout. * * @param {'full'|'partial'} type - * @param {CommandMutations} mutations */ - #notifyLayout(type = LayoutType.FULL, mutations) { + #notifyLayout(type = LayoutType.FULL) { this.dispatchEvent( new CustomEvent("needslayout", { detail: { type: type, - mutations: mutations, }, }), ); @@ -630,10 +617,8 @@ export class TextEditor extends EventTarget { * @returns {TextEditor} */ applyStylesToSelection(styles) { - this.#selectionController.startMutation(); this.#selectionController.applyStyles(styles); - const mutations = this.#selectionController.endMutation(); - this.#notifyLayout(LayoutType.FULL, mutations); + this.#notifyLayout(LayoutType.FULL); this.#changeController.notifyImmediately(); return this; } diff --git a/frontend/text-editor/src/editor/commands/CommandMutations.js b/frontend/text-editor/src/editor/commands/CommandMutations.js deleted file mode 100644 index fca36be147..0000000000 --- a/frontend/text-editor/src/editor/commands/CommandMutations.js +++ /dev/null @@ -1,66 +0,0 @@ -/** - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - * - * Copyright (c) KALEIDOS INC - */ - -/** - * Command mutations - */ -export class CommandMutations { - #added = new Set(); - #removed = new Set(); - #updated = new Set(); - - constructor(added, updated, removed) { - if (added && Array.isArray(added)) this.#added = new Set(added); - if (updated && Array.isArray(updated)) this.#updated = new Set(updated); - if (removed && Array.isArray(removed)) this.#removed = new Set(removed); - } - - get added() { - return this.#added; - } - - get removed() { - return this.#removed; - } - - get updated() { - return this.#updated; - } - - clear() { - this.#added.clear(); - this.#removed.clear(); - this.#updated.clear(); - } - - dispose() { - this.#added.clear(); - this.#added = null; - this.#removed.clear(); - this.#removed = null; - this.#updated.clear(); - this.#updated = null; - } - - add(node) { - this.#added.add(node); - return this; - } - - remove(node) { - this.#removed.add(node); - return this; - } - - update(node) { - this.#updated.add(node); - return this; - } -} - -export default CommandMutations; diff --git a/frontend/text-editor/src/editor/commands/CommandMutations.test.js b/frontend/text-editor/src/editor/commands/CommandMutations.test.js deleted file mode 100644 index 0ed4c1d7e3..0000000000 --- a/frontend/text-editor/src/editor/commands/CommandMutations.test.js +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, test, expect } from "vitest"; -import CommandMutations from "./CommandMutations.js"; - -describe("CommandMutations", () => { - test("should create a new CommandMutations", () => { - const mutations = new CommandMutations(); - expect(mutations).toHaveProperty("added"); - expect(mutations).toHaveProperty("updated"); - expect(mutations).toHaveProperty("removed"); - }); - - test("should create an initialized new CommandMutations", () => { - const mutations = new CommandMutations([1], [2], [3]); - expect(mutations.added.size).toBe(1); - expect(mutations.updated.size).toBe(1); - expect(mutations.removed.size).toBe(1); - expect(mutations.added.has(1)).toBe(true); - expect(mutations.updated.has(2)).toBe(true); - expect(mutations.removed.has(3)).toBe(true); - }); - - test("should add an added node to a CommandMutations", () => { - const mutations = new CommandMutations(); - mutations.add(1); - expect(mutations.added.has(1)).toBe(true); - }); - - test("should add an updated node to a CommandMutations", () => { - const mutations = new CommandMutations(); - mutations.update(1); - expect(mutations.updated.has(1)).toBe(true); - }); - - test("should add an removed node to a CommandMutations", () => { - const mutations = new CommandMutations(); - mutations.remove(1); - expect(mutations.removed.has(1)).toBe(true); - }); - - test("should clear a CommandMutations", () => { - const mutations = new CommandMutations(); - mutations.add(1); - mutations.update(2); - mutations.remove(3); - expect(mutations.added.has(1)).toBe(true); - expect(mutations.added.size).toBe(1); - expect(mutations.updated.has(2)).toBe(true); - expect(mutations.updated.size).toBe(1); - expect(mutations.removed.has(3)).toBe(true); - expect(mutations.removed.size).toBe(1); - - mutations.clear(); - expect(mutations.added.size).toBe(0); - expect(mutations.added.has(1)).toBe(false); - expect(mutations.updated.size).toBe(0); - expect(mutations.updated.has(1)).toBe(false); - expect(mutations.removed.size).toBe(0); - expect(mutations.removed.has(1)).toBe(false); - }); - - test("should dispose a CommandMutations", () => { - const mutations = new CommandMutations(); - mutations.add(1); - mutations.update(2); - mutations.remove(3); - mutations.dispose(); - expect(mutations.added).toBe(null); - expect(mutations.updated).toBe(null); - expect(mutations.removed).toBe(null); - }); -}); diff --git a/frontend/text-editor/src/editor/content/Text.test.js b/frontend/text-editor/src/editor/content/Text.test.js index 45924d655d..416693013e 100644 --- a/frontend/text-editor/src/editor/content/Text.test.js +++ b/frontend/text-editor/src/editor/content/Text.test.js @@ -1,5 +1,5 @@ import { describe, test, expect } from "vitest"; -import { insertInto, removeBackward, removeForward, replaceWith } from "./Text"; +import { insertInto, removeSlice, removeBackward, removeForward, removeWordBackward, replaceWith, findPreviousWordBoundary } from "./Text"; describe("Text", () => { test("* should throw when passed wrong parameters", () => { @@ -51,4 +51,23 @@ describe("Text", () => { test("`removeForward` should remove string forward from offset 6", () => { expect(removeForward("Hello, World!", 6)).toBe("Hello,World!"); }); + + test("`removeSlice` should remove a part of a text", () => { + expect(removeSlice("Hello, World!", 7, 12)).toBe("Hello, !"); + }); + + test("`findPreviousWordBoundary` edge cases", () => { + expect(findPreviousWordBoundary(null)).toBe(0); + expect(findPreviousWordBoundary("Hello, World!", 0)).toBe(0); + expect(findPreviousWordBoundary(" Hello, World!", 3)).toBe(0); + }) + + test("`removeWordBackward` with no text should return an empty string", () => { + expect(removeWordBackward(null, 0)).toBe(""); + }); + + test("`removeWordBackward` should remove a word backward", () => { + expect(removeWordBackward("Hello, World!", 13)).toBe("Hello, World"); + expect(removeWordBackward("Hello, World", 12)).toBe("Hello, "); + }); }); diff --git a/frontend/text-editor/src/editor/content/dom/Color.test.js b/frontend/text-editor/src/editor/content/dom/Color.test.js index a5d44addd1..17e1f727a4 100644 --- a/frontend/text-editor/src/editor/content/dom/Color.test.js +++ b/frontend/text-editor/src/editor/content/dom/Color.test.js @@ -2,7 +2,7 @@ import { describe, test, expect } from "vitest"; import { getFills } from "./Color.js"; /* @vitest-environment jsdom */ -describe("Color", () => { +describe.skip("Color", () => { test("getFills", () => { expect(getFills("#aa0000")).toBe( '[["^ ","~:fill-color","#aa0000","~:fill-opacity",1]]', diff --git a/frontend/text-editor/src/editor/controllers/SelectionController.js b/frontend/text-editor/src/editor/controllers/SelectionController.js index 24cb37d272..6d4c11c136 100644 --- a/frontend/text-editor/src/editor/controllers/SelectionController.js +++ b/frontend/text-editor/src/editor/controllers/SelectionController.js @@ -49,7 +49,6 @@ import { } from "../content/dom/TextNode.js"; import TextNodeIterator from "../content/dom/TextNodeIterator.js"; import TextEditor from "../TextEditor.js"; -import CommandMutations from "../commands/CommandMutations.js"; import { isRoot, setRootStyles } from "../content/dom/Root.js"; import { SelectionDirection } from "./SelectionDirection.js"; import { SafeGuard } from "./SafeGuard.js"; @@ -145,13 +144,6 @@ export class SelectionController extends EventTarget { */ #debug = null; - /** - * Command Mutations. - * - * @type {CommandMutations} - */ - #mutations = new CommandMutations(); - /** * Style defaults. * @@ -449,14 +441,14 @@ export class SelectionController extends EventTarget { dispose() { document.removeEventListener("selectionchange", this.#onSelectionChange); this.#textEditor = null; + this.#currentStyle = null; + this.#options = 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; } /** @@ -522,28 +514,6 @@ export class SelectionController extends EventTarget { return true; } - /** - * Marks the start of a mutation. - * - * Clears all the mutations kept in CommandMutations. - * - * @returns {boolean} - */ - startMutation() { - this.#mutations.clear(); - if (!this.#focusNode) return false; - return true; - } - - /** - * Marks the end of a mutation. - * - * @returns {CommandMutations} - */ - endMutation() { - return this.#mutations; - } - /** * Selects all content. * @@ -597,11 +567,18 @@ export class SelectionController extends EventTarget { * @returns {SelectionController} */ cursorToEnd() { + const root = this.#textEditor.root; + const range = document.createRange(); //Create a range (a range is a like the selection but invisible) - range.selectNodeContents(this.#textEditor.element); + range.setStart(root.lastChild.firstChild.firstChild, root.lastChild.firstChild.firstChild?.nodeValue?.length ?? 0); + range.setEnd(root.lastChild.firstChild.firstChild, root.lastChild.firstChild.firstChild?.nodeValue?.length ?? 0); range.collapse(false); + this.#selection.removeAllRanges(); this.#selection.addRange(range); + + this.#updateState(); + return this; } @@ -1340,7 +1317,6 @@ export class SelectionController extends EventTarget { if (this.focusNode.nodeValue !== removedData) { this.focusNode.nodeValue = removedData; - this.#mutations.update(this.focusTextSpan); } const paragraph = this.focusParagraph; @@ -1383,7 +1359,6 @@ export class SelectionController extends EventTarget { this.focusOffset, newText, ); - this.#mutations.update(this.focusTextSpan); return this.collapse(this.focusNode, this.focusOffset + newText.length); } @@ -1447,7 +1422,6 @@ export class SelectionController extends EventTarget { this.#textEditor.root.replaceChildren(newParagraph); return this.collapse(newTextNode, newText.length + 1); } - this.#mutations.update(this.focusTextSpan); return this.collapse(this.focusNode, startOffset + newText.length); } @@ -1525,8 +1499,6 @@ export class SelectionController extends EventTarget { const currentParagraph = this.focusParagraph; const newParagraph = createEmptyParagraph(this.#currentStyle); currentParagraph.after(newParagraph); - this.#mutations.update(currentParagraph); - this.#mutations.add(newParagraph); return this.collapse(newParagraph.firstChild.firstChild, 0); } @@ -1537,8 +1509,6 @@ export class SelectionController extends EventTarget { const currentParagraph = this.focusParagraph; const newParagraph = createEmptyParagraph(this.#currentStyle); currentParagraph.before(newParagraph); - this.#mutations.update(currentParagraph); - this.#mutations.add(newParagraph); return this.collapse(currentParagraph.firstChild.firstChild, 0); } @@ -1553,8 +1523,6 @@ export class SelectionController extends EventTarget { this.#focusOffset, ); this.focusParagraph.after(newParagraph); - this.#mutations.update(currentParagraph); - this.#mutations.add(newParagraph); return this.collapse(newParagraph.firstChild.firstChild, 0); } @@ -1586,10 +1554,6 @@ export class SelectionController extends EventTarget { this.focusOffset, ); currentParagraph.after(newParagraph); - - this.#mutations.update(currentParagraph); - this.#mutations.add(newParagraph); - // FIXME: Missing collapse? } @@ -1610,7 +1574,6 @@ export class SelectionController extends EventTarget { const previousOffset = isLineBreak(previousTextSpan.firstChild) ? 0 : previousTextSpan.firstChild.nodeValue?.length || 0; - this.#mutations.remove(paragraphToBeRemoved); return this.collapse(previousTextSpan.firstChild, previousOffset); } @@ -1632,8 +1595,6 @@ export class SelectionController extends EventTarget { } else { mergeParagraphs(previousParagraph, currentParagraph); } - this.#mutations.remove(currentParagraph); - this.#mutations.update(previousParagraph); return this.collapse(previousTextSpan.firstChild, previousOffset); } @@ -1647,8 +1608,6 @@ export class SelectionController extends EventTarget { return; } mergeParagraphs(this.focusParagraph, nextParagraph); - this.#mutations.update(currentParagraph); - this.#mutations.remove(nextParagraph); // FIXME: Missing collapse? } @@ -1665,7 +1624,6 @@ export class SelectionController extends EventTarget { paragraphToBeRemoved.remove(); const nextTextSpan = nextParagraph.firstChild; const nextOffset = this.focusOffset; - this.#mutations.remove(paragraphToBeRemoved); return this.collapse(nextTextSpan.firstChild, nextOffset); } @@ -1680,7 +1638,6 @@ export class SelectionController extends EventTarget { for (const textSpan of affectedTextSpans) { if (textSpan.textContent === "") { textSpan.remove(); - this.#mutations.remove(textSpan); } } @@ -1688,7 +1645,6 @@ export class SelectionController extends EventTarget { for (const paragraph of affectedParagraphs) { if (paragraph.children.length === 0) { paragraph.remove(); - this.#mutations.remove(paragraph); } } } diff --git a/frontend/text-editor/src/editor/controllers/SelectionController.test.js b/frontend/text-editor/src/editor/controllers/SelectionController.test.js index cfb04488ad..ff7e372c9d 100644 --- a/frontend/text-editor/src/editor/controllers/SelectionController.test.js +++ b/frontend/text-editor/src/editor/controllers/SelectionController.test.js @@ -581,6 +581,136 @@ describe("SelectionController", () => { expect(textEditorMock.root.textContent).toBe(""); }); + test("`insertParagraph` should insert a new paragraph in an empty editor", () => { + const textEditorMock = TextEditorMock.createTextEditorMockEmpty(); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController(textEditorMock, selection); + focus( + selection, + textEditorMock, + root.firstChild.firstChild.firstChild, + 0, + ); + selectionController.insertParagraph(); + expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.dataset.itype).toBe("root"); + expect(textEditorMock.root.children.length).toBe(2); + expect(textEditorMock.root.children.item(0)).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.children.item(0).dataset.itype).toBe("paragraph"); + expect(textEditorMock.root.children.item(0).firstChild).toBeInstanceOf( + HTMLSpanElement, + ); + expect(textEditorMock.root.children.item(0).firstChild.dataset.itype).toBe("span"); + expect(textEditorMock.root.children.item(1)).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.children.item(1).dataset.itype).toBe("paragraph"); + expect(textEditorMock.root.children.item(1).firstChild).toBeInstanceOf( + HTMLSpanElement, + ); + expect(textEditorMock.root.children.item(1).firstChild.dataset.itype).toBe( + "span", + ); + expect(textEditorMock.root.textContent).toBe(""); + }); + + test("`insertParagraph` should insert a new paragraph after a text", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWith([ + ["Hello, World!"] + ]); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + focus( + selection, + textEditorMock, + root.firstChild.firstChild.firstChild, + "Hello, World!".length + ); + selectionController.insertParagraph(); + expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.dataset.itype).toBe("root"); + expect(textEditorMock.root.children.length).toBe(2); + expect(textEditorMock.root.children.item(0)).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.children.item(0).dataset.itype).toBe( + "paragraph", + ); + expect(textEditorMock.root.children.item(0).firstChild).toBeInstanceOf( + HTMLSpanElement, + ); + expect(textEditorMock.root.children.item(0).firstChild.dataset.itype).toBe( + "span", + ); + expect(textEditorMock.root.children.item(0).firstChild.textContent).toBe( + "Hello, World!", + ); + expect(textEditorMock.root.children.item(1)).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.children.item(1).dataset.itype).toBe( + "paragraph", + ); + expect(textEditorMock.root.children.item(1).firstChild).toBeInstanceOf( + HTMLSpanElement, + ); + expect(textEditorMock.root.children.item(1).firstChild.dataset.itype).toBe( + "span", + ); + expect(textEditorMock.root.children.item(1).firstChild.firstChild).toBeInstanceOf( + HTMLBRElement, + ); + expect(textEditorMock.root.textContent).toBe("Hello, World!"); + }); + + test("`insertParagraph` should insert a new paragraph before a text", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWith([ + ["Hello, World!"], + ]); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + focus( + selection, + textEditorMock, + root.firstChild.firstChild.firstChild, + 0, + ); + selectionController.insertParagraph(); + expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.dataset.itype).toBe("root"); + expect(textEditorMock.root.children.length).toBe(2); + expect(textEditorMock.root.children.item(0)).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.children.item(0).dataset.itype).toBe( + "paragraph", + ); + expect(textEditorMock.root.children.item(0).firstChild).toBeInstanceOf( + HTMLSpanElement, + ); + expect(textEditorMock.root.children.item(0).firstChild.dataset.itype).toBe( + "span", + ); + expect(textEditorMock.root.children.item(0).firstChild.firstChild).toBeInstanceOf( + HTMLBRElement, + ); + expect(textEditorMock.root.children.item(1)).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.children.item(1).dataset.itype).toBe( + "paragraph", + ); + expect(textEditorMock.root.children.item(1).firstChild).toBeInstanceOf( + HTMLSpanElement, + ); + expect(textEditorMock.root.children.item(1).firstChild.dataset.itype).toBe( + "span", + ); + expect(textEditorMock.root.children.item(1).firstChild.textContent).toBe( + "Hello, World!", + ); + expect(textEditorMock.root.textContent).toBe("Hello, World!"); + }); + test("`mergeBackwardParagraph` should merge two paragraphs in backward direction (backspace)", () => { const textEditorMock = TextEditorMock.createTextEditorMockWith([ ["Hello, "], @@ -1027,7 +1157,7 @@ describe("SelectionController", () => { ); }); - test.skip("`removeSelected` multiple paragraphs", () => { + test("`removeSelected` multiple paragraphs", () => { const textEditorMock = TextEditorMock.createTextEditorMockWith([ ["Hello, "], ["\n"], @@ -1392,7 +1522,10 @@ describe("SelectionController", () => { root.firstChild.lastChild.firstChild.nodeValue.length - 3, ); selectionController.applyStyles({ + "font-family": "Montserrat, sans-serif", "font-weight": "bold", + "--fills": + '[["^ ","~:fill-color","#000000","~:fill-opacity",1],["^ ","~:fill-color","#aa0000","~:fill-opacity",1]]', }); expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); expect(textEditorMock.root.children.length).toBe(1); @@ -1492,4 +1625,68 @@ describe("SelectionController", () => { "ld!", ); }); + + test("`selectAll` should select everything", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([ + createParagraphWith(["Hello, "], { + "font-style": "italic", + }), + createParagraphWith(["World!"], { + "font-style": "oblique", + }), + ]); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController(textEditorMock, selection); + textEditorMock.element.focus(); + selectionController.selectAll(); + expect(selectionController.anchorNode).toBe( + root.firstChild.firstChild.firstChild + ); + expect(selectionController.focusNode).toBe( + root.lastChild.firstChild.firstChild, + ); + }); + + test("`cursorToEnd` should move cursor to the end", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([ + createParagraphWith(["Hello, "], { + "font-style": "italic", + }), + createParagraphWith(["World!"], { + "font-style": "oblique", + }), + ]); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController(textEditorMock, selection); + textEditorMock.element.focus(); + selectionController.cursorToEnd(); + expect(selectionController.focusNode).toBe(root.lastChild.firstChild.firstChild); + expect(selectionController.focusAtEnd).toBeTruthy(); + }) + + test("`dispose` should release every held reference", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([ + createParagraphWith(["Hello, "], { + "font-style": "italic", + }), + createParagraphWith(["World!"], { + "font-style": "oblique", + }), + ]); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController(textEditorMock, selection); + focus( + selection, + textEditorMock, + root.firstChild.firstChild.firstChild, + 0 + ); + selectionController.dispose(); + expect(selectionController.selection).toBe(null); + expect(selectionController.currentStyle).toBe(null); + expect(selectionController.options).toBe(null); + }); }); diff --git a/render-wasm/src/render/strokes.rs b/render-wasm/src/render/strokes.rs index 103831013a..0d7797b8fb 100644 --- a/render-wasm/src/render/strokes.rs +++ b/render-wasm/src/render/strokes.rs @@ -27,8 +27,8 @@ fn draw_stroke_on_rect( // - The same rect if it's a center stroke // - A bigger rect if it's an outer stroke // - A smaller rect if it's an outer stroke - let stroke_rect = stroke.outer_rect(rect); - let mut paint = stroke.to_paint(selrect, svg_attrs, scale, antialias); + let stroke_rect = stroke.aligned_rect(rect, scale); + let mut paint = stroke.to_paint(selrect, svg_attrs, antialias); // Apply both blur and shadow filters if present, composing them if necessary. let filter = compose_filters(blur, shadow); @@ -63,8 +63,8 @@ fn draw_stroke_on_circle( // - The same oval if it's a center stroke // - A bigger oval if it's an outer stroke // - A smaller oval if it's an outer stroke - let stroke_rect = stroke.outer_rect(rect); - let mut paint = stroke.to_paint(selrect, svg_attrs, scale, antialias); + let stroke_rect = stroke.aligned_rect(rect, scale); + let mut paint = stroke.to_paint(selrect, svg_attrs, antialias); // Apply both blur and shadow filters if present, composing them if necessary. let filter = compose_filters(blur, shadow); @@ -131,7 +131,6 @@ pub fn draw_stroke_on_path( selrect: &Rect, path_transform: Option<&Matrix>, svg_attrs: Option<&SvgAttrs>, - scale: f32, shadow: Option<&ImageFilter>, blur: Option<&ImageFilter>, antialias: bool, @@ -142,7 +141,7 @@ pub fn draw_stroke_on_path( let is_open = path.is_open(); let mut paint: skia_safe::Handle<_> = - stroke.to_stroked_paint(is_open, selrect, svg_attrs, scale, antialias); + stroke.to_stroked_paint(is_open, selrect, svg_attrs, antialias); let filter = compose_filters(blur, shadow); paint.set_image_filter(filter); @@ -166,7 +165,6 @@ pub fn draw_stroke_on_path( canvas, is_open, svg_attrs, - scale, blur, antialias, ); @@ -218,7 +216,6 @@ fn handle_stroke_caps( canvas: &skia::Canvas, is_open: bool, svg_attrs: Option<&SvgAttrs>, - scale: f32, blur: Option<&ImageFilter>, antialias: bool, ) { @@ -233,8 +230,7 @@ fn handle_stroke_caps( let first_point = points.first().unwrap(); let last_point = points.last().unwrap(); - let mut paint_stroke = - stroke.to_stroked_paint(is_open, selrect, svg_attrs, scale, antialias); + let mut paint_stroke = stroke.to_stroked_paint(is_open, selrect, svg_attrs, antialias); if let Some(filter) = blur { paint_stroke.set_image_filter(filter.clone()); @@ -405,7 +401,7 @@ fn draw_image_stroke_in_container( // Draw the stroke based on the shape type, we are using this stroke as // a "selector" of the area of the image we want to show. - let outer_rect = stroke.outer_rect(container); + let outer_rect = stroke.aligned_rect(container, scale); match &shape.shape_type { shape_type @ (Type::Rect(_) | Type::Frame(_)) => { @@ -450,8 +446,7 @@ fn draw_image_stroke_in_container( } } let is_open = p.is_open(); - let mut paint = - stroke.to_stroked_paint(is_open, &outer_rect, svg_attrs, scale, antialias); + let mut paint = stroke.to_stroked_paint(is_open, &outer_rect, svg_attrs, antialias); canvas.draw_path(&path, &paint); if stroke.render_kind(is_open) == StrokeKind::Outer { // Small extra inner stroke to overlap with the fill @@ -466,7 +461,6 @@ fn draw_image_stroke_in_container( canvas, is_open, svg_attrs, - scale, shape.image_filter(1.).as_ref(), antialias, ); @@ -662,7 +656,6 @@ fn render_internal( &selrect, path_transform.as_ref(), svg_attrs, - scale, shadow, shape.image_filter(1.).as_ref(), antialias, @@ -685,14 +678,13 @@ pub fn render_text_paths( shadow: Option<&ImageFilter>, antialias: bool, ) { - let scale = render_state.get_scale(); let canvas = render_state .surfaces .canvas_and_mark_dirty(surface_id.unwrap_or(SurfaceId::Strokes)); let selrect = &shape.selrect; let svg_attrs = shape.svg_attrs.as_ref(); let mut paint: skia_safe::Handle<_> = - stroke.to_text_stroked_paint(false, selrect, svg_attrs, scale, antialias); + stroke.to_text_stroked_paint(false, selrect, svg_attrs, antialias); if let Some(filter) = shadow { paint.set_image_filter(filter.clone()); diff --git a/render-wasm/src/shapes/strokes.rs b/render-wasm/src/shapes/strokes.rs index 5177ec7e03..e45c011a14 100644 --- a/render-wasm/src/shapes/strokes.rs +++ b/render-wasm/src/shapes/strokes.rs @@ -1,3 +1,4 @@ +use crate::math::is_close_to; use crate::shapes::fills::{Fill, SolidColor}; use skia_safe::{self as skia, Rect}; @@ -144,6 +145,15 @@ impl Stroke { } } + pub fn aligned_rect(&self, rect: &Rect, scale: f32) -> Rect { + let stroke_rect = self.outer_rect(rect); + if self.kind != StrokeKind::Center { + return stroke_rect; + } + + align_rect_to_half_pixel(&stroke_rect, self.width, scale) + } + pub fn outer_corners(&self, corners: &Corners) -> Corners { let offset = match self.kind { StrokeKind::Center => 0.0, @@ -162,7 +172,6 @@ impl Stroke { &self, rect: &Rect, svg_attrs: Option<&SvgAttrs>, - scale: f32, antialias: bool, ) -> skia::Paint { let mut paint = self.fill.to_paint(rect, antialias); @@ -171,7 +180,7 @@ impl Stroke { let width = match self.kind { StrokeKind::Inner => self.width, StrokeKind::Center => self.width, - StrokeKind::Outer => self.width + (1. / scale), + StrokeKind::Outer => self.width, }; paint.set_stroke_width(width); @@ -230,10 +239,9 @@ impl Stroke { is_open: bool, rect: &Rect, svg_attrs: Option<&SvgAttrs>, - scale: f32, antialias: bool, ) -> skia::Paint { - let mut paint = self.to_paint(rect, svg_attrs, scale, antialias); + let mut paint = self.to_paint(rect, svg_attrs, antialias); match self.render_kind(is_open) { StrokeKind::Inner => { paint.set_stroke_width(2. * paint.stroke_width()); @@ -254,10 +262,9 @@ impl Stroke { is_open: bool, rect: &Rect, svg_attrs: Option<&SvgAttrs>, - scale: f32, antialias: bool, ) -> skia::Paint { - let mut paint = self.to_paint(rect, svg_attrs, scale, antialias); + let mut paint = self.to_paint(rect, svg_attrs, antialias); match self.render_kind(is_open) { StrokeKind::Inner => { paint.set_stroke_width(2. * paint.stroke_width()); @@ -284,6 +291,38 @@ impl Stroke { } } +fn align_rect_to_half_pixel(rect: &Rect, stroke_width: f32, scale: f32) -> Rect { + if scale <= 0.0 { + return *rect; + } + + let stroke_pixels = stroke_width * scale; + let stroke_pixels_rounded = stroke_pixels.round(); + if !is_close_to(stroke_pixels, stroke_pixels_rounded) { + return *rect; + } + + if (stroke_pixels_rounded as i32) % 2 == 0 { + return *rect; + } + + let left_px = rect.left * scale; + let top_px = rect.top * scale; + let target_frac = 0.5; + let dx_px = target_frac - (left_px - left_px.floor()); + let dy_px = target_frac - (top_px - top_px.floor()); + + if is_close_to(dx_px, 0.0) && is_close_to(dy_px, 0.0) { + return *rect; + } + + Rect::from_xywh( + rect.left + (dx_px / scale), + rect.top + (dy_px / scale), + rect.width(), + rect.height(), + ) +} fn cap_margin_for_cap(cap: Option, width: f32) -> f32 { match cap { Some(StrokeCap::LineArrow)