Merge remote-tracking branch 'origin/staging-render' into develop

This commit is contained in:
Andrey Antukh 2025-12-12 12:19:49 +01:00
commit 33c786498d
67 changed files with 1563 additions and 564 deletions

View File

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

View File

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

View File

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

View File

@ -1,12 +1,12 @@
export class Clipboard { export class Clipboard {
static Permission = { static Permission = {
ONLY_READ: ['clipboard-read'], ONLY_READ: ["clipboard-read"],
ONLY_WRITE: ['clipboard-write'], ONLY_WRITE: ["clipboard-write"],
ALL: ['clipboard-read', 'clipboard-write'] ALL: ["clipboard-read", "clipboard-write"],
} };
static enable(context, permissions) { static enable(context, permissions) {
return context.grantPermissions(permissions) return context.grantPermissions(permissions);
} }
static writeText(page, text) { static writeText(page, text) {
@ -18,8 +18,8 @@ export class Clipboard {
} }
constructor(page, context) { constructor(page, context) {
this.page = page this.page = page;
this.context = context this.context = context;
} }
enable(permissions) { enable(permissions) {

View File

@ -1,18 +1,16 @@
export class Transit { export class Transit {
static parse(value) { static parse(value) {
if (typeof value !== 'string') if (typeof value !== "string") return value;
return value
if (value.startsWith('~')) if (value.startsWith("~")) return value.slice(2);
return value.slice(2)
return value return value;
} }
static get(object, ...path) { static get(object, ...path) {
let aux = object; let aux = object;
for (const name of path) { for (const name of path) {
if (typeof name !== 'string') { if (typeof name !== "string") {
if (!(name in aux)) { if (!(name in aux)) {
return undefined; return undefined;
} }

View File

@ -9,7 +9,7 @@ export class BasePage {
*/ */
static async mockRPCs(page, paths, options) { static async mockRPCs(page, paths, options) {
for (const [path, jsonFilename] of Object.entries(paths)) { for (const [path, jsonFilename] of Object.entries(paths)) {
await this.mockRPC(page, path, jsonFilename, options) await this.mockRPC(page, path, jsonFilename, options);
} }
} }

View File

@ -1,7 +1,7 @@
import { expect } from "@playwright/test"; import { expect } from "@playwright/test";
import { readFile } from 'node:fs/promises'; import { readFile } from "node:fs/promises";
import { BaseWebSocketPage } from "./BaseWebSocketPage"; import { BaseWebSocketPage } from "./BaseWebSocketPage";
import { Transit } from '../../helpers/Transit'; import { Transit } from "../../helpers/Transit";
export class WorkspacePage extends BaseWebSocketPage { export class WorkspacePage extends BaseWebSocketPage {
static TextEditor = class TextEditor { static TextEditor = class TextEditor {

View File

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

View File

@ -1,5 +1,5 @@
import { test, expect } from "@playwright/test"; import { test, expect } from "@playwright/test";
import { Clipboard } from '../../helpers/Clipboard'; import { Clipboard } from "../../helpers/Clipboard";
import { WorkspacePage } from "../pages/WorkspacePage"; import { WorkspacePage } from "../pages/WorkspacePage";
const timeToWait = 100; const timeToWait = 100;
@ -11,14 +11,14 @@ test.beforeEach(async ({ page, context }) => {
await WorkspacePage.mockConfigFlags(page, ["enable-feature-text-editor-v2"]); await WorkspacePage.mockConfigFlags(page, ["enable-feature-text-editor-v2"]);
}); });
test.afterEach(async ({ context}) => { test.afterEach(async ({ context }) => {
context.clearPermissions(); context.clearPermissions();
}) });
test("Create a new text shape", async ({ page }) => { test("Create a new text shape", async ({ page }) => {
const initialText = "Lorem ipsum"; const initialText = "Lorem ipsum";
const workspace = new WorkspacePage(page, { const workspace = new WorkspacePage(page, {
textEditor: true textEditor: true,
}); });
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
await workspace.goToWorkspace(); await workspace.goToWorkspace();
@ -36,10 +36,7 @@ test("Create a new text shape from pasting text", async ({ page, context }) => {
textEditor: true, textEditor: true,
}); });
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
await workspace.mockRPC( await workspace.mockRPC("update-file?id=*", "text-editor/update-file.json");
"update-file?id=*",
"text-editor/update-file.json",
);
await workspace.goToWorkspace(); await workspace.goToWorkspace();
await Clipboard.writeText(page, textToPaste); 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(); 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 textToPaste = "Lorem ipsum";
const workspace = new WorkspacePage(page, { const workspace = new WorkspacePage(page, {
textEditor: true textEditor: true,
}); });
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
await workspace.goToWorkspace(); 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); expect(textContent).toBe(textToPaste);
await workspace.textEditor.stopEditing(); 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, { const workspace = new WorkspacePage(page, {
textEditor: true textEditor: true,
}); });
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json"); 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, page,
}) => { }) => {
const workspace = new WorkspacePage(page, { const workspace = new WorkspacePage(page, {
textEditor: true textEditor: true,
}); });
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json"); 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, page,
}) => { }) => {
const workspace = new WorkspacePage(page, { const workspace = new WorkspacePage(page, {
textEditor: true textEditor: true,
}); });
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json"); 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(); 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 textToPaste = " dolor sit amet";
const workspace = new WorkspacePage(page, { const workspace = new WorkspacePage(page, {
textEditor: true textEditor: true,
}); });
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json"); 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 ({ test("Update a new text shape prepending text by pasting text", async ({
page, context page,
context,
}) => { }) => {
const textToPaste = "Dolor sit amet "; const textToPaste = "Dolor sit amet ";
const workspace = new WorkspacePage(page, { const workspace = new WorkspacePage(page, {
textEditor: true textEditor: true,
}); });
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json"); 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 textToPaste = "Dolor sit amet";
const workspace = new WorkspacePage(page, { const workspace = new WorkspacePage(page, {
textEditor: true textEditor: true,
}); });
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json"); 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 textToPaste = "dolor sit amet";
const workspace = new WorkspacePage(page, { const workspace = new WorkspacePage(page, {
textEditor: true textEditor: true,
}); });
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json"); 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 textToPaste = "dolor sit amet";
const workspace = new WorkspacePage(page, { const workspace = new WorkspacePage(page, {
textEditor: true textEditor: true,
}); });
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json"); 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, page,
}) => { }) => {
const workspace = new WorkspacePage(page, { const workspace = new WorkspacePage(page, {
textEditor: true textEditor: true,
}); });
await workspace.setupEmptyFile(); await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json"); await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
await workspace.mockRPC( await workspace.mockRPC("update-file?id=*", "text-editor/update-file.json");
"update-file?id=*",
"text-editor/update-file.json",
);
await workspace.goToWorkspace(); await workspace.goToWorkspace();
await workspace.clickLeafLayer("Lorem ipsum"); await workspace.clickLeafLayer("Lorem ipsum");
await workspace.textEditor.startEditing(); 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.selectFromStart(5);
await workspace.textEditor.changeLineHeight(1.4); 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"); expect(lineHeight).toBe("1.4");
const textContent = await workspace.textEditor.waitForTextSpanContent(); const textContent = await workspace.textEditor.waitForTextSpanContent();

View File

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

View File

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

View File

@ -30,6 +30,9 @@
(def profile (def profile
(l/derived (l/key :profile) st/state)) (l/derived (l/key :profile) st/state))
(def current-page-id
(l/derived (l/key :current-page-id) st/state))
(def team (def team
(l/derived (fn [state] (l/derived (fn [state]
(let [team-id (:current-team-id state) (let [team-id (:current-team-id state)

View File

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

View File

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

View File

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

View File

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

View File

@ -12,10 +12,12 @@
[app.common.files.helpers :as cfh] [app.common.files.helpers :as cfh]
[app.common.geom.shapes :as gsh] [app.common.geom.shapes :as gsh]
[app.common.types.color :as clr] [app.common.types.color :as clr]
[app.common.types.component :as ctk]
[app.common.types.path :as path] [app.common.types.path :as path]
[app.common.types.shape :as cts] [app.common.types.shape :as cts]
[app.common.types.shape.layout :as ctl] [app.common.types.shape.layout :as ctl]
[app.main.data.workspace.transforms :as dwt] [app.main.data.workspace.transforms :as dwt]
[app.main.data.workspace.variants :as dwv]
[app.main.features :as features] [app.main.features :as features]
[app.main.refs :as refs] [app.main.refs :as refs]
[app.main.store :as st] [app.main.store :as st]
@ -257,6 +259,16 @@
first-shape (first selected-shapes) first-shape (first selected-shapes)
show-add-variant? (and single-select?
(or (ctk/is-variant-container? first-shape)
(ctk/is-variant? first-shape)))
add-variant
(mf/use-fn
(mf/deps first-shape)
#(st/emit!
(dwv/add-new-variant (:id first-shape))))
show-padding? show-padding?
(and (nil? transform) (and (nil? transform)
single-select? single-select?
@ -635,6 +647,12 @@
:hover-top-frame-id @hover-top-frame-id :hover-top-frame-id @hover-top-frame-id
:zoom zoom}]) :zoom zoom}])
(when (dbg/enabled? :text-outline)
[:& wvd/debug-text-wasm-position-data
{:selected-shapes selected-shapes
:objects base-objects
:zoom zoom}])
(when show-selection-handlers? (when show-selection-handlers?
[:g.selection-handlers {:clipPath "url(#clip-handlers)"} [:g.selection-handlers {:clipPath "url(#clip-handlers)"}
(when-not text-editing? (when-not text-editing?
@ -663,6 +681,11 @@
{:id (first selected) {:id (first selected)
:zoom zoom}]) :zoom zoom}])
(when show-add-variant?
[:> widgets/button-add* {:shape first-shape
:zoom zoom
:on-click add-variant}])
[:g.grid-layout-editor {:clipPath "url(#clip-handlers)"} [:g.grid-layout-editor {:clipPath "url(#clip-handlers)"}
(when show-grid-editor? (when show-grid-editor?
[:& grid-layout/editor [:& grid-layout/editor

View File

@ -23,6 +23,7 @@
[app.main.refs :as refs] [app.main.refs :as refs]
[app.main.render :as render] [app.main.render :as render]
[app.main.store :as st] [app.main.store :as st]
[app.main.ui.shapes.text]
[app.main.worker :as mw] [app.main.worker :as mw]
[app.render-wasm.api.fonts :as f] [app.render-wasm.api.fonts :as f]
[app.render-wasm.api.texts :as t] [app.render-wasm.api.texts :as t]
@ -33,7 +34,7 @@
[app.render-wasm.performance :as perf] [app.render-wasm.performance :as perf]
[app.render-wasm.serializers :as sr] [app.render-wasm.serializers :as sr]
[app.render-wasm.serializers.color :as sr-clr] [app.render-wasm.serializers.color :as sr-clr]
[app.render-wasm.svg-fills :as svg-fills] [app.render-wasm.svg-filters :as svg-filters]
;; FIXME: rename; confunsing name ;; FIXME: rename; confunsing name
[app.render-wasm.wasm :as wasm] [app.render-wasm.wasm :as wasm]
[app.util.debug :as dbg] [app.util.debug :as dbg]
@ -42,6 +43,7 @@
[app.util.modules :as mod] [app.util.modules :as mod]
[app.util.text.content :as tc] [app.util.text.content :as tc]
[beicon.v2.core :as rx] [beicon.v2.core :as rx]
[cuerdas.core :as str]
[promesa.core :as p] [promesa.core :as p]
[rumext.v2 :as mf])) [rumext.v2 :as mf]))
@ -703,7 +705,7 @@
(set-grid-layout-columns (get shape :layout-grid-columns)) (set-grid-layout-columns (get shape :layout-grid-columns))
(set-grid-layout-cells (get shape :layout-grid-cells))) (set-grid-layout-cells (get shape :layout-grid-cells)))
(defn set-layout-child (defn set-layout-data
[shape] [shape]
(let [margins (get shape :layout-item-margin) (let [margins (get shape :layout-item-margin)
margin-top (get margins :m1 0) margin-top (get margins :m1 0)
@ -726,7 +728,7 @@
is-absolute (boolean (get shape :layout-item-absolute)) is-absolute (boolean (get shape :layout-item-absolute))
z-index (get shape :layout-item-z-index)] z-index (get shape :layout-item-z-index)]
(h/call wasm/internal-module (h/call wasm/internal-module
"_set_layout_child_data" "_set_layout_data"
margin-top margin-top
margin-right margin-right
margin-bottom margin-bottom
@ -746,6 +748,11 @@
is-absolute is-absolute
(d/nilv z-index 0)))) (d/nilv z-index 0))))
(defn has-any-layout-prop? [shape]
(some #(and (keyword? %)
(str/starts-with? (name %) "layout-"))
(keys shape)))
(defn clear-layout (defn clear-layout
[] []
(h/call wasm/internal-module "_clear_shape_layout")) (h/call wasm/internal-module "_clear_shape_layout"))
@ -753,10 +760,10 @@
(defn- set-shape-layout (defn- set-shape-layout
[shape objects] [shape objects]
(clear-layout) (clear-layout)
(when (or (ctl/any-layout? shape) (when (or (ctl/any-layout? shape)
(ctl/any-layout-immediate-child? objects shape)) (ctl/any-layout-immediate-child? objects shape)
(set-layout-child shape)) (has-any-layout-prop? shape))
(set-layout-data shape))
(when (ctl/flex-layout? shape) (when (ctl/flex-layout? shape)
(set-flex-layout shape)) (set-flex-layout shape))
@ -875,27 +882,43 @@
(def render-finish (def render-finish
(letfn [(do-render [ts] (letfn [(do-render [ts]
(perf/begin-measure "render-finish")
(h/call wasm/internal-module "_set_view_end") (h/call wasm/internal-module "_set_view_end")
(render ts))] (render ts)
(perf/end-measure "render-finish"))]
(fns/debounce do-render DEBOUNCE_DELAY_MS))) (fns/debounce do-render DEBOUNCE_DELAY_MS)))
(def render-pan (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 (defn set-view-box
[prev-zoom zoom vbox] [prev-zoom zoom vbox]
(h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox))) (let [is-pan (mth/close? prev-zoom zoom)]
(perf/begin-measure "set-view-box")
(h/call wasm/internal-module "_set_view_start")
(h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox)))
(if (mth/close? prev-zoom zoom) (if is-pan
(do (render-pan) (do (perf/end-measure "set-view-box")
(render-finish)) (perf/begin-measure "set-view-box::pan")
(do (h/call wasm/internal-module "_render_from_cache" 0) (render-pan)
(render-finish)))) (render-finish)
(perf/end-measure "set-view-box::pan"))
(do (perf/end-measure "set-view-box")
(perf/begin-measure "set-view-box::zoom")
(h/call wasm/internal-module "_render_from_cache" 0)
(render-finish)
(perf/end-measure "set-view-box::zoom")))))
(defn set-object (defn set-object
[objects shape] [objects shape]
(perf/begin-measure "set-object") (perf/begin-measure "set-object")
(let [id (dm/get-prop shape :id) (let [shape (svg-filters/apply-svg-derived shape)
id (dm/get-prop shape :id)
type (dm/get-prop shape :type) type (dm/get-prop shape :type)
parent-id (get shape :parent-id) parent-id (get shape :parent-id)
@ -909,14 +932,7 @@
rotation (get shape :rotation) rotation (get shape :rotation)
transform (get shape :transform) transform (get shape :transform)
;; If the shape comes from an imported SVG (we know this because fills (get shape :fills)
;; it has the :svg-attrs attribute) and it does not have its
;; own fill, we set a default black fill. This fill will be
;; inherited by child nodes and emulates the behavior of
;; standard SVG, where a node without an explicit fill
;; defaults to black.
fills (svg-fills/resolve-shape-fills shape)
strokes (if (= type :group) strokes (if (= type :group)
[] (get shape :strokes)) [] (get shape :strokes))
children (get shape :shapes) children (get shape :shapes)
@ -960,12 +976,11 @@
(set-shape-svg-attrs svg-attrs)) (set-shape-svg-attrs svg-attrs))
(when (and (some? content) (= type :svg-raw)) (when (and (some? content) (= type :svg-raw))
(set-shape-svg-raw-content (get-static-markup shape))) (set-shape-svg-raw-content (get-static-markup shape)))
(when (some? shadows) (set-shape-shadows shadows)) (set-shape-shadows shadows)
(when (= type :text) (when (= type :text)
(set-shape-grow-type grow-type)) (set-shape-grow-type grow-type))
(set-shape-layout shape objects) (set-shape-layout shape objects)
(set-shape-selrect selrect) (set-shape-selrect selrect)
(let [pending_thumbnails (into [] (concat (let [pending_thumbnails (into [] (concat
@ -989,10 +1004,7 @@
(run! (run!
(fn [id] (fn [id]
(f/update-text-layout id) (f/update-text-layout id)
(mw/emit! {:cmd :index/update-text-rect (update-text-rect! id)))))
:page-id (:current-page-id @st/state)
:shape-id id
:dimensions (get-text-dimensions id)})))))
(defn process-pending (defn process-pending
([shapes thumbnails full on-complete] ([shapes thumbnails full on-complete]
@ -1233,6 +1245,8 @@
(when-not (nil? context) (when-not (nil? context)
(let [handle (.registerContext ^js gl context #js {"majorVersion" 2})] (let [handle (.registerContext ^js gl context #js {"majorVersion" 2})]
(.makeContextCurrent ^js gl handle) (.makeContextCurrent ^js gl handle)
(set! wasm/gl-context-handle handle)
(set! wasm/gl-context context)
;; Force the WEBGL_debug_renderer_info extension as emscripten does not enable it ;; Force the WEBGL_debug_renderer_info extension as emscripten does not enable it
(.getExtension context "WEBGL_debug_renderer_info") (.getExtension context "WEBGL_debug_renderer_info")
@ -1255,6 +1269,20 @@
(set! wasm/context-initialized? false) (set! wasm/context-initialized? false)
(h/call wasm/internal-module "_clean_up") (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 ;; If this calls panics we don't want to crash. This happens sometimes
;; with hot-reload in develop ;; with hot-reload in develop
(catch :default error (catch :default error
@ -1348,6 +1376,59 @@
(h/call wasm/internal-module "_end_temp_objects") (h/call wasm/internal-module "_end_temp_objects")
content))) 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 (defn init-wasm-module
[module] [module]
(let [default-fn (unchecked-get module "default") (let [default-fn (unchecked-get module "default")

View File

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

View File

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

View File

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

View File

@ -0,0 +1,98 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.render-wasm.svg-filters
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.svg :as csvg]
[app.common.uuid :as uuid]
[app.render-wasm.svg-fills :as svg-fills]))
(def ^:private drop-shadow-tags
#{:feOffset :feGaussianBlur :feColorMatrix})
(defn- find-filter-element
"Finds a filter element by tag in filter content."
[filter-content tag]
(some #(when (= tag (:tag %)) %) filter-content))
(defn- find-filter-def
[shape]
(let [filter-attr (or (dm/get-in shape [:svg-attrs :filter])
(dm/get-in shape [:svg-attrs :style :filter]))
svg-defs (dm/get-prop shape :svg-defs)]
(when (and filter-attr svg-defs)
(let [filter-ids (csvg/extract-ids filter-attr)]
(some #(get svg-defs %) filter-ids)))))
(defn- build-blur
[gaussian-blur]
(when gaussian-blur
{:id (uuid/next)
:type :layer-blur
;; For layer blur the value matches stdDeviation directly
:value (-> (dm/get-in gaussian-blur [:attrs :stdDeviation])
(d/parse-double 0))
:hidden false}))
(defn- build-drop-shadow
[filter-content drop-shadow-elements]
(let [offset-elem (find-filter-element filter-content :feOffset)]
(when (and offset-elem (seq drop-shadow-elements))
(let [blur-elem (find-filter-element drop-shadow-elements :feGaussianBlur)
dx (-> (dm/get-in offset-elem [:attrs :dx])
(d/parse-double 0))
dy (-> (dm/get-in offset-elem [:attrs :dy])
(d/parse-double 0))
blur-value (if blur-elem
(-> (dm/get-in blur-elem [:attrs :stdDeviation])
(d/parse-double 0)
(* 2))
0)]
[{:id (uuid/next)
:style :drop-shadow
:offset-x dx
:offset-y dy
:blur blur-value
:spread 0
:hidden false
;; TODO: parse feColorMatrix to extract color/opacity
:color {:color "#000000" :opacity 1}}]))))
(defn apply-svg-filters
"Derives native blur/shadow from SVG filter definitions when the shape does
not already have them. The SVG attributes are left untouched so SVG fallback
rendering keeps working the same way as gradient fills."
[shape]
(let [existing-blur (:blur shape)
existing-shadow (:shadow shape)]
(if-let [filter-def (find-filter-def shape)]
(let [content (:content filter-def)
gaussian-blur (find-filter-element content :feGaussianBlur)
drop-shadow-elements (filter #(contains? drop-shadow-tags (:tag %)) content)
blur (or existing-blur (build-blur gaussian-blur))
shadow (if (seq existing-shadow)
existing-shadow
(build-drop-shadow content drop-shadow-elements))]
(cond-> shape
blur (assoc :blur blur)
(seq shadow) (assoc :shadow shadow)))
shape)))
(defn apply-svg-derived
"Applies SVG-derived effects (fills, blur, shadows) uniformly.
- Keeps user fills if present; otherwise derives from SVG.
- Converts SVG filters into native blur/shadow when needed.
- Always returns shape with :fills (possibly []) and blur/shadow keys."
[shape]
(let [shape' (apply-svg-filters shape)
fills (or (svg-fills/resolve-shape-fills shape') [])]
(assoc shape'
:fills fills
:blur (:blur shape')
:shadow (:shadow shape'))))

View File

@ -9,6 +9,8 @@
(defonce internal-frame-id nil) (defonce internal-frame-id nil)
(defonce internal-module #js {}) (defonce internal-module #js {})
(defonce gl-context-handle nil)
(defonce gl-context nil)
(defonce serializers (defonce serializers
#js {:blur-type shared/RawBlurType #js {:blur-type shared/RawBlurType
:blend-mode shared/RawBlendMode :blend-mode shared/RawBlendMode

View File

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

View File

@ -0,0 +1,49 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns frontend-tests.svg-filters-test
(:require
[app.render-wasm.svg-filters :as svg-filters]
[cljs.test :refer [deftest is testing]]))
(def sample-filter-shape
{:svg-attrs {:filter "url(#simple-filter)"}
:svg-defs {"simple-filter"
{:tag :filter
:content [{:tag :feOffset :attrs {:dx "2" :dy "3"}}
{:tag :feGaussianBlur :attrs {:stdDeviation "4"}}]}}})
(deftest derives-blur-and-shadow-from-svg-filter
(let [shape (svg-filters/apply-svg-filters sample-filter-shape)
blur (:blur shape)
shadow (:shadow shape)]
(testing "layer blur derived from feGaussianBlur"
(is (= :layer-blur (:type blur)))
(is (= 4.0 (:value blur))))
(testing "drop shadow derived from filter chain"
(is (= [{:style :drop-shadow
:offset-x 2.0
:offset-y 3.0
:blur 8.0
:spread 0
:hidden false
:color {:color "#000000" :opacity 1}}]
(map #(dissoc % :id) shadow))))
(testing "svg attrs remain intact"
(is (= "url(#simple-filter)" (get-in shape [:svg-attrs :filter]))))))
(deftest keeps-existing-native-filters
(let [existing {:blur {:id :existing :type :layer-blur :value 1.0}
:shadow [{:id :shadow :style :drop-shadow}]}
shape (svg-filters/apply-svg-filters (merge sample-filter-shape existing))]
(is (= (:blur existing) (:blur shape)))
(is (= (:shadow existing) (:shadow shape)))))
(deftest skips-when-no-filter-definition
(let [shape {:svg-attrs {:fill "#fff"}}
result (svg-filters/apply-svg-filters shape)]
(is (= shape result))))

View File

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

View File

@ -664,8 +664,16 @@ export class TextEditor extends EventTarget {
* @param {boolean} allowHTMLPaste * @param {boolean} allowHTMLPaste
* @returns {Root} * @returns {Root}
*/ */
export function createRootFromHTML(html, style = undefined, allowHTMLPaste = undefined) { export function createRootFromHTML(
const fragment = mapContentFragmentFromHTML(html, style || undefined, allowHTMLPaste || undefined); html,
style = undefined,
allowHTMLPaste = undefined,
) {
const fragment = mapContentFragmentFromHTML(
html,
style || undefined,
allowHTMLPaste || undefined,
);
const root = createRoot([], style); const root = createRoot([], style);
root.replaceChildren(fragment); root.replaceChildren(fragment);
resetInertElement(); resetInertElement();

View File

@ -18,7 +18,10 @@ import { TextEditor } from "../TextEditor.js";
* @param {DataTransfer} clipboardData * @param {DataTransfer} clipboardData
* @returns {DocumentFragment} * @returns {DocumentFragment}
*/ */
function getFormattedFragmentFromClipboardData(selectionController, clipboardData) { function getFormattedFragmentFromClipboardData(
selectionController,
clipboardData,
) {
return mapContentFragmentFromHTML( return mapContentFragmentFromHTML(
clipboardData.getData("text/html"), clipboardData.getData("text/html"),
selectionController.currentStyle, selectionController.currentStyle,
@ -79,9 +82,14 @@ export function paste(event, editor, selectionController) {
let fragment = null; let fragment = null;
if (editor?.options?.allowHTMLPaste) { if (editor?.options?.allowHTMLPaste) {
fragment = getFormattedOrPlainFragmentFromClipboardData(event.clipboardData); fragment = getFormattedOrPlainFragmentFromClipboardData(
event.clipboardData,
);
} else { } else {
fragment = getPlainFragmentFromClipboardData(selectionController, event.clipboardData); fragment = getPlainFragmentFromClipboardData(
selectionController,
event.clipboardData,
);
} }
if (!fragment) { if (!fragment) {
@ -92,10 +100,9 @@ export function paste(event, editor, selectionController) {
if (selectionController.isCollapsed) { if (selectionController.isCollapsed) {
const hasOnlyOneParagraph = fragment.children.length === 1; const hasOnlyOneParagraph = fragment.children.length === 1;
const hasOnlyOneTextSpan = fragment.firstElementChild.children.length === 1; const hasOnlyOneTextSpan = fragment.firstElementChild.children.length === 1;
const forceTextSpan = fragment.firstElementChild.dataset.textSpan === "force"; const forceTextSpan =
if (hasOnlyOneParagraph fragment.firstElementChild.dataset.textSpan === "force";
&& hasOnlyOneTextSpan if (hasOnlyOneParagraph && hasOnlyOneTextSpan && forceTextSpan) {
&& forceTextSpan) {
selectionController.insertIntoFocus(fragment.textContent); selectionController.insertIntoFocus(fragment.textContent);
} else { } else {
selectionController.insertPaste(fragment); selectionController.insertPaste(fragment);
@ -103,10 +110,9 @@ export function paste(event, editor, selectionController) {
} else { } else {
const hasOnlyOneParagraph = fragment.children.length === 1; const hasOnlyOneParagraph = fragment.children.length === 1;
const hasOnlyOneTextSpan = fragment.firstElementChild.children.length === 1; const hasOnlyOneTextSpan = fragment.firstElementChild.children.length === 1;
const forceTextSpan = fragment.firstElementChild.dataset.textSpan === "force"; const forceTextSpan =
if (hasOnlyOneParagraph fragment.firstElementChild.dataset.textSpan === "force";
&& hasOnlyOneTextSpan if (hasOnlyOneParagraph && hasOnlyOneTextSpan && forceTextSpan) {
&& forceTextSpan) {
selectionController.replaceText(fragment.textContent); selectionController.replaceText(fragment.textContent);
} else { } else {
selectionController.replaceWithPaste(fragment); selectionController.replaceWithPaste(fragment);

View File

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

View File

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

View File

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

View File

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

View File

@ -230,15 +230,10 @@ export function mapContentFragmentFromString(string, styleDefaults) {
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
for (const line of lines) { for (const line of lines) {
if (line === "") { if (line === "") {
fragment.appendChild( fragment.appendChild(createEmptyParagraph(styleDefaults));
createEmptyParagraph(styleDefaults)
);
} else { } else {
const textSpan = createTextSpan(new Text(line), styleDefaults); const textSpan = createTextSpan(new Text(line), styleDefaults);
const paragraph = createParagraph( const paragraph = createParagraph([textSpan], styleDefaults);
[textSpan],
styleDefaults,
);
if (lines.length === 1) { if (lines.length === 1) {
paragraph.dataset.textSpan = "force"; paragraph.dataset.textSpan = "force";
} }

View File

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

View File

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

View File

@ -6,7 +6,7 @@
* Copyright (c) KALEIDOS INC * Copyright (c) KALEIDOS INC
*/ */
import StyleDeclaration from '../../controllers/StyleDeclaration.js'; import StyleDeclaration from "../../controllers/StyleDeclaration.js";
import { getFills } from "./Color.js"; import { getFills } from "./Color.js";
const DEFAULT_FONT_SIZE = "16px"; const DEFAULT_FONT_SIZE = "16px";
@ -339,8 +339,7 @@ export function setStylesFromObject(element, allowedStyles, styleObject) {
continue; continue;
} }
let styleValue = styleObject[styleName]; let styleValue = styleObject[styleName];
if (!styleValue) if (!styleValue) continue;
continue;
if (styleName === "font-family") { if (styleName === "font-family") {
styleValue = sanitizeFontFamily(styleValue); styleValue = sanitizeFontFamily(styleValue);
@ -388,8 +387,10 @@ export function setStylesFromDeclaration(
* @returns {HTMLElement} * @returns {HTMLElement}
*/ */
export function setStyles(element, allowedStyles, styleObjectOrDeclaration) { export function setStyles(element, allowedStyles, styleObjectOrDeclaration) {
if (styleObjectOrDeclaration instanceof CSSStyleDeclaration if (
|| styleObjectOrDeclaration instanceof StyleDeclaration) { styleObjectOrDeclaration instanceof CSSStyleDeclaration ||
styleObjectOrDeclaration instanceof StyleDeclaration
) {
return setStylesFromDeclaration( return setStylesFromDeclaration(
element, element,
allowedStyles, allowedStyles,

View File

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

View File

@ -6,7 +6,7 @@
* Copyright (c) KALEIDOS INC * Copyright (c) KALEIDOS INC
*/ */
import SafeGuard from '../../controllers/SafeGuard.js'; import SafeGuard from "../../controllers/SafeGuard.js";
/** /**
* Iterator direction. * Iterator direction.
@ -58,7 +58,7 @@ export class TextNodeIterator {
startNode, startNode,
rootNode, rootNode,
skipNodes = new Set(), skipNodes = new Set(),
direction = TextNodeIteratorDirection.FORWARD direction = TextNodeIteratorDirection.FORWARD,
) { ) {
if (startNode === rootNode) { if (startNode === rootNode) {
return TextNodeIterator.findDown( return TextNodeIterator.findDown(
@ -67,7 +67,7 @@ export class TextNodeIterator {
: startNode.lastChild, : startNode.lastChild,
rootNode, rootNode,
skipNodes, skipNodes,
direction direction,
); );
} }
@ -95,7 +95,7 @@ export class TextNodeIterator {
: currentNode.lastChild, : currentNode.lastChild,
rootNode, rootNode,
skipNodes, skipNodes,
direction direction,
); );
} }
currentNode = currentNode =
@ -119,7 +119,7 @@ export class TextNodeIterator {
startNode, startNode,
rootNode, rootNode,
backTrack = new Set(), backTrack = new Set(),
direction = TextNodeIteratorDirection.FORWARD direction = TextNodeIteratorDirection.FORWARD,
) { ) {
backTrack.add(startNode); backTrack.add(startNode);
if (TextNodeIterator.isTextNode(startNode)) { if (TextNodeIterator.isTextNode(startNode)) {
@ -127,14 +127,14 @@ export class TextNodeIterator {
startNode.parentNode, startNode.parentNode,
rootNode, rootNode,
backTrack, backTrack,
direction direction,
); );
} else if (TextNodeIterator.isContainerNode(startNode)) { } else if (TextNodeIterator.isContainerNode(startNode)) {
const found = TextNodeIterator.findDown( const found = TextNodeIterator.findDown(
startNode, startNode,
rootNode, rootNode,
backTrack, backTrack,
direction direction,
); );
if (found) { if (found) {
return found; return found;
@ -144,7 +144,7 @@ export class TextNodeIterator {
startNode.parentNode, startNode.parentNode,
rootNode, rootNode,
backTrack, backTrack,
direction direction,
); );
} }
} }
@ -214,7 +214,7 @@ export class TextNodeIterator {
this.#currentNode, this.#currentNode,
this.#rootNode, this.#rootNode,
new Set(), new Set(),
TextNodeIteratorDirection.FORWARD TextNodeIteratorDirection.FORWARD,
); );
if (!nextNode) { if (!nextNode) {
@ -237,7 +237,7 @@ export class TextNodeIterator {
this.#currentNode, this.#currentNode,
this.#rootNode, this.#rootNode,
new Set(), new Set(),
TextNodeIteratorDirection.BACKWARD TextNodeIteratorDirection.BACKWARD,
); );
if (!previousNode) { if (!previousNode) {
@ -270,10 +270,8 @@ export class TextNodeIterator {
* @param {TextNode} endNode * @param {TextNode} endNode
* @yields {TextNode} * @yields {TextNode}
*/ */
* iterateFrom(startNode, endNode) { *iterateFrom(startNode, endNode) {
const comparedPosition = startNode.compareDocumentPosition( const comparedPosition = startNode.compareDocumentPosition(endNode);
endNode
);
this.#currentNode = startNode; this.#currentNode = startNode;
SafeGuard.start(); SafeGuard.start();
while (this.#currentNode !== endNode) { while (this.#currentNode !== endNode) {

View File

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

View File

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

View File

@ -54,7 +54,7 @@ import { isRoot, setRootStyles } from "../content/dom/Root.js";
import { SelectionDirection } from "./SelectionDirection.js"; import { SelectionDirection } from "./SelectionDirection.js";
import SafeGuard from "./SafeGuard.js"; import SafeGuard from "./SafeGuard.js";
import { sanitizeFontFamily } from "../content/dom/Style.js"; import { sanitizeFontFamily } from "../content/dom/Style.js";
import StyleDeclaration from './StyleDeclaration.js'; import StyleDeclaration from "./StyleDeclaration.js";
/** /**
* Supported options for the SelectionController. * 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 // FIXME: I don't like this approximation. Having to iterate nodes twice
// is bad for performance. I think we need another way of "computing" // is bad for performance. I think we need another way of "computing"
// the cascade. // 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; const paragraph = textNode.parentElement.parentElement;
this.#applyStylesFromElementToCurrentStyle(paragraph); 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; const textSpan = textNode.parentElement;
this.#mergeStylesFromElementToCurrentStyle(textSpan); this.#mergeStylesFromElementToCurrentStyle(textSpan);
} }
@ -498,19 +504,12 @@ export class SelectionController extends EventTarget {
if (!this.#savedSelection) return false; if (!this.#savedSelection) return false;
if (this.#savedSelection.anchorNode && this.#savedSelection.focusNode) { if (this.#savedSelection.anchorNode && this.#savedSelection.focusNode) {
if (this.#savedSelection.anchorNode === this.#savedSelection.focusNode) { this.#selection.setBaseAndExtent(
this.#selection.setPosition( this.#savedSelection.anchorNode,
this.#savedSelection.focusNode, this.#savedSelection.anchorOffset,
this.#savedSelection.focusOffset, this.#savedSelection.focusNode,
); this.#savedSelection.focusOffset,
} else { );
this.#selection.setBaseAndExtent(
this.#savedSelection.anchorNode,
this.#savedSelection.anchorOffset,
this.#savedSelection.focusNode,
this.#savedSelection.focusOffset,
);
}
} }
this.#savedSelection = null; this.#savedSelection = null;
return true; return true;
@ -1132,10 +1131,7 @@ export class SelectionController extends EventTarget {
const hasOnlyOneParagraph = fragment.children.length === 1; const hasOnlyOneParagraph = fragment.children.length === 1;
const forceTextSpan = const forceTextSpan =
fragment.firstElementChild?.dataset?.textSpan === "force"; fragment.firstElementChild?.dataset?.textSpan === "force";
if ( if (hasOnlyOneParagraph && forceTextSpan) {
hasOnlyOneParagraph &&
forceTextSpan
) {
// first text span // first text span
const collapseNode = fragment.firstElementChild.firstElementChild; const collapseNode = fragment.firstElementChild.firstElementChild;
if (this.isTextSpanStart) { if (this.isTextSpanStart) {
@ -1403,7 +1399,7 @@ export class SelectionController extends EventTarget {
// the focus node is a <span>. // the focus node is a <span>.
if (isTextSpan(this.focusNode)) { if (isTextSpan(this.focusNode)) {
this.focusNode.firstElementChild.replaceWith(textNode); this.focusNode.firstElementChild.replaceWith(textNode);
// the focus node is a <br>. // the focus node is a <br>.
} else { } else {
this.focusNode.replaceWith(textNode); this.focusNode.replaceWith(textNode);
} }
@ -1981,8 +1977,7 @@ export class SelectionController extends EventTarget {
this.setSelection(newTextSpan.firstChild, 0, newTextSpan.firstChild, 0); this.setSelection(newTextSpan.firstChild, 0, newTextSpan.firstChild, 0);
} }
// The styles are applied to the paragraph // The styles are applied to the paragraph
else else {
{
const paragraph = this.startParagraph; const paragraph = this.startParagraph;
setParagraphStyles(paragraph, newStyles); setParagraphStyles(paragraph, newStyles);
// Apply styles to child text spans. // Apply styles to child text spans.

View File

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

View File

@ -77,7 +77,10 @@ export class StyleDeclaration {
const currentValue = this.getPropertyValue(name); const currentValue = this.getPropertyValue(name);
if (this.#isQuotedValue(currentValue, value)) { if (this.#isQuotedValue(currentValue, value)) {
return this.setProperty(name, 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); return this.setProperty(name, value);
} else if (currentValue === "" && ["initial", "none"].includes(value)) { } else if (currentValue === "" && ["initial", "none"].includes(value)) {
return this.setProperty(name, value); return this.setProperty(name, value);
@ -107,4 +110,4 @@ export class StyleDeclaration {
} }
} }
export default StyleDeclaration export default StyleDeclaration;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -230,20 +230,62 @@ pub extern "C" fn resize_viewbox(width: i32, height: i32) {
#[no_mangle] #[no_mangle]
pub extern "C" fn set_view(zoom: f32, x: f32, y: f32) { pub extern "C" fn set_view(zoom: f32, x: f32, y: f32) {
with_state_mut!(state, { with_state_mut!(state, {
performance::begin_measure!("set_view");
let render_state = state.render_state_mut(); let render_state = state.render_state_mut();
render_state.set_view(zoom, x, y); render_state.set_view(zoom, x, y);
performance::end_measure!("set_view");
});
}
#[cfg(feature = "profile-macros")]
static mut VIEW_INTERACTION_START: i32 = 0;
#[no_mangle]
pub extern "C" fn set_view_start() {
with_state_mut!(state, {
#[cfg(feature = "profile-macros")]
unsafe {
VIEW_INTERACTION_START = performance::get_time();
}
performance::begin_measure!("set_view_start");
state.render_state.options.set_fast_mode(true);
performance::end_measure!("set_view_start");
}); });
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn set_view_end() { pub extern "C" fn set_view_end() {
with_state_mut!(state, { with_state_mut!(state, {
// We can have renders in progress let _end_start = performance::begin_timed_log!("set_view_end");
performance::begin_measure!("set_view_end");
state.render_state.options.set_fast_mode(false);
state.render_state.cancel_animation_frame(); state.render_state.cancel_animation_frame();
if state.render_state.options.is_profile_rebuild_tiles() {
state.rebuild_tiles(); let zoom_changed = state.render_state.zoom_changed();
} else { // Only rebuild tile indices when zoom has changed.
state.rebuild_tiles_shallow(); // 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<Uuid> = bytes let entries: Vec<Uuid> = bytes
.chunks(size_of::<<Uuid as SerializableResult>::BytesType>()) .chunks(size_of::<<Uuid as SerializableResult>::BytesType>())
.map(|data| Uuid::from_bytes(data.try_into().unwrap())) .map(|data| Uuid::try_from(data).unwrap())
.collect(); .collect();
with_state_mut!(state, { with_state_mut!(state, {
@ -481,7 +523,7 @@ pub extern "C" fn set_children() {
let entries: Vec<Uuid> = bytes let entries: Vec<Uuid> = bytes
.chunks(size_of::<<Uuid as SerializableResult>::BytesType>()) .chunks(size_of::<<Uuid as SerializableResult>::BytesType>())
.map(|data| Uuid::from_bytes(data.try_into().unwrap())) .map(|data| Uuid::try_from(data).unwrap())
.collect(); .collect();
set_children_set(entries); set_children_set(entries);
@ -637,7 +679,7 @@ pub extern "C" fn propagate_modifiers(pixel_precision: bool) -> *mut u8 {
let entries: Vec<_> = bytes let entries: Vec<_> = bytes
.chunks(size_of::<<TransformEntry as SerializableResult>::BytesType>()) .chunks(size_of::<<TransformEntry as SerializableResult>::BytesType>())
.map(|data| TransformEntry::from_bytes(data.try_into().unwrap())) .map(|data| TransformEntry::try_from(data).unwrap())
.collect(); .collect();
with_state!(state, { with_state!(state, {
@ -652,7 +694,7 @@ pub extern "C" fn set_modifiers() {
let entries: Vec<_> = bytes let entries: Vec<_> = bytes
.chunks(size_of::<<TransformEntry as SerializableResult>::BytesType>()) .chunks(size_of::<<TransformEntry as SerializableResult>::BytesType>())
.map(|data| TransformEntry::from_bytes(data.try_into().unwrap())) .map(|data| TransformEntry::try_from(data).unwrap())
.collect(); .collect();
let mut modifiers = HashMap::new(); let mut modifiers = HashMap::new();

View File

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

View File

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

View File

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

View File

@ -9,7 +9,8 @@ mod options;
mod shadows; mod shadows;
mod strokes; mod strokes;
mod surfaces; mod surfaces;
mod text; pub mod text;
mod ui; mod ui;
use skia_safe::{self as skia, Matrix, RRect, Rect}; use skia_safe::{self as skia, Matrix, RRect, Rect};
@ -928,6 +929,8 @@ impl RenderState {
} }
pub fn render_from_cache(&mut self, shapes: ShapesPoolRef) { pub fn render_from_cache(&mut self, shapes: ShapesPoolRef) {
let _start = performance::begin_timed_log!("render_from_cache");
performance::begin_measure!("render_from_cache");
let scale = self.get_cached_scale(); let scale = self.get_cached_scale();
if let Some(snapshot) = &self.cached_target_snapshot { if let Some(snapshot) = &self.cached_target_snapshot {
let canvas = self.surfaces.canvas(SurfaceId::Target); let canvas = self.surfaces.canvas(SurfaceId::Target);
@ -965,6 +968,8 @@ impl RenderState {
self.flush_and_submit(); self.flush_and_submit();
} }
performance::end_measure!("render_from_cache");
performance::end_timed_log!("render_from_cache", _start);
} }
pub fn start_render_loop( pub fn start_render_loop(
@ -974,6 +979,7 @@ impl RenderState {
timestamp: i32, timestamp: i32,
sync_render: bool, sync_render: bool,
) -> Result<(), String> { ) -> Result<(), String> {
let _start = performance::begin_timed_log!("start_render_loop");
let scale = self.get_scale(); let scale = self.get_scale();
self.tile_viewbox.update(self.viewbox, scale); self.tile_viewbox.update(self.viewbox, scale);
@ -1004,10 +1010,12 @@ impl RenderState {
// FIXME - review debug // FIXME - review debug
// debug::render_debug_tiles_for_viewbox(self); // debug::render_debug_tiles_for_viewbox(self);
let _tile_start = performance::begin_timed_log!("tile_cache_update");
performance::begin_measure!("tile_cache"); performance::begin_measure!("tile_cache");
self.pending_tiles self.pending_tiles
.update(&self.tile_viewbox, &self.surfaces); .update(&self.tile_viewbox, &self.surfaces);
performance::end_measure!("tile_cache"); performance::end_measure!("tile_cache");
performance::end_timed_log!("tile_cache_update", _tile_start);
self.pending_nodes.clear(); self.pending_nodes.clear();
if self.pending_nodes.capacity() < tree.len() { if self.pending_nodes.capacity() < tree.len() {
@ -1031,6 +1039,7 @@ impl RenderState {
} }
performance::end_measure!("start_render_loop"); performance::end_measure!("start_render_loop");
performance::end_timed_log!("start_render_loop", _start);
Ok(()) Ok(())
} }
@ -1479,8 +1488,11 @@ impl RenderState {
.surfaces .surfaces
.get_render_context_translation(self.render_area, scale); .get_render_context_translation(self.render_area, scale);
// Skip expensive drop shadow rendering in fast mode (during pan/zoom)
let skip_shadows = self.options.is_fast_mode();
// For text shapes, render drop shadow using text rendering logic // For text shapes, render drop shadow using text rendering logic
if !matches!(element.shape_type, Type::Text(_)) { if !skip_shadows && !matches!(element.shape_type, Type::Text(_)) {
// Shadow rendering technique: Two-pass approach for proper opacity handling // Shadow rendering technique: Two-pass approach for proper opacity handling
// //
// The shadow rendering uses a two-pass technique to ensure that overlapping // The shadow rendering uses a two-pass technique to ensure that overlapping
@ -2054,6 +2066,10 @@ impl RenderState {
self.cached_viewbox.zoom() * self.options.dpr() self.cached_viewbox.zoom() * self.options.dpr()
} }
pub fn zoom_changed(&self) -> bool {
(self.viewbox.zoom - self.cached_viewbox.zoom).abs() > f32::EPSILON
}
pub fn mark_touched(&mut self, uuid: Uuid) { pub fn mark_touched(&mut self, uuid: Uuid) {
self.touched_ids.insert(uuid); self.touched_ids.insert(uuid);
} }

View File

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

View File

@ -2,18 +2,15 @@ use super::{filters, RenderState, Shape, SurfaceId};
use crate::{ use crate::{
math::Rect, math::Rect,
shapes::{ shapes::{
merge_fills, set_paint_fill, ParagraphBuilderGroup, Stroke, StrokeKind, TextContent, calculate_position_data, calculate_text_layout_data, merge_fills, set_paint_fill,
VerticalAlign, ParagraphBuilderGroup, Stroke, StrokeKind, TextContent,
}, },
utils::{get_fallback_fonts, get_font_collection}, utils::{get_fallback_fonts, get_font_collection},
}; };
use skia_safe::{ use skia_safe::{
self as skia, self as skia,
canvas::SaveLayerRec, canvas::SaveLayerRec,
textlayout::{ textlayout::{ParagraphBuilder, StyleMetrics, TextDecoration, TextStyle},
LineMetrics, Paragraph, ParagraphBuilder, RectHeightStyle, RectWidthStyle, StyleMetrics,
TextDecoration, TextStyle,
},
Canvas, ImageFilter, Paint, Path, Canvas, ImageFilter, Paint, Path,
}; };
@ -241,48 +238,24 @@ fn draw_text(
paragraph_builder_groups: &mut [Vec<ParagraphBuilder>], paragraph_builder_groups: &mut [Vec<ParagraphBuilder>],
) { ) {
let text_content = shape.get_text_content(); let text_content = shape.get_text_content();
let selrect_width = shape.selrect().width(); let layout_info =
let text_width = text_content.get_width(selrect_width); calculate_text_layout_data(shape, text_content, paragraph_builder_groups, true);
let text_height = text_content.get_height(selrect_width);
let selrect_height = shape.selrect().height();
let mut global_offset_y = match shape.vertical_align() {
VerticalAlign::Center => (selrect_height - text_height) / 2.0,
VerticalAlign::Bottom => selrect_height - text_height,
_ => 0.0,
};
let layer_rec = SaveLayerRec::default(); let layer_rec = SaveLayerRec::default();
canvas.save_layer(&layer_rec); canvas.save_layer(&layer_rec);
let mut previous_line_height = text_content.normalized_line_height();
for paragraph_builder_group in paragraph_builder_groups { for para in &layout_info.paragraphs {
let group_offset_y = global_offset_y; para.paragraph.paint(canvas, (para.x, para.y));
let group_len = paragraph_builder_group.len(); for deco in &para.decorations {
let mut paragraph_offset_y = previous_line_height; draw_text_decorations(
canvas,
for (paragraph_index, paragraph_builder) in paragraph_builder_group.iter_mut().enumerate() { &deco.text_style,
let mut paragraph = paragraph_builder.build(); Some(deco.y),
paragraph.layout(text_width); deco.thickness,
let xy = (shape.selrect().x(), shape.selrect().y() + group_offset_y); deco.left,
paragraph.paint(canvas, xy); deco.width,
);
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, &paragraph, paragraph_builder, line_metrics, xy);
}
} }
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)>, style_metrics: &Vec<(usize, &StyleMetrics)>,
line_baseline: f32, line_baseline: f32,
) -> (f32, Option<f32>, f32, Option<f32>) { ) -> (f32, Option<f32>, f32, Option<f32>) {
@ -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)] #[allow(dead_code)]
fn calculate_total_paragraphs_height(paragraphs: &mut [ParagraphBuilder], width: f32) -> f32 { fn calculate_total_paragraphs_height(paragraphs: &mut [ParagraphBuilder], width: f32) -> f32 {
paragraphs paragraphs
@ -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? // How to use it?
// Type::Text(text_content) => { // Type::Text(text_content) => {
// self.surfaces // self.surfaces

View File

@ -384,7 +384,7 @@ pub fn propagate_modifiers(
if math::identitish(&entry.transform) { if math::identitish(&entry.transform) {
Modifier::Reflow(entry.id) Modifier::Reflow(entry.id)
} else { } else {
Modifier::Transform(entry.clone()) Modifier::Transform(*entry)
} }
}) })
.collect(); .collect();

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
use crate::mem; use crate::mem;
use crate::mem::SerializableResult; // use crate::mem::SerializableResult;
use crate::uuid::Uuid; use crate::uuid::Uuid;
use crate::with_state_mut; use crate::with_state_mut;
use crate::STATE; use crate::STATE;
@ -48,8 +48,8 @@ pub struct ShapeImageIds {
impl From<[u8; IMAGE_IDS_SIZE]> for ShapeImageIds { impl From<[u8; IMAGE_IDS_SIZE]> for ShapeImageIds {
fn from(bytes: [u8; IMAGE_IDS_SIZE]) -> Self { fn from(bytes: [u8; IMAGE_IDS_SIZE]) -> Self {
let shape_id = Uuid::from_bytes(bytes[0..16].try_into().unwrap()); let shape_id = Uuid::try_from(&bytes[0..16]).unwrap();
let image_id = Uuid::from_bytes(bytes[16..32].try_into().unwrap()); let image_id = Uuid::try_from(&bytes[16..32]).unwrap();
ShapeImageIds { shape_id, image_id } 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 /// Stores an image from an existing WebGL texture, avoiding re-decoding
/// Expected memory layout: /// Expected memory layout:
/// - bytes 0-15: shape UUID /// - bytes 0-15: shape UUID
/// - bytes 16-31: image UUID /// - bytes 16-31: image UUID
/// - bytes 32-35: is_thumbnail flag (u32) /// - bytes 32-35: is_thumbnail flag (u32)
/// - bytes 36-39: GL texture ID (u32) /// - bytes 36-39: GL texture ID (u32)
/// - bytes 40-43: width (i32) /// - bytes 40-43: width (i32)

View File

@ -40,7 +40,7 @@ pub extern "C" fn clear_shape_layout() {
} }
#[no_mangle] #[no_mangle]
pub extern "C" fn set_layout_child_data( pub extern "C" fn set_layout_data(
margin_top: f32, margin_top: f32,
margin_right: f32, margin_right: f32,
margin_bottom: f32, margin_bottom: f32,

View File

@ -51,25 +51,20 @@ impl TryFrom<&[u8]> for RawSegmentData {
} }
} }
impl From<RawSegmentData> for [u8; RAW_SEGMENT_DATA_SIZE] {
fn from(value: RawSegmentData) -> Self {
unsafe { std::mem::transmute(value) }
}
}
impl SerializableResult for RawSegmentData { impl SerializableResult for RawSegmentData {
type BytesType = [u8; RAW_SEGMENT_DATA_SIZE]; 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 // The generic trait doesn't know the size of the array. This is why the
// clone needs to be here even if it could be generic. // clone needs to be here even if it could be generic.
fn clone_to_slice(&self, slice: &mut [u8]) { fn clone_to_slice(&self, slice: &mut [u8]) {
slice.clone_from_slice(&self.as_bytes()); let bytes = Self::BytesType::from(*self);
slice.clone_from_slice(&bytes);
} }
} }

View File

@ -48,7 +48,7 @@ pub extern "C" fn calculate_bool(raw_bool_type: u8) -> *mut u8 {
let entries: Vec<Uuid> = bytes let entries: Vec<Uuid> = bytes
.chunks(size_of::<<Uuid as SerializableResult>::BytesType>()) .chunks(size_of::<<Uuid as SerializableResult>::BytesType>())
.map(|data| Uuid::from_bytes(data.try_into().unwrap())) .map(|data| Uuid::try_from(data).unwrap())
.collect(); .collect();
mem::free_bytes(); mem::free_bytes();

View File

@ -2,13 +2,14 @@ use macros::ToJs;
use super::{fills::RawFillData, fonts::RawFontStyle}; use super::{fills::RawFillData, fonts::RawFontStyle};
use crate::math::{Matrix, Point}; use crate::math::{Matrix, Point};
use crate::mem; use crate::mem::{self, SerializableResult};
use crate::shapes::{ use crate::shapes::{
self, GrowType, Shape, TextAlign, TextDecoration, TextDirection, TextTransform, Type, self, GrowType, Shape, TextAlign, TextDecoration, TextDirection, TextTransform, Type,
}; };
use crate::utils::{uuid_from_u32, uuid_from_u32_quartet}; use crate::utils::{uuid_from_u32, uuid_from_u32_quartet};
use crate::{ 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::<RawTextSpan>(); const RAW_SPAN_DATA_SIZE: usize = std::mem::size_of::<RawTextSpan>();
@ -411,3 +412,39 @@ pub extern "C" fn get_caret_position_at(x: f32, y: f32) -> i32 {
}); });
-1 -1
} }
const RAW_POSITION_DATA_SIZE: usize = size_of::<shapes::PositionData>();
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<shapes::PositionData> 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::<shapes::PositionData>::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)
}