diff --git a/common/src/app/common/files/shapes_builder.cljc b/common/src/app/common/files/shapes_builder.cljc index 2db8f13f0a..004b495583 100644 --- a/common/src/app/common/files/shapes_builder.cljc +++ b/common/src/app/common/files/shapes_builder.cljc @@ -82,6 +82,113 @@ (declare create-svg-children) (declare parse-svg-element) +(defn- process-gradient-stops + "Processes gradient stops to extract stop-color and stop-opacity from style attributes + and convert them to direct attributes. This ensures stops with style='stop-color:#...;stop-opacity:1' + are properly converted to stop-color and stop-opacity attributes." + [stops] + (mapv (fn [stop] + (let [stop-attrs (:attrs stop) + stop-style (get stop-attrs :style) + ;; Parse style if it's a string using csvg/parse-style utility + parsed-style (when (and (string? stop-style) (seq stop-style)) + (csvg/parse-style stop-style)) + ;; Extract stop-color and stop-opacity from style + style-stop-color (when parsed-style (:stop-color parsed-style)) + style-stop-opacity (when parsed-style (:stop-opacity parsed-style)) + ;; Merge: use direct attributes first, then style values as fallback + final-attrs (cond-> stop-attrs + (and style-stop-color (not (contains? stop-attrs :stop-color))) + (assoc :stop-color style-stop-color) + + (and style-stop-opacity (not (contains? stop-attrs :stop-opacity))) + (assoc :stop-opacity style-stop-opacity) + + ;; Remove style attribute if we've extracted its values + (or style-stop-color style-stop-opacity) + (dissoc :style))] + (assoc stop :attrs final-attrs))) + stops)) + +(defn- resolve-gradient-href + "Resolves xlink:href references in gradients by merging the referenced gradient's + stops and attributes with the referencing gradient. This ensures gradients that + reference other gradients (like linearGradient3550 referencing linearGradient3536) + inherit the stops from the base gradient. + + According to SVG spec, when a gradient has xlink:href: + - It inherits all attributes from the referenced gradient + - It inherits all stops from the referenced gradient + - The referencing gradient's attributes override the base ones + - If the referencing gradient has stops, they replace the base stops + + Returns the defs map with all gradient href references resolved." + [defs] + (letfn [(resolve-gradient [gradient-id gradient-node defs visited] + (if (contains? visited gradient-id) + (do + #?(:cljs (js/console.warn "[resolve-gradient] Circular reference detected for" gradient-id) + :clj nil) + gradient-node) ;; Avoid circular references + (let [attrs (:attrs gradient-node) + href-id (or (:href attrs) (:xlink:href attrs)) + href-id (when (and (string? href-id) (pos? (count href-id))) + (subs href-id 1)) ;; Remove leading # + + base-gradient (when (and href-id (contains? defs href-id)) + (get defs href-id)) + + resolved-base (when base-gradient (resolve-gradient href-id base-gradient defs (conj visited gradient-id)))] + + (if resolved-base + ;; Merge: base gradient attributes + referencing gradient attributes + ;; Use referencing gradient's stops if present, otherwise use base stops + (let [base-attrs (:attrs resolved-base) + ref-attrs (:attrs gradient-node) + + ;; Start with base attributes (without id), then merge with ref attributes + ;; This ensures ref attributes override base ones + base-attrs-clean (dissoc base-attrs :id) + ref-attrs-clean (dissoc ref-attrs :href :xlink:href :id) + + ;; Special handling for gradientTransform: if both have it, combine them + base-transform (get base-attrs :gradientTransform) + ref-transform (get ref-attrs :gradientTransform) + combined-transform (cond + (and base-transform ref-transform) + (str base-transform " " ref-transform) ;; Apply base first, then ref + :else (or ref-transform base-transform)) + + ;; Merge attributes: base first, then ref (ref overrides) + merged-attrs (-> (d/deep-merge base-attrs-clean ref-attrs-clean) + (cond-> combined-transform + (assoc :gradientTransform combined-transform))) + + ;; If referencing gradient has content (stops), use it; otherwise use base content + final-content (if (seq (:content gradient-node)) + (:content gradient-node) + (:content resolved-base)) + + ;; Process stops to extract stop-color and stop-opacity from style attributes + processed-content (process-gradient-stops final-content) + + result {:tag (:tag gradient-node) + :attrs (assoc merged-attrs :id gradient-id) + :content processed-content}] + result) + ;; Process stops even for gradients without references to extract style attributes + (let [processed-content (process-gradient-stops (:content gradient-node))] + (assoc gradient-node :content processed-content))))))] + (let [gradient-tags #{:linearGradient :radialGradient} + result (reduce-kv + (fn [acc id node] + (if (contains? gradient-tags (:tag node)) + (assoc acc id (resolve-gradient id node defs #{})) + (assoc acc id node))) + {} + defs)] + result))) + (defn create-svg-shapes ([svg-data pos objects frame-id parent-id selected center?] (create-svg-shapes (uuid/next) svg-data pos objects frame-id parent-id selected center?)) @@ -112,6 +219,9 @@ (csvg/fix-percents) (csvg/extract-defs)) + ;; Resolve gradient href references in all defs before processing shapes + def-nodes (resolve-gradient-href def-nodes) + ;; In penpot groups have the size of their children. To ;; respect the imported svg size and empty space let's create ;; a transparent shape as background to respect the imported @@ -142,12 +252,23 @@ (reduce (partial create-svg-children objects selected frame-id root-id svg-data) [unames []] (d/enumerate (->> (:content svg-data) - (mapv #(csvg/inherit-attributes root-attrs %)))))] + (mapv #(csvg/inherit-attributes root-attrs %))))) - [root-shape children]))) + ;; Collect all defs from children and merge into root shape + all-defs-from-children (reduce (fn [acc child] + (if-let [child-defs (:svg-defs child)] + (merge acc child-defs) + acc)) + {} + children) + + ;; Merge defs from svg-data and children into root shape + root-shape-with-defs (assoc root-shape :svg-defs (merge def-nodes all-defs-from-children))] + + [root-shape-with-defs children]))) (defn create-raw-svg - [name frame-id {:keys [x y width height offset-x offset-y]} {:keys [attrs] :as data}] + [name frame-id {:keys [x y width height offset-x offset-y defs] :as svg-data} {:keys [attrs] :as data}] (let [props (csvg/attrs->props attrs) vbox (grc/make-rect offset-x offset-y width height)] (cts/setup-shape @@ -160,10 +281,11 @@ :y y :content data :svg-attrs props - :svg-viewbox vbox}))) + :svg-viewbox vbox + :svg-defs defs}))) (defn create-svg-root - [id frame-id parent-id {:keys [name x y width height offset-x offset-y attrs]}] + [id frame-id parent-id {:keys [name x y width height offset-x offset-y attrs defs] :as svg-data}] (let [props (-> (dissoc attrs :viewBox :view-box :xmlns) (d/without-keys csvg/inheritable-props) (csvg/attrs->props))] @@ -177,7 +299,8 @@ :height height :x (+ x offset-x) :y (+ y offset-y) - :svg-attrs props}))) + :svg-attrs props + :svg-defs defs}))) (defn create-svg-children [objects selected frame-id parent-id svg-data [unames children] [_index svg-element]] @@ -198,7 +321,7 @@ (defn create-group - [name frame-id {:keys [x y width height offset-x offset-y] :as svg-data} {:keys [attrs]}] + [name frame-id {:keys [x y width height offset-x offset-y defs] :as svg-data} {:keys [attrs]}] (let [transform (csvg/parse-transform (:transform attrs)) attrs (-> attrs (d/without-keys csvg/inheritable-props) @@ -214,7 +337,8 @@ :height height :svg-transform transform :svg-attrs attrs - :svg-viewbox vbox}))) + :svg-viewbox vbox + :svg-defs defs}))) (defn create-path-shape [name frame-id svg-data {:keys [attrs] :as data}] (when (and (contains? attrs :d) (seq (:d attrs))) @@ -523,6 +647,21 @@ :else (dm/str tag))] (dm/str "svg-" suffix))) +(defn- filter-valid-def-references + "Filters out false positive references that are not valid def IDs. + Filters out: + - Colors in style attributes (hex colors like #f9dd67) + - Style fragments that contain CSS keywords (like stop-opacity) + - References that don't exist in defs" + [ref-ids defs] + (let [is-style-fragment? (fn [ref-id] + (or (clr/hex-color-string? (str "#" ref-id)) + (str/includes? ref-id ";") ;; Contains CSS separator + (str/includes? ref-id "stop-opacity") ;; CSS keyword + (str/includes? ref-id "stop-color")))] ;; CSS keyword + (->> ref-ids + (remove is-style-fragment?) ;; Filter style fragments and hex colors + (filter #(contains? defs %))))) ;; Only existing defs (defn parse-svg-element [frame-id svg-data {:keys [tag attrs hidden] :as element} unames] @@ -534,7 +673,11 @@ (let [name (or (:id attrs) (tag->name tag)) att-refs (csvg/find-attr-references attrs) defs (get svg-data :defs) - references (csvg/find-def-references defs att-refs) + valid-refs (filter-valid-def-references att-refs defs) + all-refs (csvg/find-def-references defs valid-refs) + ;; Filter the final result to ensure all references are valid defs + ;; This prevents false positives from style attributes in gradient stops + references (filter-valid-def-references all-refs defs) href-id (or (:href attrs) (:xlink:href attrs) " ") href-id (if (and (string? href-id) diff --git a/common/src/app/common/svg.cljc b/common/src/app/common/svg.cljc index a3b309b14f..63092d0454 100644 --- a/common/src/app/common/svg.cljc +++ b/common/src/app/common/svg.cljc @@ -546,9 +546,19 @@ filter-values))) (defn extract-ids [val] - (when (some? val) + ;; Extract referenced ids from string values like "url(#myId)". + ;; Non-string values (maps, numbers, nil, etc.) return an empty seq + ;; to avoid re-seq type errors when attributes carry nested structures. + (cond + (string? val) (->> (re-seq xml-id-regex val) - (mapv second)))) + (mapv second)) + + (sequential? val) + (mapcat extract-ids val) + + :else + [])) (defn fix-dot-number "Fixes decimal numbers starting in dot but without leading 0" diff --git a/frontend/package.json b/frontend/package.json index cefd507cbd..86c1ad4d0b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -32,8 +32,8 @@ "e2e:server": "node ./scripts/e2e-server.js", "fmt:clj": "cljfmt fix --parallel=true src/ test/", "fmt:clj:check": "cljfmt check --parallel=false src/ test/", - "fmt:js": "yarn run prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js -w", - "fmt:js:check": "yarn run prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js", + "fmt:js": "yarn run prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js -c text-editor/**/*.js -w", + "fmt:js:check": "yarn run prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js text-editor/**/*.js", "lint:clj": "clj-kondo --parallel --lint src/", "lint:scss": "yarn run prettier -c resources/styles -c src/**/*.scss", "lint:scss:fix": "yarn run prettier -c resources/styles -c src/**/*.scss -w", diff --git a/frontend/playwright/helpers/Clipboard.js b/frontend/playwright/helpers/Clipboard.js index 046b298632..3a5dda2287 100644 --- a/frontend/playwright/helpers/Clipboard.js +++ b/frontend/playwright/helpers/Clipboard.js @@ -1,12 +1,12 @@ export class Clipboard { static Permission = { - ONLY_READ: ['clipboard-read'], - ONLY_WRITE: ['clipboard-write'], - ALL: ['clipboard-read', 'clipboard-write'] - } + ONLY_READ: ["clipboard-read"], + ONLY_WRITE: ["clipboard-write"], + ALL: ["clipboard-read", "clipboard-write"], + }; static enable(context, permissions) { - return context.grantPermissions(permissions) + return context.grantPermissions(permissions); } static writeText(page, text) { @@ -18,8 +18,8 @@ export class Clipboard { } constructor(page, context) { - this.page = page - this.context = context + this.page = page; + this.context = context; } enable(permissions) { diff --git a/frontend/playwright/helpers/Transit.js b/frontend/playwright/helpers/Transit.js index f56292ed6a..30d692d8b0 100644 --- a/frontend/playwright/helpers/Transit.js +++ b/frontend/playwright/helpers/Transit.js @@ -1,18 +1,16 @@ export class Transit { static parse(value) { - if (typeof value !== 'string') - return value + if (typeof value !== "string") return value; - if (value.startsWith('~')) - return value.slice(2) + if (value.startsWith("~")) return value.slice(2); - return value + return value; } static get(object, ...path) { let aux = object; for (const name of path) { - if (typeof name !== 'string') { + if (typeof name !== "string") { if (!(name in aux)) { return undefined; } diff --git a/frontend/playwright/ui/pages/BasePage.js b/frontend/playwright/ui/pages/BasePage.js index 475f2f6cd5..e9ad2df2c5 100644 --- a/frontend/playwright/ui/pages/BasePage.js +++ b/frontend/playwright/ui/pages/BasePage.js @@ -9,7 +9,7 @@ export class BasePage { */ static async mockRPCs(page, paths, options) { for (const [path, jsonFilename] of Object.entries(paths)) { - await this.mockRPC(page, path, jsonFilename, options) + await this.mockRPC(page, path, jsonFilename, options); } } diff --git a/frontend/playwright/ui/pages/WorkspacePage.js b/frontend/playwright/ui/pages/WorkspacePage.js index c91a431325..728f313416 100644 --- a/frontend/playwright/ui/pages/WorkspacePage.js +++ b/frontend/playwright/ui/pages/WorkspacePage.js @@ -1,7 +1,7 @@ import { expect } from "@playwright/test"; -import { readFile } from 'node:fs/promises'; +import { readFile } from "node:fs/promises"; import { BaseWebSocketPage } from "./BaseWebSocketPage"; -import { Transit } from '../../helpers/Transit'; +import { Transit } from "../../helpers/Transit"; export class WorkspacePage extends BaseWebSocketPage { static TextEditor = class TextEditor { diff --git a/frontend/playwright/ui/specs/render-wasm.spec.js b/frontend/playwright/ui/specs/render-wasm.spec.js index 51b6be593b..d8b72d13be 100644 --- a/frontend/playwright/ui/specs/render-wasm.spec.js +++ b/frontend/playwright/ui/specs/render-wasm.spec.js @@ -51,7 +51,7 @@ test.skip("BUG 12164 - Crash when trying to fetch a missing font", async ({ pageId: "2b7f0188-51a1-8193-8006-e05bad87b74d", }); - await workspacePage.page.waitForTimeout(1000) + await workspacePage.page.waitForTimeout(1000); await workspacePage.waitForFirstRender(); await expect( diff --git a/frontend/playwright/ui/specs/text-editor-v2.spec.js b/frontend/playwright/ui/specs/text-editor-v2.spec.js index dfef62049f..cc6061f192 100644 --- a/frontend/playwright/ui/specs/text-editor-v2.spec.js +++ b/frontend/playwright/ui/specs/text-editor-v2.spec.js @@ -1,5 +1,5 @@ import { test, expect } from "@playwright/test"; -import { Clipboard } from '../../helpers/Clipboard'; +import { Clipboard } from "../../helpers/Clipboard"; import { WorkspacePage } from "../pages/WorkspacePage"; const timeToWait = 100; @@ -11,14 +11,14 @@ test.beforeEach(async ({ page, context }) => { await WorkspacePage.mockConfigFlags(page, ["enable-feature-text-editor-v2"]); }); -test.afterEach(async ({ context}) => { +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 + textEditor: true, }); await workspace.setupEmptyFile(); await workspace.goToWorkspace(); @@ -36,10 +36,7 @@ test("Create a new text shape from pasting text", async ({ page, context }) => { textEditor: true, }); await workspace.setupEmptyFile(); - await workspace.mockRPC( - "update-file?id=*", - "text-editor/update-file.json", - ); + await workspace.mockRPC("update-file?id=*", "text-editor/update-file.json"); await workspace.goToWorkspace(); await Clipboard.writeText(page, textToPaste); @@ -55,10 +52,13 @@ test("Create a new text shape from pasting text", async ({ page, context }) => { await workspace.textEditor.stopEditing(); }); -test("Create a new text shape from pasting text using context menu", async ({ page, context }) => { +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 + textEditor: true, }); await workspace.setupEmptyFile(); await workspace.goToWorkspace(); @@ -72,11 +72,13 @@ test("Create a new text shape from pasting text using context menu", async ({ pa expect(textContent).toBe(textToPaste); await workspace.textEditor.stopEditing(); -}) +}); -test("Update an already created text shape by appending text", async ({ page }) => { +test("Update an already created text shape by appending text", async ({ + page, +}) => { const workspace = new WorkspacePage(page, { - textEditor: true + textEditor: true, }); await workspace.setupEmptyFile(); await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json"); @@ -94,7 +96,7 @@ test("Update an already created text shape by prepending text", async ({ page, }) => { const workspace = new WorkspacePage(page, { - textEditor: true + textEditor: true, }); await workspace.setupEmptyFile(); await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json"); @@ -112,7 +114,7 @@ test("Update an already created text shape by inserting text in between", async page, }) => { const workspace = new WorkspacePage(page, { - textEditor: true + textEditor: true, }); await workspace.setupEmptyFile(); await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json"); @@ -126,10 +128,13 @@ test("Update an already created text shape by inserting text in between", async await workspace.textEditor.stopEditing(); }); -test("Update a new text shape appending text by pasting text", async ({ page, context }) => { +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 + textEditor: true, }); await workspace.setupEmptyFile(); await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json"); @@ -147,11 +152,12 @@ test("Update a new text shape appending text by pasting text", async ({ page, co }); test("Update a new text shape prepending text by pasting text", async ({ - page, context + page, + context, }) => { const textToPaste = "Dolor sit amet "; const workspace = new WorkspacePage(page, { - textEditor: true + textEditor: true, }); await workspace.setupEmptyFile(); await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json"); @@ -173,7 +179,7 @@ test("Update a new text shape replacing (starting) text with pasted text", async }) => { const textToPaste = "Dolor sit amet"; const workspace = new WorkspacePage(page, { - textEditor: true + textEditor: true, }); await workspace.setupEmptyFile(); await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json"); @@ -197,7 +203,7 @@ test("Update a new text shape replacing (ending) text with pasted text", async ( }) => { const textToPaste = "dolor sit amet"; const workspace = new WorkspacePage(page, { - textEditor: true + textEditor: true, }); await workspace.setupEmptyFile(); await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json"); @@ -221,7 +227,7 @@ test("Update a new text shape replacing (in between) text with pasted text", asy }) => { const textToPaste = "dolor sit amet"; const workspace = new WorkspacePage(page, { - textEditor: true + textEditor: true, }); await workspace.setupEmptyFile(); await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json"); @@ -244,14 +250,11 @@ test("Update text font size selecting a part of it (starting)", async ({ page, }) => { const workspace = new WorkspacePage(page, { - textEditor: true + 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.mockRPC("update-file?id=*", "text-editor/update-file.json"); await workspace.goToWorkspace(); await workspace.clickLeafLayer("Lorem ipsum"); await workspace.textEditor.startEditing(); @@ -280,7 +283,10 @@ test.skip("Update text line height selecting a part of it (starting)", async ({ await workspace.textEditor.selectFromStart(5); await workspace.textEditor.changeLineHeight(1.4); - const lineHeight = await workspace.textEditor.waitForParagraphStyle(1, 'line-height'); + const lineHeight = await workspace.textEditor.waitForParagraphStyle( + 1, + "line-height", + ); expect(lineHeight).toBe("1.4"); const textContent = await workspace.textEditor.waitForTextSpanContent(); diff --git a/frontend/src/app/main/data/persistence.cljs b/frontend/src/app/main/data/persistence.cljs index 4ecbac5458..adcc70cbb3 100644 --- a/frontend/src/app/main/data/persistence.cljs +++ b/frontend/src/app/main/data/persistence.cljs @@ -24,6 +24,8 @@ (def revn-data (atom {})) (def queue-conj (fnil conj #queue [])) +(def force-persist? #(= % ::force-persist)) + (defn- update-status [status] (ptk/reify ::update-status diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index c550c150a5..93db68bd85 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -32,7 +32,7 @@ [app.main.data.helpers :as dsh] [app.main.data.modal :as modal] [app.main.data.notifications :as ntf] - [app.main.data.persistence :as-alias dps] + [app.main.data.persistence :as dps] [app.main.data.plugins :as dp] [app.main.data.profile :as du] [app.main.data.project :as dpj] @@ -67,6 +67,7 @@ [app.main.errors] [app.main.features :as features] [app.main.features.pointer-map :as fpmap] + [app.main.refs :as refs] [app.main.repo :as rp] [app.main.router :as rt] [app.render-wasm :as wasm] @@ -379,6 +380,59 @@ (->> (rx/from added) (rx/map process-wasm-object))))))) + (when render-wasm? + (let [local-commits-s + (->> stream + (rx/filter dch/commit?) + (rx/map deref) + (rx/filter #(and (= :local (:source %)) + (not (contains? (:tags %) :position-data)))) + (rx/filter (complement empty?))) + + notifier-s + (rx/merge + (->> local-commits-s (rx/debounce 1000)) + (->> stream (rx/filter dps/force-persist?))) + + objects-s + (rx/from-atom refs/workspace-page-objects {:emit-current-value? true}) + + current-page-id-s + (rx/from-atom refs/current-page-id {:emit-current-value? true})] + + (->> local-commits-s + (rx/buffer-until notifier-s) + (rx/with-latest-from objects-s) + (rx/map + (fn [[commits objects]] + (->> commits + (mapcat :redo-changes) + (filter #(contains? #{:mod-obj :add-obj} (:type %))) + (filter #(cfh/text-shape? objects (:id %))) + (map #(vector + (:id %) + (wasm.api/calculate-position-data (get objects (:id %)))))))) + + (rx/with-latest-from current-page-id-s) + (rx/map + (fn [[text-position-data page-id]] + (let [changes + (->> text-position-data + (mapv (fn [[id position-data]] + {:type :mod-obj + :id id + :page-id page-id + :operations + [{:type :set + :attr :position-data + :val position-data + :ignore-touched true + :ignore-geometry true}]})))] + (dch/commit-changes + {:redo-changes changes :undo-changes [] + :save-undo? false + :tags #{:position-data}}))))))) + (->> stream (rx/filter dch/commit?) (rx/map deref) diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index c4454c430a..e4473c1731 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -30,6 +30,9 @@ (def profile (l/derived (l/key :profile) st/state)) +(def current-page-id + (l/derived (l/key :current-page-id) st/state)) + (def team (l/derived (fn [state] (let [team-id (:current-team-id state) diff --git a/frontend/src/app/main/ui/components/select.cljs b/frontend/src/app/main/ui/components/select.cljs index d00267078d..07c2a9db86 100644 --- a/frontend/src/app/main/ui/components/select.cljs +++ b/frontend/src/app/main/ui/components/select.cljs @@ -60,6 +60,7 @@ current-id (get state :id) current-value (get state :current-value) current-label (get label-index current-value) + is-open? (get state :is-open?) node-ref (mf/use-ref nil) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs index d2311f26d4..6c2e4137c9 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs @@ -102,7 +102,7 @@ [:> deprecated-input/numeric-input* {:placeholder (cond (not all-equal?) - "Mixed" + (tr "settings.multiple") (= :multiple (:r1 values)) (tr "settings.multiple") :else diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs index 31d88d2bae..ce8f2ce7b5 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs @@ -265,11 +265,13 @@ (mf/deps font on-change) (fn [new-variant-id] (let [variant (d/seek #(= new-variant-id (:id %)) (:variants font))] - (on-change {:font-id (:id font) - :font-family (:family font) - :font-variant-id new-variant-id - :font-weight (:weight variant) - :font-style (:style variant)}) + (when-not (nil? variant) + (on-change {:font-id (:id font) + :font-family (:family font) + :font-variant-id new-variant-id + :font-weight (:weight variant) + :font-style (:style variant)})) + (dom/blur! (dom/get-target new-variant-id))))) on-font-select @@ -342,12 +344,13 @@ {:value (:id variant) :key (pr-str variant) :label (:name variant)}))) - variant-options (if (= font-size :multiple) + variant-options (if (= font-variant-id :multiple) (conj basic-variant-options - {:value :multiple + {:value "" :key :multiple-variants :label "--"}) basic-variant-options)] + ;; TODO Add disabled mode [:& select {:class (stl/css :font-variant-select) diff --git a/frontend/src/app/main/ui/workspace/viewport/debug.cljs b/frontend/src/app/main/ui/workspace/viewport/debug.cljs index c14ad650bf..2b1587533b 100644 --- a/frontend/src/app/main/ui/workspace/viewport/debug.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/debug.cljs @@ -16,6 +16,7 @@ [app.common.geom.shapes.points :as gpo] [app.common.types.shape.layout :as ctl] [app.common.uuid :as uuid] + [app.render-wasm.api :as wasm.api] [cuerdas.core :as str] [rumext.v2 :as mf])) @@ -275,3 +276,26 @@ :y2 (:y end-p) :style {:stroke "red" :stroke-width (/ 1 zoom)}}]))])))) + +(mf/defc debug-text-wasm-position-data + {::mf/wrap-props false} + [props] + (let [zoom (unchecked-get props "zoom") + selected-shapes (unchecked-get props "selected-shapes") + + selected-text + (when (and (= (count selected-shapes) 1) (= :text (-> selected-shapes first :type))) + (first selected-shapes)) + + position-data + (when selected-text + (wasm.api/calculate-position-data selected-text))] + + (for [{:keys [x y width height]} position-data] + [:rect {:x x + :y (- y height) + :width width + :height height + :fill "none" + :strokeWidth (/ 1 zoom) + :stroke "red"}]))) diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index a7b42452cf..a667d3abc5 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -12,10 +12,12 @@ [app.common.files.helpers :as cfh] [app.common.geom.shapes :as gsh] [app.common.types.color :as clr] + [app.common.types.component :as ctk] [app.common.types.path :as path] [app.common.types.shape :as cts] [app.common.types.shape.layout :as ctl] [app.main.data.workspace.transforms :as dwt] + [app.main.data.workspace.variants :as dwv] [app.main.features :as features] [app.main.refs :as refs] [app.main.store :as st] @@ -257,6 +259,16 @@ first-shape (first selected-shapes) + show-add-variant? (and single-select? + (or (ctk/is-variant-container? first-shape) + (ctk/is-variant? first-shape))) + + add-variant + (mf/use-fn + (mf/deps first-shape) + #(st/emit! + (dwv/add-new-variant (:id first-shape)))) + show-padding? (and (nil? transform) single-select? @@ -635,6 +647,12 @@ :hover-top-frame-id @hover-top-frame-id :zoom zoom}]) + (when (dbg/enabled? :text-outline) + [:& wvd/debug-text-wasm-position-data + {:selected-shapes selected-shapes + :objects base-objects + :zoom zoom}]) + (when show-selection-handlers? [:g.selection-handlers {:clipPath "url(#clip-handlers)"} (when-not text-editing? @@ -663,6 +681,11 @@ {:id (first selected) :zoom zoom}]) + (when show-add-variant? + [:> widgets/button-add* {:shape first-shape + :zoom zoom + :on-click add-variant}]) + [:g.grid-layout-editor {:clipPath "url(#clip-handlers)"} (when show-grid-editor? [:& grid-layout/editor diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 32b82196b9..cc18034b57 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -23,6 +23,7 @@ [app.main.refs :as refs] [app.main.render :as render] [app.main.store :as st] + [app.main.ui.shapes.text] [app.main.worker :as mw] [app.render-wasm.api.fonts :as f] [app.render-wasm.api.texts :as t] @@ -33,7 +34,7 @@ [app.render-wasm.performance :as perf] [app.render-wasm.serializers :as sr] [app.render-wasm.serializers.color :as sr-clr] - [app.render-wasm.svg-fills :as svg-fills] + [app.render-wasm.svg-filters :as svg-filters] ;; FIXME: rename; confunsing name [app.render-wasm.wasm :as wasm] [app.util.debug :as dbg] @@ -42,6 +43,7 @@ [app.util.modules :as mod] [app.util.text.content :as tc] [beicon.v2.core :as rx] + [cuerdas.core :as str] [promesa.core :as p] [rumext.v2 :as mf])) @@ -703,7 +705,7 @@ (set-grid-layout-columns (get shape :layout-grid-columns)) (set-grid-layout-cells (get shape :layout-grid-cells))) -(defn set-layout-child +(defn set-layout-data [shape] (let [margins (get shape :layout-item-margin) margin-top (get margins :m1 0) @@ -726,7 +728,7 @@ is-absolute (boolean (get shape :layout-item-absolute)) z-index (get shape :layout-item-z-index)] (h/call wasm/internal-module - "_set_layout_child_data" + "_set_layout_data" margin-top margin-right margin-bottom @@ -746,6 +748,11 @@ is-absolute (d/nilv z-index 0)))) +(defn has-any-layout-prop? [shape] + (some #(and (keyword? %) + (str/starts-with? (name %) "layout-")) + (keys shape))) + (defn clear-layout [] (h/call wasm/internal-module "_clear_shape_layout")) @@ -753,10 +760,10 @@ (defn- set-shape-layout [shape objects] (clear-layout) - (when (or (ctl/any-layout? shape) - (ctl/any-layout-immediate-child? objects shape)) - (set-layout-child shape)) + (ctl/any-layout-immediate-child? objects shape) + (has-any-layout-prop? shape)) + (set-layout-data shape)) (when (ctl/flex-layout? shape) (set-flex-layout shape)) @@ -875,27 +882,43 @@ (def render-finish (letfn [(do-render [ts] + (perf/begin-measure "render-finish") (h/call wasm/internal-module "_set_view_end") - (render ts))] + (render ts) + (perf/end-measure "render-finish"))] (fns/debounce do-render DEBOUNCE_DELAY_MS))) (def render-pan - (fns/throttle render THROTTLE_DELAY_MS)) + (letfn [(do-render-pan [ts] + (perf/begin-measure "render-pan") + (render ts) + (perf/end-measure "render-pan"))] + (fns/throttle do-render-pan THROTTLE_DELAY_MS))) (defn set-view-box [prev-zoom zoom vbox] - (h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox))) + (let [is-pan (mth/close? prev-zoom zoom)] + (perf/begin-measure "set-view-box") + (h/call wasm/internal-module "_set_view_start") + (h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox))) - (if (mth/close? prev-zoom zoom) - (do (render-pan) - (render-finish)) - (do (h/call wasm/internal-module "_render_from_cache" 0) - (render-finish)))) + (if is-pan + (do (perf/end-measure "set-view-box") + (perf/begin-measure "set-view-box::pan") + (render-pan) + (render-finish) + (perf/end-measure "set-view-box::pan")) + (do (perf/end-measure "set-view-box") + (perf/begin-measure "set-view-box::zoom") + (h/call wasm/internal-module "_render_from_cache" 0) + (render-finish) + (perf/end-measure "set-view-box::zoom"))))) (defn set-object [objects shape] (perf/begin-measure "set-object") - (let [id (dm/get-prop shape :id) + (let [shape (svg-filters/apply-svg-derived shape) + id (dm/get-prop shape :id) type (dm/get-prop shape :type) parent-id (get shape :parent-id) @@ -909,14 +932,7 @@ rotation (get shape :rotation) transform (get shape :transform) - ;; If the shape comes from an imported SVG (we know this because - ;; it has the :svg-attrs attribute) and it does not have its - ;; own fill, we set a default black fill. This fill will be - ;; inherited by child nodes and emulates the behavior of - ;; standard SVG, where a node without an explicit fill - ;; defaults to black. - fills (svg-fills/resolve-shape-fills shape) - + fills (get shape :fills) strokes (if (= type :group) [] (get shape :strokes)) children (get shape :shapes) @@ -960,12 +976,11 @@ (set-shape-svg-attrs svg-attrs)) (when (and (some? content) (= type :svg-raw)) (set-shape-svg-raw-content (get-static-markup shape))) - (when (some? shadows) (set-shape-shadows shadows)) + (set-shape-shadows shadows) (when (= type :text) (set-shape-grow-type grow-type)) (set-shape-layout shape objects) - (set-shape-selrect selrect) (let [pending_thumbnails (into [] (concat @@ -989,10 +1004,7 @@ (run! (fn [id] (f/update-text-layout id) - (mw/emit! {:cmd :index/update-text-rect - :page-id (:current-page-id @st/state) - :shape-id id - :dimensions (get-text-dimensions id)}))))) + (update-text-rect! id))))) (defn process-pending ([shapes thumbnails full on-complete] @@ -1233,6 +1245,8 @@ (when-not (nil? context) (let [handle (.registerContext ^js gl context #js {"majorVersion" 2})] (.makeContextCurrent ^js gl handle) + (set! wasm/gl-context-handle handle) + (set! wasm/gl-context context) ;; Force the WEBGL_debug_renderer_info extension as emscripten does not enable it (.getExtension context "WEBGL_debug_renderer_info") @@ -1255,6 +1269,20 @@ (set! wasm/context-initialized? false) (h/call wasm/internal-module "_clean_up") + ;; Ensure the WebGL context is properly disposed so browsers do not keep + ;; accumulating active contexts between page switches. + (when-let [gl (unchecked-get wasm/internal-module "GL")] + (when-let [handle wasm/gl-context-handle] + (try + ;; Ask the browser to release resources explicitly if available. + (when-let [ctx wasm/gl-context] + (when-let [lose-ext (.getExtension ^js ctx "WEBGL_lose_context")] + (.loseContext ^js lose-ext))) + (.deleteContext ^js gl handle) + (finally + (set! wasm/gl-context-handle nil) + (set! wasm/gl-context nil))))) + ;; If this calls panics we don't want to crash. This happens sometimes ;; with hot-reload in develop (catch :default error @@ -1348,6 +1376,59 @@ (h/call wasm/internal-module "_end_temp_objects") content))) +(def POSITION-DATA-U8-SIZE 36) +(def POSITION-DATA-U32-SIZE (/ POSITION-DATA-U8-SIZE 4)) + +(defn calculate-position-data + [shape] + (when wasm/context-initialized? + (use-shape (:id shape)) + (let [heapf32 (mem/get-heap-f32) + heapu32 (mem/get-heap-u32) + offset (-> (h/call wasm/internal-module "_calculate_position_data") + (mem/->offset-32)) + length (aget heapu32 offset) + + max-offset (+ offset 1 (* length POSITION-DATA-U32-SIZE)) + + result + (loop [result (transient []) + offset (inc offset)] + (if (< offset max-offset) + (let [entry (dr/read-position-data-entry heapu32 heapf32 offset)] + (recur (conj! result entry) + (+ offset POSITION-DATA-U32-SIZE))) + (persistent! result))) + + result + (->> result + (mapv + (fn [{:keys [paragraph span start-pos end-pos direction x y width height]}] + (let [content (:content shape) + element (-> content :children + (get 0) :children ;; paragraph-set + (get paragraph) :children ;; paragraph + (get span)) + text (subs (:text element) start-pos end-pos)] + + {:x x + :y (+ y height) + :width width + :height height + :direction (dr/translate-direction direction) + :font-family (get element :font-family) + :font-size (get element :font-size) + :font-weight (get element :font-weight) + :text-transform (get element :text-transform) + :text-decoration (get element :text-decoration) + :letter-spacing (get element :letter-spacing) + :font-style (get element :font-style) + :fills (get element :fills) + :text text}))))] + (mem/free) + + result))) + (defn init-wasm-module [module] (let [default-fn (unchecked-get module "default") diff --git a/frontend/src/app/render_wasm/deserializers.cljs b/frontend/src/app/render_wasm/deserializers.cljs index dd718d82c4..09376033d1 100644 --- a/frontend/src/app/render_wasm/deserializers.cljs +++ b/frontend/src/app/render_wasm/deserializers.cljs @@ -45,4 +45,29 @@ :center (gpt/point cx cy) :transform (gmt/matrix a b c d e f)})) +(defn read-position-data-entry + [heapu32 heapf32 offset] + (let [paragraph (aget heapu32 (+ offset 0)) + span (aget heapu32 (+ offset 1)) + start-pos (aget heapu32 (+ offset 2)) + end-pos (aget heapu32 (+ offset 3)) + x (aget heapf32 (+ offset 4)) + y (aget heapf32 (+ offset 5)) + width (aget heapf32 (+ offset 6)) + height (aget heapf32 (+ offset 7)) + direction (aget heapu32 (+ offset 8))] + {:paragraph paragraph + :span span + :start-pos start-pos + :end-pos end-pos + :x x + :y y + :width width + :height height + :direction direction})) +(defn translate-direction + [direction] + (case direction + 0 "rtl" + "ltr")) diff --git a/frontend/src/app/render_wasm/shape.cljs b/frontend/src/app/render_wasm/shape.cljs index 95513ed1a0..335a1432d5 100644 --- a/frontend/src/app/render_wasm/shape.cljs +++ b/frontend/src/app/render_wasm/shape.cljs @@ -14,7 +14,7 @@ [app.common.types.shape.layout :as ctl] [app.main.refs :as refs] [app.render-wasm.api :as api] - [app.render-wasm.svg-fills :as svg-fills] + [app.render-wasm.svg-filters :as svg-filters] [app.render-wasm.wasm :as wasm] [beicon.v2.core :as rx] [cljs.core :as c] @@ -130,7 +130,11 @@ (defn- set-wasm-attr! [shape k] (when wasm/context-initialized? - (let [v (get shape k) + (let [shape (case k + :svg-attrs (svg-filters/apply-svg-derived (assoc shape :svg-attrs (get shape :svg-attrs))) + (:fills :blur :shadow) (svg-filters/apply-svg-derived shape) + shape) + v (get shape k) id (get shape :id)] (case k :parent-id @@ -163,8 +167,7 @@ (api/set-shape-transform v) :fills - (let [fills (svg-fills/resolve-shape-fills shape)] - (into [] (api/set-shape-fills id fills false))) + (api/set-shape-fills id v false) :strokes (into [] (api/set-shape-strokes id v false)) @@ -222,8 +225,12 @@ v]) :svg-attrs - (when (cfh/path-shape? shape) - (api/set-shape-svg-attrs v)) + (do + (api/set-shape-svg-attrs v) + ;; Always update fills/blur/shadow to clear previous state if filters disappear + (api/set-shape-fills id (:fills shape) false) + (api/set-shape-blur (:blur shape)) + (api/set-shape-shadows (:shadow shape))) :masked-group (when (cfh/mask-shape? shape) @@ -262,7 +269,7 @@ :layout-item-min-w :layout-item-absolute :layout-item-z-index) - (api/set-layout-child shape) + (api/set-layout-data shape) :layout-grid-rows (api/set-grid-layout-rows v) @@ -292,7 +299,7 @@ (ctl/flex-layout? shape) (api/set-flex-layout shape)) - (api/set-layout-child shape)) + (api/set-layout-data shape)) ;; Property not in WASM nil)))) diff --git a/frontend/src/app/render_wasm/svg_fills.cljs b/frontend/src/app/render_wasm/svg_fills.cljs index ba9b40d3a9..b7e77afa54 100644 --- a/frontend/src/app/render_wasm/svg_fills.cljs +++ b/frontend/src/app/render_wasm/svg_fills.cljs @@ -74,6 +74,30 @@ :width (max 0.01 (or (dm/get-prop shape :width) 1)) :height (max 0.01 (or (dm/get-prop shape :height) 1))})))) +(defn- apply-svg-transform + "Applies SVG transform to a point if present." + [pt svg-transform] + (if svg-transform + (gpt/transform pt svg-transform) + pt)) + +(defn- apply-viewbox-transform + "Transforms a point from viewBox space to selrect space." + [pt viewbox rect] + (if viewbox + (let [{svg-x :x svg-y :y svg-width :width svg-height :height} viewbox + rect-width (max 0.01 (dm/get-prop rect :width)) + rect-height (max 0.01 (dm/get-prop rect :height)) + origin-x (or (dm/get-prop rect :x) (dm/get-prop rect :x1) 0) + origin-y (or (dm/get-prop rect :y) (dm/get-prop rect :y1) 0) + scale-x (/ rect-width svg-width) + scale-y (/ rect-height svg-height) + ;; Transform from viewBox space to selrect space + transformed-x (+ origin-x (* (- (dm/get-prop pt :x) svg-x) scale-x)) + transformed-y (+ origin-y (* (- (dm/get-prop pt :y) svg-y) scale-y))] + (gpt/point transformed-x transformed-y)) + pt)) + (defn- normalize-point [pt units shape] (if (= units "userspaceonuse") @@ -81,9 +105,16 @@ width (max 0.01 (dm/get-prop rect :width)) height (max 0.01 (dm/get-prop rect :height)) origin-x (or (dm/get-prop rect :x) (dm/get-prop rect :x1) 0) - origin-y (or (dm/get-prop rect :y) (dm/get-prop rect :y1) 0)] - (gpt/point (/ (- (dm/get-prop pt :x) origin-x) width) - (/ (- (dm/get-prop pt :y) origin-y) height))) + origin-y (or (dm/get-prop rect :y) (dm/get-prop rect :y1) 0) + svg-transform (:svg-transform shape) + viewbox (:svg-viewbox shape) + ;; For userSpaceOnUse, coordinates are in SVG user space + ;; We need to transform them to shape space before normalizing + pt-after-svg-transform (apply-svg-transform pt svg-transform) + transformed-pt (apply-viewbox-transform pt-after-svg-transform viewbox rect) + normalized-x (/ (- (dm/get-prop transformed-pt :x) origin-x) width) + normalized-y (/ (- (dm/get-prop transformed-pt :y) origin-y) height)] + (gpt/point normalized-x normalized-y)) pt)) (defn- normalize-attrs @@ -257,18 +288,25 @@ (parse-gradient-stop node)))) vec)] (when (seq stops) - (let [[center radius-point] + (let [[center point-x point-y] (let [points (apply-gradient-transform [(gpt/point cx cy) - (gpt/point (+ cx r) cy)] + (gpt/point (+ cx r) cy) + (gpt/point cx (+ cy r))] transform)] (map #(normalize-point % units shape) points)) - radius (gpt/distance center radius-point)] + radius-x (gpt/distance center point-x) + radius-y (gpt/distance center point-y) + ;; Prefer Y as the base radius so width becomes the X/Y ratio. + base-radius (if (pos? radius-y) radius-y radius-x) + radius-point (if (pos? radius-y) point-y point-x) + width (let [safe-radius (max base-radius 1.0e-6)] + (/ radius-x safe-radius))] {:type :radial :start-x (dm/get-prop center :x) :start-y (dm/get-prop center :y) :end-x (dm/get-prop radius-point :x) :end-y (dm/get-prop radius-point :y) - :width radius + :width width :stops stops})))) (defn- svg-gradient->fill diff --git a/frontend/src/app/render_wasm/svg_filters.cljs b/frontend/src/app/render_wasm/svg_filters.cljs new file mode 100644 index 0000000000..699df81522 --- /dev/null +++ b/frontend/src/app/render_wasm/svg_filters.cljs @@ -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')))) + diff --git a/frontend/src/app/render_wasm/wasm.cljs b/frontend/src/app/render_wasm/wasm.cljs index 3901dc975f..5fec8156e3 100644 --- a/frontend/src/app/render_wasm/wasm.cljs +++ b/frontend/src/app/render_wasm/wasm.cljs @@ -9,6 +9,8 @@ (defonce internal-frame-id nil) (defonce internal-module #js {}) +(defonce gl-context-handle nil) +(defonce gl-context nil) (defonce serializers #js {:blur-type shared/RawBlurType :blend-mode shared/RawBlendMode diff --git a/frontend/test/frontend_tests/svg_fills_test.cljs b/frontend/test/frontend_tests/svg_fills_test.cljs index a2f202c943..3f9d5788ed 100644 --- a/frontend/test/frontend_tests/svg_fills_test.cljs +++ b/frontend/test/frontend_tests/svg_fills_test.cljs @@ -42,6 +42,37 @@ (deftest skips-when-no-svg-fill (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 (let [fills [{:fill-color "#ff00ff" :fill-opacity 0.75}] resolved (svg-fills/resolve-shape-fills {:fills fills})] diff --git a/frontend/test/frontend_tests/svg_filters_test.cljs b/frontend/test/frontend_tests/svg_filters_test.cljs new file mode 100644 index 0000000000..d469183389 --- /dev/null +++ b/frontend/test/frontend_tests/svg_filters_test.cljs @@ -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)))) + diff --git a/frontend/text-editor/src/editor/Event.js b/frontend/text-editor/src/editor/Event.js index 9751bad734..23fa4b1338 100644 --- a/frontend/text-editor/src/editor/Event.js +++ b/frontend/text-editor/src/editor/Event.js @@ -15,7 +15,7 @@ */ export function addEventListeners(target, object, options) { 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) { Object.entries(object).forEach(([type, listener]) => - target.removeEventListener(type, listener) + target.removeEventListener(type, listener), ); } diff --git a/frontend/text-editor/src/editor/TextEditor.js b/frontend/text-editor/src/editor/TextEditor.js index 92e9111eff..e8e8ff1ea2 100644 --- a/frontend/text-editor/src/editor/TextEditor.js +++ b/frontend/text-editor/src/editor/TextEditor.js @@ -664,8 +664,16 @@ export class TextEditor extends EventTarget { * @param {boolean} allowHTMLPaste * @returns {Root} */ -export function createRootFromHTML(html, style = undefined, allowHTMLPaste = undefined) { - const fragment = mapContentFragmentFromHTML(html, style || undefined, allowHTMLPaste || undefined); +export function createRootFromHTML( + html, + style = undefined, + allowHTMLPaste = undefined, +) { + const fragment = mapContentFragmentFromHTML( + html, + style || undefined, + allowHTMLPaste || undefined, + ); const root = createRoot([], style); root.replaceChildren(fragment); resetInertElement(); diff --git a/frontend/text-editor/src/editor/clipboard/paste.js b/frontend/text-editor/src/editor/clipboard/paste.js index cc63579062..1eb85e1db8 100644 --- a/frontend/text-editor/src/editor/clipboard/paste.js +++ b/frontend/text-editor/src/editor/clipboard/paste.js @@ -18,7 +18,10 @@ import { TextEditor } from "../TextEditor.js"; * @param {DataTransfer} clipboardData * @returns {DocumentFragment} */ -function getFormattedFragmentFromClipboardData(selectionController, clipboardData) { +function getFormattedFragmentFromClipboardData( + selectionController, + clipboardData, +) { return mapContentFragmentFromHTML( clipboardData.getData("text/html"), selectionController.currentStyle, @@ -79,9 +82,14 @@ export function paste(event, editor, selectionController) { let fragment = null; if (editor?.options?.allowHTMLPaste) { - fragment = getFormattedOrPlainFragmentFromClipboardData(event.clipboardData); + fragment = getFormattedOrPlainFragmentFromClipboardData( + event.clipboardData, + ); } else { - fragment = getPlainFragmentFromClipboardData(selectionController, event.clipboardData); + fragment = getPlainFragmentFromClipboardData( + selectionController, + event.clipboardData, + ); } if (!fragment) { @@ -92,10 +100,9 @@ export function paste(event, editor, selectionController) { if (selectionController.isCollapsed) { const hasOnlyOneParagraph = fragment.children.length === 1; const hasOnlyOneTextSpan = fragment.firstElementChild.children.length === 1; - const forceTextSpan = fragment.firstElementChild.dataset.textSpan === "force"; - if (hasOnlyOneParagraph - && hasOnlyOneTextSpan - && forceTextSpan) { + const forceTextSpan = + fragment.firstElementChild.dataset.textSpan === "force"; + if (hasOnlyOneParagraph && hasOnlyOneTextSpan && forceTextSpan) { selectionController.insertIntoFocus(fragment.textContent); } else { selectionController.insertPaste(fragment); @@ -103,10 +110,9 @@ export function paste(event, editor, selectionController) { } else { const hasOnlyOneParagraph = fragment.children.length === 1; const hasOnlyOneTextSpan = fragment.firstElementChild.children.length === 1; - const forceTextSpan = fragment.firstElementChild.dataset.textSpan === "force"; - if (hasOnlyOneParagraph - && hasOnlyOneTextSpan - && forceTextSpan) { + const forceTextSpan = + fragment.firstElementChild.dataset.textSpan === "force"; + if (hasOnlyOneParagraph && hasOnlyOneTextSpan && forceTextSpan) { selectionController.replaceText(fragment.textContent); } else { selectionController.replaceWithPaste(fragment); diff --git a/frontend/text-editor/src/editor/commands/deleteContentBackward.js b/frontend/text-editor/src/editor/commands/deleteContentBackward.js index cbc112aa68..dfefe5a4aa 100644 --- a/frontend/text-editor/src/editor/commands/deleteContentBackward.js +++ b/frontend/text-editor/src/editor/commands/deleteContentBackward.js @@ -23,7 +23,7 @@ export function deleteContentBackward(event, editor, selectionController) { // If not is collapsed AKA is a selection, then // we removeSelected. if (!selectionController.isCollapsed) { - return selectionController.removeSelected({ direction: 'backward' }); + return selectionController.removeSelected({ direction: "backward" }); } // 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) { return selectionController.removeBackwardText(); - // If we're in a text node but we're at the end of the - // paragraph, we should merge the current paragraph - // with the following paragraph. + // If we're in a text node but we're at the end of the + // paragraph, we should merge the current paragraph + // with the following paragraph. } else if ( selectionController.isTextFocus && selectionController.focusAtStart ) { return selectionController.mergeBackwardParagraph(); - // If we're at an text span or a line break paragraph - // and there's more than one paragraph, then we should - // remove the next paragraph. + // If we're at an text span or a line break paragraph + // and there's more than one paragraph, then we should + // remove the next paragraph. } else if ( selectionController.isTextSpanFocus || selectionController.isLineBreakFocus diff --git a/frontend/text-editor/src/editor/commands/deleteContentForward.js b/frontend/text-editor/src/editor/commands/deleteContentForward.js index 53951bd7f1..0f51188699 100644 --- a/frontend/text-editor/src/editor/commands/deleteContentForward.js +++ b/frontend/text-editor/src/editor/commands/deleteContentForward.js @@ -28,22 +28,21 @@ export function deleteContentForward(event, editor, selectionController) { // If we're in a text node and the offset is // greater than 0 (not at the start of the text span) // we simple remove a character from the text. - if (selectionController.isTextFocus - && selectionController.focusAtEnd) { + if (selectionController.isTextFocus && selectionController.focusAtEnd) { return selectionController.mergeForwardParagraph(); - // If we're in a text node but we're at the end of the - // paragraph, we should merge the current paragraph - // with the following paragraph. + // If we're in a text node but we're at the end of the + // paragraph, we should merge the current paragraph + // with the following paragraph. } else if ( selectionController.isTextFocus && selectionController.focusOffset >= 0 ) { return selectionController.removeForwardText(); - // If we're at a text span or a line break paragraph - // and there's more than one paragraph, then we should - // remove the next paragraph. + // If we're at a text span or a line break paragraph + // and there's more than one paragraph, then we should + // remove the next paragraph. } else if ( (selectionController.isTextSpanFocus || selectionController.isLineBreakFocus) && diff --git a/frontend/text-editor/src/editor/content/Text.test.js b/frontend/text-editor/src/editor/content/Text.test.js index e8c43a1301..45924d655d 100644 --- a/frontend/text-editor/src/editor/content/Text.test.js +++ b/frontend/text-editor/src/editor/content/Text.test.js @@ -1,11 +1,17 @@ -import { describe, test, expect } from 'vitest' -import { insertInto, removeBackward, removeForward, replaceWith } from './Text'; +import { describe, test, expect } from "vitest"; +import { insertInto, removeBackward, removeForward, replaceWith } from "./Text"; describe("Text", () => { test("* should throw when passed wrong parameters", () => { - expect(() => insertInto(Infinity, Infinity, Infinity)).toThrowError('Invalid string'); - expect(() => insertInto('Hello', Infinity, Infinity)).toThrowError('Invalid offset'); - expect(() => insertInto('Hello', 0, Infinity)).toThrowError('Invalid string'); + expect(() => insertInto(Infinity, Infinity, 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", () => { @@ -13,7 +19,9 @@ describe("Text", () => { }); 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)", () => { @@ -26,13 +34,13 @@ describe("Text", () => { test("`removeBackward` should remove string backward from end", () => { expect(removeBackward("Hello, World!", "Hello, World!".length)).toBe( - "Hello, World" + "Hello, World", ); }); test("`removeForward` should remove string forward from end", () => { expect(removeForward("Hello, World!", "Hello, World!".length)).toBe( - "Hello, World!" + "Hello, World!", ); }); diff --git a/frontend/text-editor/src/editor/content/dom/Color.js b/frontend/text-editor/src/editor/content/dom/Color.js index 01a9e23bb7..ba798dd67a 100644 --- a/frontend/text-editor/src/editor/content/dom/Color.js +++ b/frontend/text-editor/src/editor/content/dom/Color.js @@ -24,7 +24,7 @@ function getContext() { if (!context) { context = canvas.getContext("2d"); } - return context + return context; } /** diff --git a/frontend/text-editor/src/editor/content/dom/Content.js b/frontend/text-editor/src/editor/content/dom/Content.js index 357c7fbe42..62181d11c4 100644 --- a/frontend/text-editor/src/editor/content/dom/Content.js +++ b/frontend/text-editor/src/editor/content/dom/Content.js @@ -230,15 +230,10 @@ export function mapContentFragmentFromString(string, styleDefaults) { const fragment = document.createDocumentFragment(); for (const line of lines) { if (line === "") { - fragment.appendChild( - createEmptyParagraph(styleDefaults) - ); + fragment.appendChild(createEmptyParagraph(styleDefaults)); } else { const textSpan = createTextSpan(new Text(line), styleDefaults); - const paragraph = createParagraph( - [textSpan], - styleDefaults, - ); + const paragraph = createParagraph([textSpan], styleDefaults); if (lines.length === 1) { paragraph.dataset.textSpan = "force"; } diff --git a/frontend/text-editor/src/editor/content/dom/Paragraph.test.js b/frontend/text-editor/src/editor/content/dom/Paragraph.test.js index 28c79612af..57e5fb7f54 100644 --- a/frontend/text-editor/src/editor/content/dom/Paragraph.test.js +++ b/frontend/text-editor/src/editor/content/dom/Paragraph.test.js @@ -112,7 +112,11 @@ describe("Paragraph", () => { const helloTextSpan = createTextSpan(new Text("Hello, ")); const worldTextSpan = createTextSpan(new Text("World")); const exclTextSpan = createTextSpan(new Text("!")); - const paragraph = createParagraph([helloTextSpan, worldTextSpan, exclTextSpan]); + const paragraph = createParagraph([ + helloTextSpan, + worldTextSpan, + exclTextSpan, + ]); const newParagraph = splitParagraphAtNode(paragraph, 1); expect(newParagraph).toBeInstanceOf(HTMLDivElement); expect(newParagraph.nodeName).toBe(TAG); diff --git a/frontend/text-editor/src/editor/content/dom/Root.test.js b/frontend/text-editor/src/editor/content/dom/Root.test.js index 49b5195d59..31f3d100c8 100644 --- a/frontend/text-editor/src/editor/content/dom/Root.test.js +++ b/frontend/text-editor/src/editor/content/dom/Root.test.js @@ -1,5 +1,11 @@ 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 */ describe("Root", () => { diff --git a/frontend/text-editor/src/editor/content/dom/Style.js b/frontend/text-editor/src/editor/content/dom/Style.js index cc11320495..9868572d09 100644 --- a/frontend/text-editor/src/editor/content/dom/Style.js +++ b/frontend/text-editor/src/editor/content/dom/Style.js @@ -6,7 +6,7 @@ * Copyright (c) KALEIDOS INC */ -import StyleDeclaration from '../../controllers/StyleDeclaration.js'; +import StyleDeclaration from "../../controllers/StyleDeclaration.js"; import { getFills } from "./Color.js"; const DEFAULT_FONT_SIZE = "16px"; @@ -339,8 +339,7 @@ export function setStylesFromObject(element, allowedStyles, styleObject) { continue; } let styleValue = styleObject[styleName]; - if (!styleValue) - continue; + if (!styleValue) continue; if (styleName === "font-family") { styleValue = sanitizeFontFamily(styleValue); @@ -388,8 +387,10 @@ export function setStylesFromDeclaration( * @returns {HTMLElement} */ export function setStyles(element, allowedStyles, styleObjectOrDeclaration) { - if (styleObjectOrDeclaration instanceof CSSStyleDeclaration - || styleObjectOrDeclaration instanceof StyleDeclaration) { + if ( + styleObjectOrDeclaration instanceof CSSStyleDeclaration || + styleObjectOrDeclaration instanceof StyleDeclaration + ) { return setStylesFromDeclaration( element, allowedStyles, diff --git a/frontend/text-editor/src/editor/content/dom/TextNode.js b/frontend/text-editor/src/editor/content/dom/TextNode.js index 5aaa8afd6c..051bf054c2 100644 --- a/frontend/text-editor/src/editor/content/dom/TextNode.js +++ b/frontend/text-editor/src/editor/content/dom/TextNode.js @@ -22,8 +22,7 @@ import { isRoot } from "./Root.js"; */ export function isTextNode(node) { if (!node) throw new TypeError("Invalid text node"); - return node.nodeType === Node.TEXT_NODE - || isLineBreak(node); + return node.nodeType === Node.TEXT_NODE || isLineBreak(node); } /** @@ -33,8 +32,7 @@ export function isTextNode(node) { * @returns {boolean} */ export function isEmptyTextNode(node) { - return node.nodeType === Node.TEXT_NODE - && node.nodeValue === ""; + return node.nodeType === Node.TEXT_NODE && node.nodeValue === ""; } /** diff --git a/frontend/text-editor/src/editor/content/dom/TextNodeIterator.js b/frontend/text-editor/src/editor/content/dom/TextNodeIterator.js index 08357d0dd0..ef347efee9 100644 --- a/frontend/text-editor/src/editor/content/dom/TextNodeIterator.js +++ b/frontend/text-editor/src/editor/content/dom/TextNodeIterator.js @@ -6,7 +6,7 @@ * Copyright (c) KALEIDOS INC */ -import SafeGuard from '../../controllers/SafeGuard.js'; +import SafeGuard from "../../controllers/SafeGuard.js"; /** * Iterator direction. @@ -58,7 +58,7 @@ export class TextNodeIterator { startNode, rootNode, skipNodes = new Set(), - direction = TextNodeIteratorDirection.FORWARD + direction = TextNodeIteratorDirection.FORWARD, ) { if (startNode === rootNode) { return TextNodeIterator.findDown( @@ -67,7 +67,7 @@ export class TextNodeIterator { : startNode.lastChild, rootNode, skipNodes, - direction + direction, ); } @@ -95,7 +95,7 @@ export class TextNodeIterator { : currentNode.lastChild, rootNode, skipNodes, - direction + direction, ); } currentNode = @@ -119,7 +119,7 @@ export class TextNodeIterator { startNode, rootNode, backTrack = new Set(), - direction = TextNodeIteratorDirection.FORWARD + direction = TextNodeIteratorDirection.FORWARD, ) { backTrack.add(startNode); if (TextNodeIterator.isTextNode(startNode)) { @@ -127,14 +127,14 @@ export class TextNodeIterator { startNode.parentNode, rootNode, backTrack, - direction + direction, ); } else if (TextNodeIterator.isContainerNode(startNode)) { const found = TextNodeIterator.findDown( startNode, rootNode, backTrack, - direction + direction, ); if (found) { return found; @@ -144,7 +144,7 @@ export class TextNodeIterator { startNode.parentNode, rootNode, backTrack, - direction + direction, ); } } @@ -214,7 +214,7 @@ export class TextNodeIterator { this.#currentNode, this.#rootNode, new Set(), - TextNodeIteratorDirection.FORWARD + TextNodeIteratorDirection.FORWARD, ); if (!nextNode) { @@ -237,7 +237,7 @@ export class TextNodeIterator { this.#currentNode, this.#rootNode, new Set(), - TextNodeIteratorDirection.BACKWARD + TextNodeIteratorDirection.BACKWARD, ); if (!previousNode) { @@ -270,10 +270,8 @@ export class TextNodeIterator { * @param {TextNode} endNode * @yields {TextNode} */ - * iterateFrom(startNode, endNode) { - const comparedPosition = startNode.compareDocumentPosition( - endNode - ); + *iterateFrom(startNode, endNode) { + const comparedPosition = startNode.compareDocumentPosition(endNode); this.#currentNode = startNode; SafeGuard.start(); while (this.#currentNode !== endNode) { diff --git a/frontend/text-editor/src/editor/controllers/ChangeController.js b/frontend/text-editor/src/editor/controllers/ChangeController.js index 8ca9ef571a..166f89a598 100644 --- a/frontend/text-editor/src/editor/controllers/ChangeController.js +++ b/frontend/text-editor/src/editor/controllers/ChangeController.js @@ -38,7 +38,7 @@ export class ChangeController extends EventTarget { * @param {number} [time=500] */ constructor(time = 500) { - super() + super(); if (typeof time === "number" && (!Number.isInteger(time) || time <= 0)) { throw new TypeError("Invalid time"); } diff --git a/frontend/text-editor/src/editor/controllers/SafeGuard.js b/frontend/text-editor/src/editor/controllers/SafeGuard.js index c19f05eb41..c288b8aab7 100644 --- a/frontend/text-editor/src/editor/controllers/SafeGuard.js +++ b/frontend/text-editor/src/editor/controllers/SafeGuard.js @@ -24,19 +24,19 @@ export function start() { */ export function update() { if (Date.now - startTime >= SAFE_GUARD_TIME) { - throw new Error('Safe guard timeout'); + throw new Error("Safe guard timeout"); } } -let timeoutId = 0 +let timeoutId = 0; export function throwAfter(error, timeout = SAFE_GUARD_TIME) { timeoutId = setTimeout(() => { - throw error - }, timeout) + throw error; + }, timeout); } export function throwCancel() { - clearTimeout(timeoutId) + clearTimeout(timeoutId); } export default { diff --git a/frontend/text-editor/src/editor/controllers/SelectionController.js b/frontend/text-editor/src/editor/controllers/SelectionController.js index edfe69c03e..2586aab148 100644 --- a/frontend/text-editor/src/editor/controllers/SelectionController.js +++ b/frontend/text-editor/src/editor/controllers/SelectionController.js @@ -54,7 +54,7 @@ import { isRoot, setRootStyles } from "../content/dom/Root.js"; import { SelectionDirection } from "./SelectionDirection.js"; import SafeGuard from "./SafeGuard.js"; import { sanitizeFontFamily } from "../content/dom/Style.js"; -import StyleDeclaration from './StyleDeclaration.js'; +import StyleDeclaration from "./StyleDeclaration.js"; /** * Supported options for the SelectionController. @@ -280,11 +280,17 @@ export class SelectionController extends EventTarget { // 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)) { + 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)) { + for (const textNode of this.#textNodeIterator.iterateFrom( + startNode, + endNode, + )) { const textSpan = textNode.parentElement; this.#mergeStylesFromElementToCurrentStyle(textSpan); } @@ -498,19 +504,12 @@ export class SelectionController extends EventTarget { if (!this.#savedSelection) return false; if (this.#savedSelection.anchorNode && this.#savedSelection.focusNode) { - if (this.#savedSelection.anchorNode === this.#savedSelection.focusNode) { - this.#selection.setPosition( - this.#savedSelection.focusNode, - this.#savedSelection.focusOffset, - ); - } else { - this.#selection.setBaseAndExtent( - this.#savedSelection.anchorNode, - this.#savedSelection.anchorOffset, - this.#savedSelection.focusNode, - this.#savedSelection.focusOffset, - ); - } + this.#selection.setBaseAndExtent( + this.#savedSelection.anchorNode, + this.#savedSelection.anchorOffset, + this.#savedSelection.focusNode, + this.#savedSelection.focusOffset, + ); } this.#savedSelection = null; return true; @@ -1132,10 +1131,7 @@ export class SelectionController extends EventTarget { const hasOnlyOneParagraph = fragment.children.length === 1; const forceTextSpan = fragment.firstElementChild?.dataset?.textSpan === "force"; - if ( - hasOnlyOneParagraph && - forceTextSpan - ) { + if (hasOnlyOneParagraph && forceTextSpan) { // first text span const collapseNode = fragment.firstElementChild.firstElementChild; if (this.isTextSpanStart) { @@ -1403,7 +1399,7 @@ export class SelectionController extends EventTarget { // the focus node is a . if (isTextSpan(this.focusNode)) { this.focusNode.firstElementChild.replaceWith(textNode); - // the focus node is a
. + // the focus node is a
. } else { this.focusNode.replaceWith(textNode); } @@ -1981,8 +1977,7 @@ export class SelectionController extends EventTarget { this.setSelection(newTextSpan.firstChild, 0, newTextSpan.firstChild, 0); } // The styles are applied to the paragraph - else - { + else { const paragraph = this.startParagraph; setParagraphStyles(paragraph, newStyles); // Apply styles to child text spans. diff --git a/frontend/text-editor/src/editor/controllers/SelectionController.test.js b/frontend/text-editor/src/editor/controllers/SelectionController.test.js index 726acf2e4c..0885223ad5 100644 --- a/frontend/text-editor/src/editor/controllers/SelectionController.test.js +++ b/frontend/text-editor/src/editor/controllers/SelectionController.test.js @@ -278,9 +278,9 @@ describe("SelectionController", () => { expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe( "Hello", ); - expect( - textEditorMock.root.lastChild.firstChild.firstChild.nodeValue, - ).toBe(", World!"); + expect(textEditorMock.root.lastChild.firstChild.firstChild.nodeValue).toBe( + ", World!", + ); }); test("`insertPaste` should insert a paragraph from a pasted fragment (at middle)", () => { @@ -292,7 +292,12 @@ describe("SelectionController", () => { textEditorMock, 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 fragment = document.createDocumentFragment(); fragment.append(paragraph); @@ -315,9 +320,9 @@ describe("SelectionController", () => { expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe( "Lorem ", ); - expect(textEditorMock.root.children.item(1).firstChild.firstChild.nodeValue).toBe( - "ipsum ", - ); + expect( + textEditorMock.root.children.item(1).firstChild.firstChild.nodeValue, + ).toBe("ipsum "); expect(textEditorMock.root.lastChild.firstChild.firstChild.nodeValue).toBe( "dolor", ); @@ -359,25 +364,21 @@ describe("SelectionController", () => { expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe( "Hello", ); - expect( - textEditorMock.root.lastChild.firstChild.firstChild.nodeValue, - ).toBe(", World!"); + expect(textEditorMock.root.lastChild.firstChild.firstChild.nodeValue).toBe( + ", World!", + ); }); 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 selection = document.getSelection(); const selectionController = new SelectionController( textEditorMock, selection, ); - focus( - selection, - textEditorMock, - root.firstChild.firstChild.firstChild, - 0, - ); + focus(selection, textEditorMock, root.firstChild.firstChild.firstChild, 0); const paragraph = createParagraph([createTextSpan(new Text("Hello"))]); paragraph.dataset.textSpan = "force"; const fragment = document.createDocumentFragment(); @@ -415,7 +416,12 @@ describe("SelectionController", () => { textEditorMock, 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 "))]); paragraph.dataset.textSpan = "force"; const fragment = document.createDocumentFragment(); @@ -439,9 +445,9 @@ describe("SelectionController", () => { expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe( "Lorem ", ); - expect(textEditorMock.root.firstChild.children.item(1).firstChild.nodeValue).toBe( - "ipsum ", - ); + expect( + textEditorMock.root.firstChild.children.item(1).firstChild.nodeValue, + ).toBe("ipsum "); expect( textEditorMock.root.firstChild.children.item(2).firstChild.nodeValue, ).toBe("dolor"); @@ -461,9 +467,7 @@ describe("SelectionController", () => { root.firstChild.firstChild.firstChild, "Hello".length, ); - const paragraph = createParagraph([ - createTextSpan(new Text(", World!")) - ]); + const paragraph = createParagraph([createTextSpan(new Text(", World!"))]); paragraph.dataset.textSpan = "force"; const fragment = document.createDocumentFragment(); fragment.append(paragraph); @@ -486,9 +490,9 @@ describe("SelectionController", () => { expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe( "Hello", ); - expect(textEditorMock.root.firstChild.children.item(1).firstChild.nodeValue).toBe( - ", World!", - ); + expect( + textEditorMock.root.firstChild.children.item(1).firstChild.nodeValue, + ).toBe(", World!"); }); test("`removeBackwardText` should remove text in backward direction (backspace)", () => { diff --git a/frontend/text-editor/src/editor/controllers/StyleDeclaration.js b/frontend/text-editor/src/editor/controllers/StyleDeclaration.js index fcca9e39b4..09a4ce9699 100644 --- a/frontend/text-editor/src/editor/controllers/StyleDeclaration.js +++ b/frontend/text-editor/src/editor/controllers/StyleDeclaration.js @@ -77,7 +77,10 @@ export class StyleDeclaration { const currentValue = this.getPropertyValue(name); if (this.#isQuotedValue(currentValue, value)) { return this.setProperty(name, value); - } else if (currentValue === "" && value === StyleDeclaration.Property.NULL) { + } else if ( + currentValue === "" && + value === StyleDeclaration.Property.NULL + ) { return this.setProperty(name, value); } else if (currentValue === "" && ["initial", "none"].includes(value)) { return this.setProperty(name, value); @@ -107,4 +110,4 @@ export class StyleDeclaration { } } -export default StyleDeclaration +export default StyleDeclaration; diff --git a/frontend/text-editor/src/editor/debug/SelectionControllerDebug.js b/frontend/text-editor/src/editor/debug/SelectionControllerDebug.js index e588497a4d..cd8eef9665 100644 --- a/frontend/text-editor/src/editor/debug/SelectionControllerDebug.js +++ b/frontend/text-editor/src/editor/debug/SelectionControllerDebug.js @@ -43,33 +43,38 @@ export class SelectionControllerDebug { this.#elements.isParagraphStart.checked = selectionController.isParagraphStart; 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.isTextAnchor.checked = selectionController.isTextAnchor; this.#elements.isTextFocus.checked = selectionController.isTextFocus; this.#elements.focusNode.value = this.getNodeDescription( selectionController.focusNode, - selectionController.focusOffset + selectionController.focusOffset, ); this.#elements.focusOffset.value = selectionController.focusOffset; this.#elements.anchorNode.value = this.getNodeDescription( selectionController.anchorNode, - selectionController.anchorOffset + selectionController.anchorOffset, ); this.#elements.anchorOffset.value = selectionController.anchorOffset; this.#elements.focusTextSpan.value = this.getNodeDescription( - selectionController.focusTextSpan + selectionController.focusTextSpan, ); this.#elements.anchorTextSpan.value = this.getNodeDescription( - selectionController.anchorTextSpan + selectionController.anchorTextSpan, ); this.#elements.focusParagraph.value = this.getNodeDescription( - selectionController.focusParagraph + selectionController.focusParagraph, ); 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); } } diff --git a/frontend/text-editor/src/playground/geom.js b/frontend/text-editor/src/playground/geom.js index 4f9962d2ab..2d065eee62 100644 --- a/frontend/text-editor/src/playground/geom.js +++ b/frontend/text-editor/src/playground/geom.js @@ -39,10 +39,7 @@ export class Point { } polar(angle, length = 1.0) { - return this.set( - Math.cos(angle) * length, - Math.sin(angle) * length - ); + return this.set(Math.cos(angle) * length, Math.sin(angle) * length); } add({ x, y }) { @@ -119,10 +116,7 @@ export class Point { export class Rect { static create(x, y, width, height) { - return new Rect( - new Point(width, height), - new Point(x, y), - ); + return new Rect(new Point(width, height), new Point(x, y)); } #size; @@ -228,10 +222,7 @@ export class Rect { } clone() { - return new Rect( - this.#size.clone(), - this.#position.clone(), - ); + return new Rect(this.#size.clone(), this.#position.clone()); } toFixed(fractionDigits = 0) { diff --git a/frontend/text-editor/src/playground/shape.js b/frontend/text-editor/src/playground/shape.js index 2f462202dc..e54579a974 100644 --- a/frontend/text-editor/src/playground/shape.js +++ b/frontend/text-editor/src/playground/shape.js @@ -82,13 +82,13 @@ export class Shape { } get rotation() { - return this.#rotation + return this.#rotation; } set rotation(newRotation) { if (!Number.isFinite(newRotation)) { - throw new TypeError('Invalid rotation') + throw new TypeError("Invalid rotation"); } - this.#rotation = newRotation + this.#rotation = newRotation; } } diff --git a/frontend/text-editor/src/playground/style.js b/frontend/text-editor/src/playground/style.js index aeef4a236c..bb52b93d9d 100644 --- a/frontend/text-editor/src/playground/style.js +++ b/frontend/text-editor/src/playground/style.js @@ -6,8 +6,7 @@ export function fromStyle(style) { const entry = Object.entries(this).find(([name, value]) => name === fromStyleValue(style) ? value : 0, ); - if (!entry) - return; + if (!entry) return; const [name] = entry; return name; diff --git a/frontend/text-editor/src/playground/viewport.js b/frontend/text-editor/src/playground/viewport.js index 87d0d34462..a6e3980573 100644 --- a/frontend/text-editor/src/playground/viewport.js +++ b/frontend/text-editor/src/playground/viewport.js @@ -1,4 +1,4 @@ -import { Point } from './geom'; +import { Point } from "./geom"; export class Viewport { #zoom; @@ -38,7 +38,7 @@ export class Viewport { } pan(dx, dy) { - this.#position.x += dx / this.#zoom - this.#position.y += dy / this.#zoom + this.#position.x += dx / this.#zoom; + this.#position.y += dy / this.#zoom; } } diff --git a/frontend/text-editor/src/test/TextEditorMock.js b/frontend/text-editor/src/test/TextEditorMock.js index 2fe620d3a3..2ce0ae4c06 100644 --- a/frontend/text-editor/src/test/TextEditorMock.js +++ b/frontend/text-editor/src/test/TextEditorMock.js @@ -1,6 +1,9 @@ import { createRoot } from "../editor/content/dom/Root.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"; export class TextEditorMock extends EventTarget { @@ -38,14 +41,14 @@ export class TextEditorMock extends EventTarget { static createTextEditorMockWithRoot(root) { const container = TextEditorMock.getTemplate(); const selectionImposterElement = container.querySelector( - ".text-editor-selection-imposter" + ".text-editor-selection-imposter", ); const textEditorMock = new TextEditorMock( container.querySelector(".text-editor-content"), { root, selectionImposterElement, - } + }, ); return textEditorMock; } @@ -86,8 +89,8 @@ export class TextEditorMock extends EventTarget { return this.createTextEditorMockWithParagraphs([ createParagraph([ text.length === 0 - ? createEmptyTextSpan() - : createTextSpan(new Text(text)) + ? createEmptyTextSpan() + : createTextSpan(new Text(text)), ]), ]); } @@ -100,7 +103,9 @@ export class TextEditorMock extends EventTarget { * @returns */ static createTextEditorMockWithParagraph(textSpans) { - return this.createTextEditorMockWithParagraphs([createParagraph(textSpans)]); + return this.createTextEditorMockWithParagraphs([ + createParagraph(textSpans), + ]); } #element = null; diff --git a/frontend/text-editor/vite.config.js b/frontend/text-editor/vite.config.js index 34d8e7cdfc..bce37c7715 100644 --- a/frontend/text-editor/vite.config.js +++ b/frontend/text-editor/vite.config.js @@ -1,30 +1,28 @@ import path from "node:path"; -import fs from 'node:fs/promises'; +import fs from "node:fs/promises"; import { defineConfig } from "vite"; import { coverageConfigDefaults } from "vitest/config"; async function waitFor(timeInMillis) { - return new Promise(resolve => - setTimeout(_ => resolve(), timeInMillis) - ); + return new Promise((resolve) => setTimeout((_) => resolve(), timeInMillis)); } const wasmWatcherPlugin = (options = {}) => { return { name: "vite-wasm-watcher-plugin", configureServer(server) { - 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.wasm"); + server.watcher.add("../resources/public/js/render_wasm.js"); server.watcher.on("change", async (file) => { if (file.includes("../resources/")) { // If we copy the files immediately, we end // up with an empty .js file (I don't know why). - await waitFor(100) + await waitFor(100); // copy files. await fs.copyFile( path.resolve(file), - path.resolve('./src/wasm/', path.basename(file)) - ) + path.resolve("./src/wasm/", path.basename(file)), + ); console.log(`${file} changed`); } }); @@ -49,9 +47,7 @@ const wasmWatcherPlugin = (options = {}) => { }; export default defineConfig({ - plugins: [ - wasmWatcherPlugin() - ], + plugins: [wasmWatcherPlugin()], root: "./src", resolve: { alias: { diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 68a318675e..c519e27b6a 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -230,20 +230,62 @@ pub extern "C" fn resize_viewbox(width: i32, height: i32) { #[no_mangle] pub extern "C" fn set_view(zoom: f32, x: f32, y: f32) { with_state_mut!(state, { + performance::begin_measure!("set_view"); let render_state = state.render_state_mut(); 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] pub extern "C" fn set_view_end() { 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(); - if state.render_state.options.is_profile_rebuild_tiles() { - state.rebuild_tiles(); - } else { - state.rebuild_tiles_shallow(); + + 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); + } + 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 + ); } }); } @@ -261,7 +303,7 @@ pub extern "C" fn set_focus_mode() { let entries: Vec = bytes .chunks(size_of::<::BytesType>()) - .map(|data| Uuid::from_bytes(data.try_into().unwrap())) + .map(|data| Uuid::try_from(data).unwrap()) .collect(); with_state_mut!(state, { @@ -481,7 +523,7 @@ pub extern "C" fn set_children() { let entries: Vec = bytes .chunks(size_of::<::BytesType>()) - .map(|data| Uuid::from_bytes(data.try_into().unwrap())) + .map(|data| Uuid::try_from(data).unwrap()) .collect(); set_children_set(entries); @@ -637,7 +679,7 @@ pub extern "C" fn propagate_modifiers(pixel_precision: bool) -> *mut u8 { let entries: Vec<_> = bytes .chunks(size_of::<::BytesType>()) - .map(|data| TransformEntry::from_bytes(data.try_into().unwrap())) + .map(|data| TransformEntry::try_from(data).unwrap()) .collect(); with_state!(state, { @@ -652,7 +694,7 @@ pub extern "C" fn set_modifiers() { let entries: Vec<_> = bytes .chunks(size_of::<::BytesType>()) - .map(|data| TransformEntry::from_bytes(data.try_into().unwrap())) + .map(|data| TransformEntry::try_from(data).unwrap()) .collect(); let mut modifiers = HashMap::new(); diff --git a/render-wasm/src/mem.rs b/render-wasm/src/mem.rs index bf5ce418b0..54cf4aa9d1 100644 --- a/render-wasm/src/mem.rs +++ b/render-wasm/src/mem.rs @@ -57,10 +57,8 @@ pub fn bytes_or_empty() -> Vec { guard.take().unwrap_or_default() } -pub trait SerializableResult { +pub trait SerializableResult: From + Into { type BytesType; - fn from_bytes(bytes: Self::BytesType) -> Self; - fn as_bytes(&self) -> Self::BytesType; fn clone_to_slice(&self, slice: &mut [u8]); } diff --git a/render-wasm/src/options.rs b/render-wasm/src/options.rs index 37afacfc21..1267c900ea 100644 --- a/render-wasm/src/options.rs +++ b/render-wasm/src/options.rs @@ -1,2 +1,3 @@ pub const DEBUG_VISIBLE: u32 = 0x01; pub const PROFILE_REBUILD_TILES: u32 = 0x02; +pub const FAST_MODE: u32 = 0x04; diff --git a/render-wasm/src/performance.rs b/render-wasm/src/performance.rs index 0d508b1b89..6f9eb233de 100644 --- a/render-wasm/src/performance.rs +++ b/render-wasm/src/performance.rs @@ -1,7 +1,3 @@ -#[allow(unused_imports)] -#[cfg(target_arch = "wasm32")] -use crate::get_now; - #[allow(dead_code)] #[cfg(target_arch = "wasm32")] pub fn get_time() -> i32 { @@ -15,6 +11,68 @@ pub fn get_time() -> 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_rules! mark { ($name:expr) => { diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 2b3038999d..2af83dc286 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -9,7 +9,8 @@ mod options; mod shadows; mod strokes; mod surfaces; -mod text; +pub mod text; + mod ui; use skia_safe::{self as skia, Matrix, RRect, Rect}; @@ -928,6 +929,8 @@ impl RenderState { } 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(); if let Some(snapshot) = &self.cached_target_snapshot { let canvas = self.surfaces.canvas(SurfaceId::Target); @@ -965,6 +968,8 @@ impl RenderState { self.flush_and_submit(); } + performance::end_measure!("render_from_cache"); + performance::end_timed_log!("render_from_cache", _start); } pub fn start_render_loop( @@ -974,6 +979,7 @@ impl RenderState { timestamp: i32, sync_render: bool, ) -> Result<(), String> { + let _start = performance::begin_timed_log!("start_render_loop"); let scale = self.get_scale(); self.tile_viewbox.update(self.viewbox, scale); @@ -1004,10 +1010,12 @@ impl RenderState { // FIXME - review debug // debug::render_debug_tiles_for_viewbox(self); + let _tile_start = performance::begin_timed_log!("tile_cache_update"); performance::begin_measure!("tile_cache"); self.pending_tiles .update(&self.tile_viewbox, &self.surfaces); performance::end_measure!("tile_cache"); + performance::end_timed_log!("tile_cache_update", _tile_start); self.pending_nodes.clear(); if self.pending_nodes.capacity() < tree.len() { @@ -1031,6 +1039,7 @@ impl RenderState { } performance::end_measure!("start_render_loop"); + performance::end_timed_log!("start_render_loop", _start); Ok(()) } @@ -1479,8 +1488,11 @@ impl RenderState { .surfaces .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 - 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 // // The shadow rendering uses a two-pass technique to ensure that overlapping @@ -2054,6 +2066,10 @@ impl RenderState { 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) { self.touched_ids.insert(uuid); } diff --git a/render-wasm/src/render/options.rs b/render-wasm/src/render/options.rs index 74fb1cf70c..9dd57309e7 100644 --- a/render-wasm/src/render/options.rs +++ b/render-wasm/src/render/options.rs @@ -15,6 +15,19 @@ impl RenderOptions { 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 { self.dpr.unwrap_or(1.0) } diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index 7285386af5..58f10cbc6c 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -2,18 +2,15 @@ use super::{filters, RenderState, Shape, SurfaceId}; use crate::{ math::Rect, shapes::{ - merge_fills, set_paint_fill, ParagraphBuilderGroup, Stroke, StrokeKind, TextContent, - VerticalAlign, + calculate_position_data, calculate_text_layout_data, merge_fills, set_paint_fill, + ParagraphBuilderGroup, Stroke, StrokeKind, TextContent, }, utils::{get_fallback_fonts, get_font_collection}, }; use skia_safe::{ self as skia, canvas::SaveLayerRec, - textlayout::{ - LineMetrics, Paragraph, ParagraphBuilder, RectHeightStyle, RectWidthStyle, StyleMetrics, - TextDecoration, TextStyle, - }, + textlayout::{ParagraphBuilder, StyleMetrics, TextDecoration, TextStyle}, Canvas, ImageFilter, Paint, Path, }; @@ -241,48 +238,24 @@ fn draw_text( paragraph_builder_groups: &mut [Vec], ) { let text_content = shape.get_text_content(); - let selrect_width = shape.selrect().width(); - let text_width = text_content.get_width(selrect_width); - 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 layout_info = + calculate_text_layout_data(shape, text_content, paragraph_builder_groups, true); let layer_rec = SaveLayerRec::default(); canvas.save_layer(&layer_rec); - let mut previous_line_height = text_content.normalized_line_height(); - for paragraph_builder_group in paragraph_builder_groups { - let group_offset_y = global_offset_y; - let group_len = paragraph_builder_group.len(); - let mut paragraph_offset_y = previous_line_height; - - for (paragraph_index, paragraph_builder) in paragraph_builder_group.iter_mut().enumerate() { - let mut paragraph = paragraph_builder.build(); - paragraph.layout(text_width); - let xy = (shape.selrect().x(), shape.selrect().y() + group_offset_y); - paragraph.paint(canvas, xy); - - let line_metrics = paragraph.get_line_metrics(); - - if paragraph_index == group_len - 1 { - if line_metrics.is_empty() { - paragraph_offset_y = paragraph.ideographic_baseline(); - } else { - paragraph_offset_y = paragraph.height(); - previous_line_height = paragraph.ideographic_baseline(); - } - } - - for line_metrics in paragraph.get_line_metrics().iter() { - render_text_decoration(canvas, ¶graph, paragraph_builder, line_metrics, xy); - } + for para in &layout_info.paragraphs { + para.paragraph.paint(canvas, (para.x, para.y)); + for deco in ¶.decorations { + draw_text_decorations( + canvas, + &deco.text_style, + Some(deco.y), + deco.thickness, + deco.left, + deco.width, + ); } - - global_offset_y += paragraph_offset_y; } } @@ -307,7 +280,7 @@ fn draw_text_decorations( } } -fn calculate_decoration_metrics( +pub fn calculate_decoration_metrics( style_metrics: &Vec<(usize, &StyleMetrics)>, line_baseline: f32, ) -> (f32, Option, f32, Option) { @@ -357,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)] fn calculate_total_paragraphs_height(paragraphs: &mut [ParagraphBuilder], width: f32) -> f32 { paragraphs @@ -506,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? // Type::Text(text_content) => { // self.surfaces diff --git a/render-wasm/src/shapes/modifiers.rs b/render-wasm/src/shapes/modifiers.rs index 07c5230ce8..02e7911aaa 100644 --- a/render-wasm/src/shapes/modifiers.rs +++ b/render-wasm/src/shapes/modifiers.rs @@ -384,7 +384,7 @@ pub fn propagate_modifiers( if math::identitish(&entry.transform) { Modifier::Reflow(entry.id) } else { - Modifier::Transform(entry.clone()) + Modifier::Transform(*entry) } }) .collect(); diff --git a/render-wasm/src/shapes/shape_to_path.rs b/render-wasm/src/shapes/shape_to_path.rs index d98c702420..03d26b2b08 100644 --- a/render-wasm/src/shapes/shape_to_path.rs +++ b/render-wasm/src/shapes/shape_to_path.rs @@ -63,10 +63,50 @@ fn make_corner( 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) -> Vec { let sr = shape.selrect; 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 p2 = (sr.x() + r1.x, sr.y()); let p3 = (sr.x() + sr.width() - r2.x, sr.y()); diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index ecace5d187..42cb6cd373 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -1,3 +1,4 @@ +use crate::render::text::calculate_decoration_metrics; use crate::{ math::{Bounds, Matrix, Rect}, 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 * 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))) } +// 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> { + 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)] pub struct TextContent { pub paragraphs: Vec, @@ -440,59 +494,15 @@ impl TextContent { 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> { - 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. fn text_layout_auto_width(&self) -> TextContentLayoutResult { let mut paragraph_builders = self.paragraph_builder_group_from_text(None); 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 = - self.build_paragraphs_from_paragraph_builders(&mut paragraph_builders, f32::MAX); + build_paragraphs_from_paragraph_builders(&mut paragraph_builders, f32::MAX); let (width, height) = paragraphs @@ -521,10 +531,9 @@ impl TextContent { let mut paragraph_builders = self.paragraph_builder_group_from_text(None); let normalized_line_height = - self.calculate_normalized_line_height(&mut paragraph_builders, width); + calculate_normalized_line_height(&mut paragraph_builders, width); - let paragraphs = - self.build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width); + let paragraphs = build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width); let height = paragraphs .iter() .flatten() @@ -546,10 +555,9 @@ impl TextContent { let mut paragraph_builders = self.paragraph_builder_group_from_text(None); let normalized_line_height = - self.calculate_normalized_line_height(&mut paragraph_builders, width); + calculate_normalized_line_height(&mut paragraph_builders, width); - let paragraphs = - self.build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width); + let paragraphs = build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width); let paragraph_height = paragraphs .iter() .flatten() @@ -576,8 +584,7 @@ impl TextContent { pub fn get_height(&self, width: f32) -> f32 { let mut paragraph_builders = self.paragraph_builder_group_from_text(None); - let paragraphs = - self.build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width); + let paragraphs = build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width); let paragraph_height = paragraphs .iter() .flatten() @@ -733,8 +740,7 @@ impl TextContent { let width = self.width(); let mut paragraph_builders = self.paragraph_builder_group_from_text(None); - let paragraphs = - self.build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width); + let paragraphs = build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width); paragraphs .iter() @@ -863,17 +869,17 @@ impl Paragraph { #[derive(Debug, PartialEq, Clone)] pub struct TextSpan { - text: String, - font_family: FontFamily, - font_size: f32, - line_height: f32, - letter_spacing: f32, - font_weight: i32, - font_variant_id: Uuid, - text_decoration: Option, - text_transform: Option, - text_direction: TextDirection, - fills: Vec, + pub text: String, + pub font_family: FontFamily, + pub font_size: f32, + pub line_height: f32, + pub letter_spacing: f32, + pub font_weight: i32, + pub font_variant_id: Uuid, + pub text_decoration: Option, + pub text_transform: Option, + pub text_direction: TextDirection, + pub fills: Vec, } 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, + pub decorations: Vec, +} + +#[allow(dead_code)] +#[derive(Debug)] +pub struct TextLayoutData { + pub position_data: Vec, + pub content_rect: Rect, + pub paragraphs: Vec, +} + +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 = 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 = 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 = 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 { + 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 +} diff --git a/render-wasm/src/shapes/transform.rs b/render-wasm/src/shapes/transform.rs index f3eba62d67..d6997599d8 100644 --- a/render-wasm/src/shapes/transform.rs +++ b/render-wasm/src/shapes/transform.rs @@ -23,13 +23,13 @@ impl Modifier { } } -#[derive(PartialEq, Debug, Clone)] +#[derive(PartialEq, Debug, Clone, Copy)] pub enum TransformEntrySource { Input, Propagate, } -#[derive(PartialEq, Debug, Clone)] +#[derive(PartialEq, Debug, Clone, Copy)] #[repr(C)] pub struct TransformEntry { pub id: Uuid, @@ -65,10 +65,8 @@ impl TransformEntry { } } -impl SerializableResult for TransformEntry { - type BytesType = [u8; 40]; - - fn from_bytes(bytes: Self::BytesType) -> Self { +impl From<[u8; 40]> for TransformEntry { + fn from(bytes: [u8; 40]) -> Self { let id = uuid_from_u32_quartet( u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]), 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) } +} - fn as_bytes(&self) -> Self::BytesType { - let mut result: Self::BytesType = [0; 40]; - let (a, b, c, d) = uuid_to_u32_quartet(&self.id); +impl TryFrom<&[u8]> for TransformEntry { + type Error = String; + fn try_from(bytes: &[u8]) -> Result { + let bytes: [u8; 40] = bytes + .try_into() + .map_err(|_| "Invalid transform entry bytes".to_string())?; + Ok(TransformEntry::from(bytes)) + } +} + +impl From 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[4..8].clone_from_slice(&b.to_le_bytes()); result[8..12].clone_from_slice(&c.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[20..24].clone_from_slice(&self.transform[3].to_le_bytes()); - result[24..28].clone_from_slice(&self.transform[1].to_le_bytes()); - result[28..32].clone_from_slice(&self.transform[4].to_le_bytes()); - result[32..36].clone_from_slice(&self.transform[2].to_le_bytes()); - result[36..40].clone_from_slice(&self.transform[5].to_le_bytes()); + result[16..20].clone_from_slice(&value.transform[0].to_le_bytes()); + result[20..24].clone_from_slice(&value.transform[3].to_le_bytes()); + result[24..28].clone_from_slice(&value.transform[1].to_le_bytes()); + result[28..32].clone_from_slice(&value.transform[4].to_le_bytes()); + result[32..36].clone_from_slice(&value.transform[2].to_le_bytes()); + result[36..40].clone_from_slice(&value.transform[5].to_le_bytes()); result } +} + +impl SerializableResult for TransformEntry { + type BytesType = [u8; 40]; // 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. 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), ); - let bytes = entry.as_bytes(); + let bytes: [u8; 40] = entry.into(); - assert_eq!(entry, TransformEntry::from_bytes(bytes)); + assert_eq!(entry, TransformEntry::from(bytes)); } } diff --git a/render-wasm/src/uuid.rs b/render-wasm/src/uuid.rs index 519d2e158a..e4f5120b59 100644 --- a/render-wasm/src/uuid.rs +++ b/render-wasm/src/uuid.rs @@ -49,10 +49,8 @@ impl fmt::Display for Uuid { } } -impl SerializableResult for Uuid { - type BytesType = [u8; 16]; - - fn from_bytes(bytes: Self::BytesType) -> Self { +impl From<[u8; 16]> for Uuid { + fn from(bytes: [u8; 16]) -> Self { Self(*uuid_from_u32_quartet( u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]), 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]]), )) } +} - fn as_bytes(&self) -> Self::BytesType { - let mut result: Self::BytesType = [0; 16]; - let (a, b, c, d) = uuid_to_u32_quartet(self); +impl TryFrom<&[u8]> for Uuid { + type Error = String; + fn try_from(bytes: &[u8]) -> Result { + let bytes: [u8; 16] = bytes + .try_into() + .map_err(|_| "Invalid UUID bytes".to_string())?; + Ok(Self::from(bytes)) + } +} + +impl From 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[4..8].clone_from_slice(&b.to_le_bytes()); result[8..12].clone_from_slice(&c.to_le_bytes()); @@ -71,10 +81,15 @@ impl SerializableResult for Uuid { result } +} + +impl SerializableResult for Uuid { + type BytesType = [u8; 16]; // 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. 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); } } diff --git a/render-wasm/src/wasm/fills/image.rs b/render-wasm/src/wasm/fills/image.rs index 4c1e49b94d..9c7a5d312b 100644 --- a/render-wasm/src/wasm/fills/image.rs +++ b/render-wasm/src/wasm/fills/image.rs @@ -1,5 +1,5 @@ use crate::mem; -use crate::mem::SerializableResult; +// use crate::mem::SerializableResult; use crate::uuid::Uuid; use crate::with_state_mut; use crate::STATE; @@ -48,8 +48,8 @@ pub struct ShapeImageIds { impl From<[u8; IMAGE_IDS_SIZE]> for ShapeImageIds { fn from(bytes: [u8; IMAGE_IDS_SIZE]) -> Self { - let shape_id = Uuid::from_bytes(bytes[0..16].try_into().unwrap()); - let image_id = Uuid::from_bytes(bytes[16..32].try_into().unwrap()); + let shape_id = Uuid::try_from(&bytes[0..16]).unwrap(); + let image_id = Uuid::try_from(&bytes[16..32]).unwrap(); ShapeImageIds { shape_id, image_id } } } @@ -93,7 +93,7 @@ pub extern "C" fn store_image() { /// Stores an image from an existing WebGL texture, avoiding re-decoding /// Expected memory layout: /// - bytes 0-15: shape UUID -/// - bytes 16-31: image UUID +/// - bytes 16-31: image UUID /// - bytes 32-35: is_thumbnail flag (u32) /// - bytes 36-39: GL texture ID (u32) /// - bytes 40-43: width (i32) diff --git a/render-wasm/src/wasm/layouts.rs b/render-wasm/src/wasm/layouts.rs index 4e97222ca3..903edaa0e6 100644 --- a/render-wasm/src/wasm/layouts.rs +++ b/render-wasm/src/wasm/layouts.rs @@ -40,7 +40,7 @@ pub extern "C" fn clear_shape_layout() { } #[no_mangle] -pub extern "C" fn set_layout_child_data( +pub extern "C" fn set_layout_data( margin_top: f32, margin_right: f32, margin_bottom: f32, diff --git a/render-wasm/src/wasm/paths.rs b/render-wasm/src/wasm/paths.rs index 744ee361f6..815c9d2804 100644 --- a/render-wasm/src/wasm/paths.rs +++ b/render-wasm/src/wasm/paths.rs @@ -51,25 +51,20 @@ impl TryFrom<&[u8]> for RawSegmentData { } } +impl From for [u8; RAW_SEGMENT_DATA_SIZE] { + fn from(value: RawSegmentData) -> Self { + unsafe { std::mem::transmute(value) } + } +} + impl SerializableResult for RawSegmentData { type BytesType = [u8; RAW_SEGMENT_DATA_SIZE]; - fn from_bytes(bytes: Self::BytesType) -> Self { - unsafe { std::mem::transmute(bytes) } - } - - fn as_bytes(&self) -> Self::BytesType { - let ptr = self as *const RawSegmentData as *const u8; - let bytes: &[u8] = unsafe { std::slice::from_raw_parts(ptr, RAW_SEGMENT_DATA_SIZE) }; - let mut result = [0; RAW_SEGMENT_DATA_SIZE]; - result.copy_from_slice(bytes); - result - } - // 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. 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); } } diff --git a/render-wasm/src/wasm/paths/bools.rs b/render-wasm/src/wasm/paths/bools.rs index e3c7b15c59..36bd0e4440 100644 --- a/render-wasm/src/wasm/paths/bools.rs +++ b/render-wasm/src/wasm/paths/bools.rs @@ -48,7 +48,7 @@ pub extern "C" fn calculate_bool(raw_bool_type: u8) -> *mut u8 { let entries: Vec = bytes .chunks(size_of::<::BytesType>()) - .map(|data| Uuid::from_bytes(data.try_into().unwrap())) + .map(|data| Uuid::try_from(data).unwrap()) .collect(); mem::free_bytes(); diff --git a/render-wasm/src/wasm/text.rs b/render-wasm/src/wasm/text.rs index 1ae81d06b9..df2e72f841 100644 --- a/render-wasm/src/wasm/text.rs +++ b/render-wasm/src/wasm/text.rs @@ -2,13 +2,14 @@ use macros::ToJs; use super::{fills::RawFillData, fonts::RawFontStyle}; use crate::math::{Matrix, Point}; -use crate::mem; +use crate::mem::{self, SerializableResult}; use crate::shapes::{ self, GrowType, Shape, TextAlign, TextDecoration, TextDirection, TextTransform, Type, }; use crate::utils::{uuid_from_u32, uuid_from_u32_quartet}; use crate::{ - with_current_shape_mut, with_state, with_state_mut, with_state_mut_current_shape, STATE, + with_current_shape, with_current_shape_mut, with_state, with_state_mut, + with_state_mut_current_shape, STATE, }; const RAW_SPAN_DATA_SIZE: usize = std::mem::size_of::(); @@ -411,3 +412,39 @@ pub extern "C" fn get_caret_position_at(x: f32, y: f32) -> i32 { }); -1 } + +const RAW_POSITION_DATA_SIZE: usize = size_of::(); + +impl From<[u8; RAW_POSITION_DATA_SIZE]> for shapes::PositionData { + fn from(bytes: [u8; RAW_POSITION_DATA_SIZE]) -> Self { + unsafe { std::mem::transmute(bytes) } + } +} + +impl From for [u8; RAW_POSITION_DATA_SIZE] { + fn from(value: shapes::PositionData) -> Self { + unsafe { std::mem::transmute(value) } + } +} + +impl SerializableResult for shapes::PositionData { + type BytesType = [u8; RAW_POSITION_DATA_SIZE]; + + // 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. + fn clone_to_slice(&self, slice: &mut [u8]) { + let bytes = Self::BytesType::from(*self); + slice.clone_from_slice(&bytes); + } +} + +#[no_mangle] +pub extern "C" fn calculate_position_data() -> *mut u8 { + let mut result = Vec::::default(); + with_current_shape!(state, |shape: &Shape| { + if let Type::Text(text_content) = &shape.shape_type { + result = shapes::calculate_position_data(shape, text_content, false); + } + }); + mem::write_vec(result) +}