mirror of https://github.com/penpot/penpot.git
Merge remote-tracking branch 'origin/staging-render' into develop
This commit is contained in:
commit
33c786498d
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"}])))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
|
|
|
||||||
|
|
@ -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))))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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'))))
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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})]
|
||||||
|
|
|
||||||
|
|
@ -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))))
|
||||||
|
|
||||||
|
|
@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) &&
|
||||||
|
|
|
||||||
|
|
@ -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!",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ function getContext() {
|
||||||
if (!context) {
|
if (!context) {
|
||||||
context = canvas.getContext("2d");
|
context = canvas.getContext("2d");
|
||||||
}
|
}
|
||||||
return context
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 === "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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)", () => {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 ¶.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, ¶graph, 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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue