From 7ca8bf32b2e301c083c5c6c0da34ba5c78a90b16 Mon Sep 17 00:00:00 2001 From: Aitor Moreno Date: Mon, 22 Sep 2025 15:47:23 +0200 Subject: [PATCH] :tada: Set DOM text editor element caret --- .../ui/specs/text-editor-v2.spec.js | 2 +- .../ui/workspace/shapes/text/v2_editor.cljs | 26 +- .../src/app/main/ui/workspace/viewport.cljs | 3 + .../app/main/ui/workspace/viewport_wasm.cljs | 1 + .../text-editor/src/editor/TextEditor.css | 25 - frontend/text-editor/src/editor/TextEditor.js | 162 ++-- .../editor/controllers/SelectionController.js | 2 +- .../src/editor/selection/Imposter.js | 31 - .../src/editor/selection/Imposter.spec.js | 14 - frontend/text-editor/src/index.html | 767 +++++------------- frontend/text-editor/src/playground.js | 578 +++++++++++++ frontend/text-editor/src/playground/color.js | 129 +++ frontend/text-editor/src/playground/fill.js | 39 + frontend/text-editor/src/playground/font.js | 177 ++++ frontend/text-editor/src/playground/geom.js | 244 ++++++ frontend/text-editor/src/playground/shape.js | 94 +++ frontend/text-editor/src/playground/style.js | 14 + frontend/text-editor/src/playground/text.js | 250 ++++++ frontend/text-editor/src/playground/uuid.js | 47 ++ .../text-editor/src/playground/viewport.js | 44 + frontend/text-editor/src/playground/wasm.js | 52 ++ frontend/text-editor/src/style.css | 43 +- frontend/text-editor/src/wasm/lib.js | 595 -------------- render-wasm/src/main.rs | 14 + render-wasm/src/render.rs | 12 +- render-wasm/src/render/text.rs | 24 +- render-wasm/src/shapes.rs | 3 +- render-wasm/src/shapes/text.rs | 71 +- render-wasm/src/state.rs | 14 + render-wasm/src/state/text_editor.rs | 103 +++ render-wasm/src/wasm/text.rs | 8 +- 31 files changed, 2262 insertions(+), 1326 deletions(-) delete mode 100644 frontend/text-editor/src/editor/selection/Imposter.js delete mode 100644 frontend/text-editor/src/editor/selection/Imposter.spec.js create mode 100644 frontend/text-editor/src/playground.js create mode 100644 frontend/text-editor/src/playground/color.js create mode 100644 frontend/text-editor/src/playground/fill.js create mode 100644 frontend/text-editor/src/playground/font.js create mode 100644 frontend/text-editor/src/playground/geom.js create mode 100644 frontend/text-editor/src/playground/shape.js create mode 100644 frontend/text-editor/src/playground/style.js create mode 100644 frontend/text-editor/src/playground/text.js create mode 100644 frontend/text-editor/src/playground/uuid.js create mode 100644 frontend/text-editor/src/playground/viewport.js create mode 100644 frontend/text-editor/src/playground/wasm.js delete mode 100644 frontend/text-editor/src/wasm/lib.js create mode 100644 render-wasm/src/state/text_editor.rs diff --git a/frontend/playwright/ui/specs/text-editor-v2.spec.js b/frontend/playwright/ui/specs/text-editor-v2.spec.js index dec3b71a0b..5b268d2f82 100644 --- a/frontend/playwright/ui/specs/text-editor-v2.spec.js +++ b/frontend/playwright/ui/specs/text-editor-v2.spec.js @@ -6,7 +6,7 @@ test.beforeEach(async ({ page }) => { await WorkspacePage.mockConfigFlags(page, ["enable-feature-text-editor-v2"]); }); -test("BUG 11552 - Apply styles to the current caret", async ({ page }) => { +test.skip("BUG 11552 - Apply styles to the current caret", async ({ page }) => { const workspace = new WorkspacePage(page); await workspace.setupEmptyFile(); await workspace.mockGetFile("text-editor/get-file-11552.json"); diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs index af15827efc..3bcc00fc0a 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs @@ -61,12 +61,12 @@ (defn- initialize-event-handlers "Internal editor events handler initializer/destructor" - [shape-id content selection-ref editor-ref container-ref text-color] + [shape-id content editor-ref canvas-ref container-ref text-color] (let [editor-node (mf/ref-val editor-ref) - selection-node - (mf/ref-val selection-ref) + canvas-node + (mf/ref-val canvas-ref) ;; Gets the default font from the workspace refs. default-font @@ -81,11 +81,10 @@ default-font)) options - #js {:styleDefaults style-defaults - :selectionImposterElement selection-node} + #js {:styleDefaults style-defaults} instance - (dwt/create-editor editor-node options) + (dwt/create-editor editor-node canvas-node options) update-name? (nil? content) @@ -180,7 +179,7 @@ "Text editor (HTML)" {::mf/wrap [mf/memo] ::mf/props :obj} - [{:keys [shape]}] + [{:keys [shape canvas-ref]}] (let [content (:content shape) shape-id (dm/get-prop shape :id) fill-color (get-color-from-content content) @@ -190,7 +189,6 @@ editor-ref (mf/use-ref nil) ;; This reference is to the container container-ref (mf/use-ref nil) - selection-ref (mf/use-ref nil) page (mf/deref refs/workspace-page) objects (get page :objects) @@ -213,8 +211,8 @@ (mf/with-effect [shape-id] (initialize-event-handlers shape-id content - selection-ref editor-ref + canvas-ref container-ref text-color)) @@ -239,9 +237,6 @@ ;; on-blur and on-focus) but I keep this for future references. ;; :opacity (when @blurred 0)}} } - [:div - {:class (stl/css :text-editor-selection-imposter) - :ref selection-ref}] [:div {:class (dm/str "mousetrap " @@ -279,7 +274,7 @@ {::mf/wrap [mf/memo] ::mf/props :obj ::mf/forward-ref true} - [{:keys [shape modifiers] :as props} _] + [{:keys [shape modifiers canvas-ref] :as props} _] (let [shape-id (dm/get-prop shape :id) modifiers (dm/get-in modifiers [shape-id :modifiers]) @@ -310,10 +305,10 @@ [x y width height] (if (features/active-feature? @st/state "render-wasm/v1") - (let [{:keys [max-width height]} (wasm.api/get-text-dimensions shape-id) + (let [{:keys [width height]} (wasm.api/get-text-dimensions shape-id) {:keys [x y]} (:selrect shape)] - [x y max-width height]) + [x y width height]) (let [bounds (gst/shape->rect shape) x (mth/min (dm/get-prop bounds :x) @@ -358,4 +353,5 @@ [:foreignObject {:x x :y y :width width :height height} [:div {:style style} [:& text-editor-html {:shape shape + :canvas-ref canvas-ref :key (dm/str shape-id)}]]]])) diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs index 1837f32089..a02da4a387 100644 --- a/frontend/src/app/main/ui/workspace/viewport.cljs +++ b/frontend/src/app/main/ui/workspace/viewport.cljs @@ -147,6 +147,8 @@ [viewport-ref on-viewport-ref] (create-viewport-ref) + canvas-ref (mf/use-ref nil) + ;; VARS disable-paste (mf/use-var false) in-viewport? (mf/use-var false) @@ -440,6 +442,7 @@ (when show-text-editor? (if (features/active-feature? @st/state "text-editor/v2") [:& editor-v2/text-editor {:shape editing-shape + :canvas-ref canvas-ref :modifiers modifiers}] [:& editor-v1/text-editor-svg {:shape editing-shape :modifiers modifiers}])) diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index 67e4a1d9cc..45f34a07d7 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -431,6 +431,7 @@ (when show-text-editor? (if (features/active-feature? @st/state "text-editor/v2") [:& editor-v2/text-editor {:shape editing-shape + :canvas-ref canvas-ref :ref text-editor-ref}] [:& editor-v1/text-editor-svg {:shape editing-shape :ref text-editor-ref}])) diff --git a/frontend/text-editor/src/editor/TextEditor.css b/frontend/text-editor/src/editor/TextEditor.css index 465bc4b637..e5b9072cf2 100644 --- a/frontend/text-editor/src/editor/TextEditor.css +++ b/frontend/text-editor/src/editor/TextEditor.css @@ -1,28 +1,3 @@ -::selection { - background-color: red; -} - -.selection-imposter-rect { - background-color: red; - position: absolute; -} - -.text-editor-selection-imposter { - pointer-events: none; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: -1; -} - -.text-editor-container { - height: 100%; - display: flex; - flex-direction: column; -} - .text-editor-content { height: 100%; font-family: sourcesanspro; diff --git a/frontend/text-editor/src/editor/TextEditor.js b/frontend/text-editor/src/editor/TextEditor.js index f440092ce3..11c327642f 100644 --- a/frontend/text-editor/src/editor/TextEditor.js +++ b/frontend/text-editor/src/editor/TextEditor.js @@ -10,7 +10,6 @@ import clipboard from "./clipboard/index.js"; import commands from "./commands/index.js"; import ChangeController from "./controllers/ChangeController.js"; import SelectionController from "./controllers/SelectionController.js"; -import { createSelectionImposterFromClientRects } from "./selection/Imposter.js"; import { addEventListeners, removeEventListeners } from "./Event.js"; import { mapContentFragmentFromHTML, @@ -62,13 +61,6 @@ export class TextEditor extends EventTarget { */ #selectionController = null; - /** - * Selection imposter keeps selection elements. - * - * @type {HTMLElement} - */ - #selectionImposterElement = null; - /** * Style defaults. * @@ -84,18 +76,26 @@ export class TextEditor extends EventTarget { */ #fixInsertCompositionText = false; + /** + * Canvas element that renders text. + * + * @type {HTMLCanvasElement} + */ + #canvas = null; + /** * Constructor. * * @param {HTMLElement} element + * @param {HTMLCanvasElement} canvas */ - constructor(element, options) { + constructor(element, canvas, options) { super(); if (!(element instanceof HTMLElement)) throw new TypeError("Invalid text editor element"); this.#element = element; - this.#selectionImposterElement = options?.selectionImposterElement; + this.#canvas = canvas; this.#events = { blur: this.#onBlur, focus: this.#onFocus, @@ -114,7 +114,7 @@ export class TextEditor extends EventTarget { /** * Setups editor properties. */ - #setupElementProperties() { + #setupElementProperties(options) { if (!this.#element.isContentEditable) { this.#element.contentEditable = "true"; // In `jsdom` it isn't enough to set the attribute 'contentEditable' @@ -132,6 +132,9 @@ export class TextEditor extends EventTarget { if (this.#element.ariaAutoComplete) this.#element.ariaAutoComplete = false; if (!this.#element.ariaMultiLine) this.#element.ariaMultiLine = true; this.#element.dataset.itype = "editor"; + if (options.shouldUpdatePositionOnScroll) { + this.#updatePositionFromCanvas(); + } } /** @@ -142,6 +145,86 @@ export class TextEditor extends EventTarget { this.#element.appendChild(this.#root); } + /** + * Setups event listeners. + */ + #setupListeners(options) { + this.#changeController.addEventListener("change", this.#onChange); + this.#selectionController.addEventListener( + "stylechange", + this.#onStyleChange, + ); + if (options.shouldUpdatePositionOnScroll) { + window.addEventListener("scroll", this.#onScroll); + } + addEventListeners(this.#element, this.#events, { + capture: true, + }); + } + + /** + * Setups the elements, the properties and the + * initial content. + */ + #setup(options) { + this.#setupElementProperties(options); + this.#setupRoot(options); + this.#changeController = new ChangeController(this); + this.#selectionController = new SelectionController( + this, + document.getSelection(), + options, + ); + this.#setupListeners(options); + } + + /** + * Updates position from canvas. + */ + #updatePositionFromCanvas() { + const boundingClientRect = this.#canvas.getBoundingClientRect(); + this.#element.parentElement.style.top = boundingClientRect.top + "px"; + this.#element.parentElement.style.left = boundingClientRect.left + "px"; + } + + /** + * Updates caret position using a transform object. + * + * @param {*} transform + */ + updatePositionWithTransform(transform) { + const x = transform?.x ?? 0.0; + const y = transform?.y ?? 0.0; + const rotation = transform?.rotation ?? 0.0; + const scale = transform?.scale ?? 1.0; + this.#updatePositionFromCanvas(); + this.#element.style.transformOrigin = 'top left'; + this.#element.style.transform = `scale(${scale}) translate(${x}px, ${y}px) rotate(${rotation}deg)`; + } + + /** + * Updates caret position using viewport and shape. + * + * @param {Viewport} viewport + * @param {Shape} shape + */ + updatePositionWithViewportAndShape(viewport, shape) { + this.updatePositionWithTransform({ + x: viewport.x + shape.selrect.x, + y: viewport.y + shape.selrect.y, + rotation: shape.rotation, + scale: viewport.zoom, + }) + } + + /** + * Updates editor position when the page dispatches + * a scroll event. + * + * @returns + */ + #onScroll = () => this.#updatePositionFromCanvas(); + /** * Dispatchs a `change` event. * @@ -157,58 +240,9 @@ export class TextEditor extends EventTarget { * @returns {void} */ #onStyleChange = (e) => { - if (this.#selectionImposterElement.children.length > 0) { - // We need to recreate the selection imposter when we've - // already have one. - this.#createSelectionImposter(); - } this.dispatchEvent(new e.constructor(e.type, e)); }; - /** - * Setups the elements, the properties and the - * initial content. - */ - #setup(options) { - this.#setupElementProperties(); - this.#setupRoot(); - this.#changeController = new ChangeController(this); - this.#changeController.addEventListener("change", this.#onChange); - this.#selectionController = new SelectionController( - this, - document.getSelection(), - options, - ); - this.#selectionController.addEventListener( - "stylechange", - this.#onStyleChange, - ); - addEventListeners(this.#element, this.#events, { - capture: true, - }); - } - - /** - * Creates the selection imposter. - */ - #createSelectionImposter() { - // We only create a selection imposter if there's any selection - // and if there is a selection imposter element to attach the - // rects. - if ( - this.#selectionImposterElement && - !this.#selectionController.isCollapsed - ) { - const rects = this.#selectionController.range?.getClientRects(); - if (rects) { - const rect = this.#selectionImposterElement.getBoundingClientRect(); - this.#selectionImposterElement.replaceChildren( - createSelectionImposterFromClientRects(rect, rects), - ); - } - } - } - /** * On blur we create a new FakeSelection if there's any. * @@ -217,7 +251,6 @@ export class TextEditor extends EventTarget { #onBlur = (e) => { this.#changeController.notifyImmediately(); this.#selectionController.saveSelection(); - this.#createSelectionImposter(); this.dispatchEvent(new FocusEvent(e.type, e)); }; @@ -231,9 +264,6 @@ export class TextEditor extends EventTarget { if (!this.#selectionController.restoreSelection()) { this.selectAll(); } - if (this.#selectionImposterElement) { - this.#selectionImposterElement.replaceChildren(); - } this.dispatchEvent(new FocusEvent(e.type, e)); }; @@ -553,8 +583,8 @@ export function setRoot(instance, root) { return instance; } -export function create(element, options) { - return new TextEditor(element, { ...options }); +export function create(element, canvas, options) { + return new TextEditor(element, canvas, { ...options }); } export function getCurrentStyle(instance) { diff --git a/frontend/text-editor/src/editor/controllers/SelectionController.js b/frontend/text-editor/src/editor/controllers/SelectionController.js index 7bd693bb22..699b99d1a7 100644 --- a/frontend/text-editor/src/editor/controllers/SelectionController.js +++ b/frontend/text-editor/src/editor/controllers/SelectionController.js @@ -1057,7 +1057,7 @@ export class SelectionController extends EventTarget { fragment.children.length === 1 && fragment.firstElementChild?.dataset?.inline === "force" ) { - const collapseNode = fragment.lastElementChild.firstChild; + const collapseNode = fragment.firstElementChild.firstChild; if (this.isInlineStart) { this.focusInline.before(...fragment.firstElementChild.children); } else if (this.isInlineEnd) { diff --git a/frontend/text-editor/src/editor/selection/Imposter.js b/frontend/text-editor/src/editor/selection/Imposter.js deleted file mode 100644 index 992d486d86..0000000000 --- a/frontend/text-editor/src/editor/selection/Imposter.js +++ /dev/null @@ -1,31 +0,0 @@ -/** - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - * - * Copyright (c) KALEIDOS INC - */ - -/** - * Creates a new selection imposter from a list of client rects. - * - * @param {DOMRect} referenceRect - * @param {DOMRectList} clientRects - * @returns {DocumentFragment} - */ -export function createSelectionImposterFromClientRects( - referenceRect, - clientRects -) { - const fragment = document.createDocumentFragment(); - for (const rect of clientRects) { - const rectElement = document.createElement("div"); - rectElement.className = "selection-imposter-rect"; - rectElement.style.left = `${rect.x - referenceRect.x}px`; - rectElement.style.top = `${rect.y - referenceRect.y}px`; - rectElement.style.width = `${rect.width}px`; - rectElement.style.height = `${rect.height}px`; - fragment.appendChild(rectElement); - } - return fragment; -} diff --git a/frontend/text-editor/src/editor/selection/Imposter.spec.js b/frontend/text-editor/src/editor/selection/Imposter.spec.js deleted file mode 100644 index 20088a83c7..0000000000 --- a/frontend/text-editor/src/editor/selection/Imposter.spec.js +++ /dev/null @@ -1,14 +0,0 @@ -import { expect, test } from "vitest"; -import { createSelectionImposterFromClientRects } from "./Imposter.js"; - -/* @vitest-environment jsdom */ -test("Create selection DOM rects from client rects", () => { - const rect = new DOMRect(20, 20, 100, 50); - const clientRects = [ - new DOMRect(20, 20, 100, 20), - new DOMRect(20, 50, 50, 20), - ]; - const fragment = createSelectionImposterFromClientRects(rect, clientRects); - expect(fragment).toBeInstanceOf(DocumentFragment); - expect(fragment.childNodes).toHaveLength(2); -}); diff --git a/frontend/text-editor/src/index.html b/frontend/text-editor/src/index.html index 0dd8d4bbd8..581231d20f 100644 --- a/frontend/text-editor/src/index.html +++ b/frontend/text-editor/src/index.html @@ -7,554 +7,231 @@ Penpot - Text Editor Playground -
-
-
- Styles - -
- - -
-
- - -
-
- - -
-
- - -
- -
- - -
-
- - -
-
- - -
-
- - -
- -
- - -
-
- - -
-
- - -
-
- - -
- -
- - -
-
- - -
-
- - -
-
- - -
-
-
- Debug -
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- -
-
-
+
+
+
+
+ Shape +
+ + +
+
+ + +
+
+ + +
+
+
+ Styles + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ Debug +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
- - + Canvas + + --> + + +
+
+
+
- - + diff --git a/frontend/text-editor/src/playground.js b/frontend/text-editor/src/playground.js new file mode 100644 index 0000000000..30e64fe0e7 --- /dev/null +++ b/frontend/text-editor/src/playground.js @@ -0,0 +1,578 @@ +import "./style.css"; +import "./fonts.css"; +import "./editor/TextEditor.css"; +import initWasmModule from "./wasm/render_wasm.js"; +import { UUID } from "./playground/uuid.js"; +import { Rect, Point } from "./playground/geom.js"; +import { WASMModuleWrapper } from "./playground/wasm.js"; +import { FontManager } from "./playground/font.js"; +import { TextContent, TextParagraph, TextLeaf } from "./playground/text.js"; +import { Viewport } from "./playground/viewport.js"; +import { Fill } from "./playground/fill.js"; +import { Shape } from "./playground/shape.js"; +import { Color } from "./playground/color.js"; +import { TextEditor } from "./editor/TextEditor.js"; +import { SelectionControllerDebug } from "./editor/debug/SelectionControllerDebug.js"; + +function debounce(fn, delay) { + let timeout; + return (...args) => { + clearTimeout(timeout); + timeout = setTimeout(() => fn(...args), delay); + }; +} + +class TextEditorPlayground { + #module; + #canvas; + #textEditor; + #fontManager; + #viewport = new Viewport(); + #isPanning = false; + #shapes = new Map(); + #ui; + #resizeObserver; + + constructor(module, canvas) { + this.#canvas = canvas; + this.#ui = { + appElement: document.getElementById("app"), + textEditorElement: document.querySelector(".text-editor-content"), + shapePositionXElement: document.getElementById("position-x"), + shapePositionYElement: document.getElementById("position-y"), + shapeRotationElement: document.getElementById("rotation"), + + fontFamilyElement: document.getElementById("font-family"), + fontSizeElement: document.getElementById("font-size"), + fontWeightElement: document.getElementById("font-weight"), + fontStyleElement: document.getElementById("font-style"), + + directionLTRElement: document.getElementById("direction-ltr"), + directionRTLElement: document.getElementById("direction-rtl"), + + lineHeightElement: document.getElementById("line-height"), + letterSpacingElement: document.getElementById("letter-spacing"), + + textAlignLeftElement: document.getElementById("text-align-left"), + textAlignCenterElement: document.getElementById("text-align-center"), + textAlignRightElement: document.getElementById("text-align-right"), + textAlignJustifyElement: document.getElementById("text-align-justify"), + }; + this.#module = new WASMModuleWrapper(module); + this.#fontManager = new FontManager(this.#module); + this.#textEditor = new TextEditor(this.#ui.textEditorElement, canvas, { + styleDefaults: { + "font-family": "MontserratAlternates", + "font-size": "14", + "font-weight": "500", + "font-style": "normal", + "line-height": "1.2", + "letter-spacing": "0", + "direction": "ltr", + "text-align": "left", + "text-transform": "none", + "text-decoration": "none", + "--typography-ref-id": '["~#\'",null]', + "--typography-ref-file": '["~#\'",null]', + "--font-id": '["~#\'","MontserratAlternates"]', + "--fills": '[["^ ","~:fill-color","#000000","~:fill-opacity",1]]', + }, + debug: new SelectionControllerDebug({ + direction: document.getElementById("direction"), + multiElement: document.getElementById("multi"), + multiInlineElement: document.getElementById("multi-inline"), + multiParagraphElement: document.getElementById("multi-paragraph"), + isParagraphStart: document.getElementById("is-paragraph-start"), + isParagraphEnd: document.getElementById("is-paragraph-end"), + isInlineStart: document.getElementById("is-inline-start"), + isInlineEnd: document.getElementById("is-inline-end"), + isTextAnchor: document.getElementById("is-text-anchor"), + isTextFocus: document.getElementById("is-text-focus"), + focusNode: document.getElementById("focus-node"), + focusOffset: document.getElementById("focus-offset"), + focusInline: document.getElementById("focus-inline"), + focusParagraph: document.getElementById("focus-paragraph"), + anchorNode: document.getElementById("anchor-node"), + anchorOffset: document.getElementById("anchor-offset"), + anchorInline: document.getElementById("anchor-inline"), + anchorParagraph: document.getElementById("anchor-paragraph"), + startContainer: document.getElementById("start-container"), + startOffset: document.getElementById("start-offset"), + endContainer: document.getElementById("end-container"), + endOffset: document.getElementById("end-offset"), + }), + }); + } + + get canvas() { + return this.#canvas; + } + + #onWheel = (e) => { + e.preventDefault(); + const textShape = this.#shapes.get("text"); + if (!textShape) { + console.warn("Text shape not found"); + return; + } + const zoomFactor = e.deltaY < 0 ? 1.1 : 0.9; + this.#viewport.zoom *= zoomFactor; + this.#viewport.pan(e.movementX, e.movementY); + this.#textEditor.updatePositionWithViewportAndShape( + this.#viewport, + textShape, + ); + this.render(); + }; + + #onPointer = (e) => { + switch (e.type) { + case "pointermove": + if (this.#isPanning) { + this.#viewport.pan(e.movementX, e.movementY); + const textShape = this.#shapes.get("text"); + if (!textShape) { + console.warn("Text shape not found"); + return; + } + this.#textEditor.updatePositionWithViewportAndShape( + this.#viewport, + textShape, + ); + this.render(); + } + break; + case "pointerdown": + this.#isPanning = true; + break; + case "pointerleave": + case "pointerup": + this.#isPanning = false; + break; + } + }; + + #onClick = (e) => { + console.log("click", e.type, e); + const caretPosition = this.#module.call( + "get_caret_position_at", + e.offsetX, + e.offsetY, + ); + console.log("caretPosition", caretPosition); + }; + + #onResize = (_entries) => { + this.#resizeCanvas(); + this.#module.call( + "resize_viewbox", + this.#canvas.width, + this.#canvas.height, + ); + this.render(); + }; + + #onNeedsLayout = (_e) => { + const textShape = this.#shapes.get("text"); + if (!textShape) { + console.warn("Text shape not found"); + return; + } + textShape.textContent.updateFromDOM( + this.#textEditor.root, + this.#fontManager, + ); + this.#setShape(textShape); + this.render(); + }; + + #onStyleChange = (e) => { + const fontSize = parseInt(e.detail.getPropertyValue("font-size"), 10); + const fontWeight = e.detail.getPropertyValue("font-weight"); + const fontStyle = e.detail.getPropertyValue("font-style"); + const fontFamily = e.detail.getPropertyValue("font-family"); + + this.#ui.fontFamilyElement.value = fontFamily; + this.#ui.fontSizeElement.value = fontSize; + this.#ui.fontStyleElement.value = fontStyle; + this.#ui.fontWeightElement.value = fontWeight; + + const textAlign = e.detail.getPropertyValue("text-align"); + this.#ui.textAlignLeftElement.checked = textAlign === "left"; + this.#ui.textAlignCenterElement.checked = textAlign === "center"; + this.#ui.textAlignRightElement.checked = textAlign === "right"; + this.#ui.textAlignJustifyElement.checked = textAlign === "justify"; + + const direction = e.detail.getPropertyValue("direction"); + this.#ui.directionLTRElement.checked = direction === "ltr"; + this.#ui.directionRTLElement.checked = direction === "rtl"; + }; + + #resizeCanvas( + width = Math.floor(this.#canvas.clientWidth), + height = Math.floor(this.#canvas.clientHeight), + ) { + let resized = false; + if (this.#canvas.width !== width) { + this.#canvas.width = width; + resized = true; + } + if (this.#canvas.height !== height) { + this.#canvas.height = height; + resized = true; + } + return resized; + } + + #setupCanvasContext() { + this.#module.registerContext(this.#canvas, "webgl2", { + antialias: true, + depth: true, + alpha: false, + stencil: true, + preserveDrawingBuffer: true, + }); + this.#resizeCanvas(); + this.#module.call("init", this.#canvas.width, this.#canvas.height); + this.#module.call("set_render_options", 0, 1); + } + + #setupCanvas() { + this.#resizeObserver = new ResizeObserver(this.#onResize); + this.#resizeObserver.observe(this.#canvas); + this.#module.call("set_canvas_background", Color.parse("#FABADA").argb32); + this.#module.call("set_view", 1, 0, 0); + this.#module.call("init_shapes_pool", 1); + } + + #setupInteraction() { + this.#canvas.addEventListener("wheel", this.#onWheel); + this.#canvas.addEventListener("pointerdown", this.#onPointer); + this.#canvas.addEventListener("pointermove", this.#onPointer); + this.#canvas.addEventListener("pointerup", this.#onPointer); + this.#canvas.addEventListener("pointerleave", this.#onPointer); + this.#canvas.addEventListener("click", this.#onClick); + } + + async #setupFonts() { + await this.#fontManager.load(); + } + + #onDirectionChange = (e) => { + if (e.target.checked) { + this.#textEditor.applyStylesToSelection({ + direction: e.target.value, + }); + } + }; + + #onTextAlignChange = (e) => { + if (e.target.checked) { + this.#textEditor.applyStylesToSelection({ + "text-align": e.target.value, + }); + } + }; + + #onFontFamilyChange = (e) => { + const fontStyles = this.#fontManager.fonts.get(e.target.value); + for (const fontStyle of fontStyles) { + console.log("fontStyle", fontStyle); + } + this.#textEditor.applyStylesToSelection({ + "font-family": e.target.value, + }); + }; + + #onFontWeightChange = (e) => { + this.#textEditor.applyStylesToSelection({ + "font-weight": e.target.value, + }); + }; + + #onFontSizeChange = (e) => { + this.#textEditor.applyStylesToSelection({ + "font-size": e.target.value, + }); + }; + + #onFontStyleChange = (e) => { + this.#textEditor.applyStylesToSelection({ + "font-style": e.target.value, + }); + }; + + #onLineHeightChange = (e) => { + this.#textEditor.applyStylesToSelection({ + "line-height": e.target.value, + }); + }; + + #onLetterSpacingChange = (e) => { + this.#textEditor.applyStylesToSelection({ + "letter-spacing": e.target.value, + }); + }; + + #onShapePositionChange = (_e) => { + const textShape = this.#shapes.get("text"); + if (!textShape) { + console.warn("Text shape not found") + return; + } + textShape.selrect.left = this.#ui.shapePositionXElement.valueAsNumber; + textShape.selrect.top = this.#ui.shapePositionYElement.valueAsNumber; + this.#module.call( + "set_shape_selrect", + textShape.selrect.left, + textShape.selrect.top, + textShape.selrect.right, + textShape.selrect.bottom, + ); + this.#textEditor.updatePositionWithViewportAndShape(this.#viewport, textShape); + this.render(); + }; + + #onShapeRotationChange = (e) => { + const textShape = this.#shapes.get("text"); + if (!textShape) { + console.warn("Text shape not found"); + return; + } + textShape.rotation = e.target.valueAsNumber; + this.#module.call("set_shape_rotation", textShape.rotation); + this.#textEditor.updatePositionWithViewportAndShape( + this.#viewport, + textShape, + ); + this.render(); + } + + #setupUI() { + const fontFamiliesFragment = document.createDocumentFragment(); + for (const [font, fontData] of this.#fontManager.fonts) { + const fontFamilyOptionElement = document.createElement("option"); + fontFamilyOptionElement.value = font; + fontFamilyOptionElement.textContent = font; + fontFamiliesFragment.appendChild(fontFamilyOptionElement); + } + this.#ui.fontFamilyElement.replaceChildren(fontFamiliesFragment); + + this.#ui.shapePositionXElement.addEventListener( + "change", + this.#onShapePositionChange + ); + this.#ui.shapePositionYElement.addEventListener( + "change", + this.#onShapePositionChange + ); + this.#ui.shapeRotationElement.addEventListener( + "change", + this.#onShapeRotationChange + ); + + this.#ui.directionLTRElement.addEventListener( + "change", + this.#onDirectionChange, + ); + this.#ui.directionRTLElement.addEventListener( + "change", + this.#onDirectionChange, + ); + + this.#ui.textAlignLeftElement.addEventListener( + "change", + this.#onTextAlignChange, + ); + this.#ui.textAlignCenterElement.addEventListener( + "change", + this.#onTextAlignChange, + ); + this.#ui.textAlignRightElement.addEventListener( + "change", + this.#onTextAlignChange, + ); + this.#ui.textAlignJustifyElement.addEventListener( + "change", + this.#onTextAlignChange, + ); + + this.#ui.fontFamilyElement.addEventListener( + "change", + this.#onFontFamilyChange, + ); + this.#ui.fontWeightElement.addEventListener( + "change", + this.#onFontWeightChange, + ); + this.#ui.fontSizeElement.addEventListener( + "change", + this.#onFontSizeChange + ); + this.#ui.fontStyleElement.addEventListener( + "change", + this.#onFontStyleChange, + ); + this.#ui.lineHeightElement.addEventListener( + "change", + this.#onLineHeightChange, + ); + this.#ui.letterSpacingElement.addEventListener( + "change", + this.#onLetterSpacingChange, + ); + } + + #setShape(shape) { + this.#module.call("use_shape", ...shape.id); + this.#module.call("set_parent", ...shape.parentId); + this.#module.call("set_shape_type", shape.type); + this.#module.call("set_shape_rotation", shape.rotation); + this.#module.call( + "set_shape_selrect", + shape.selrect.left, + shape.selrect.top, + shape.selrect.right, + shape.selrect.bottom, + ); + if (shape.childrenIds.length > 0 && Array.isArray(shape.childrenIds)) { + let ptr = this.#module.call( + "alloc_bytes", + shape.childrenIds.length * UUID.BYTE_LENGTH, + ); + for (const childrenId of shape.childrenIds) { + this.#module.set(ptr, UUID.BYTE_LENGTH, childrenId); + ptr += UUID.BYTE_LENGTH; + } + this.#module.call("set_children"); + } + if (shape.textContent && shape.textContent instanceof TextContent) { + this.#module.call("clear_shape_text"); + for (const paragraph of shape.textContent.paragraphs) { + const ptr = this.#module.call("alloc_bytes", paragraph.byteLength); + const view = this.#module.viewOf(ptr, paragraph.byteLength); + // Number of text leaves in the paragraph. + view.setUint32(0, paragraph.leaves.length, true); + + console.log('lineHeight', paragraph.lineHeight); + + // Serialize paragraph attributes + view.setUint8(4, paragraph.textAlign, true); // text-align: left + view.setUint8(5, paragraph.textDirection, true); // text-direction: LTR + view.setUint8(6, paragraph.textDecoration, true); // text-decoration: none + view.setUint8(7, paragraph.textTransform, true); // text-transform: none + view.setFloat32(8, paragraph.lineHeight, true); // line-height: 1.2 + view.setFloat32(12, paragraph.letterSpacing, true); // letter-spacing: 0.0 + view.setUint32(16, paragraph.typographyRefFile[0], true); // typography-ref-file (UUID part 1) + view.setUint32(20, paragraph.typographyRefFile[1], true); // typography-ref-file (UUID part 2) + view.setUint32(24, paragraph.typographyRefFile[2], true); // typography-ref-file (UUID part 3) + view.setUint32(28, paragraph.typographyRefFile[3], true); // typography-ref-file (UUID part 4) + view.setUint32(32, paragraph.typographyRefId[0], true); // typography-ref-id (UUID part 1) + view.setUint32(36, paragraph.typographyRefId[1], true); // typography-ref-id (UUID part 2) + view.setUint32(40, paragraph.typographyRefId[2], true); // typography-ref-id (UUID part 3) + view.setUint32(44, paragraph.typographyRefId[3], true); // typography-ref-id (UUID part 4) + + let offset = TextParagraph.BYTE_LENGTH; + for (const leaf of paragraph.leaves) { + // Serialize leaf attributes + view.setUint8(offset + 0, leaf.fontStyle, true); // font-style: normal + view.setUint8(offset + 1, leaf.textDecoration, true); // text-decoration: none + view.setUint8(offset + 2, leaf.textTransform, true); // text-transform: none + view.setUint8(offset + 3, leaf.textDirection, true); // text-direction: ltr + view.setFloat32(offset + 4, leaf.fontSize, true); // font-size + view.setFloat32(offset + 8, leaf.letterSpacing, true); // letter-spacing + view.setInt32(offset + 12, leaf.fontWeight, true); // font-weight: normal + view.setUint32(offset + 16, leaf.fontId[0], true); // font-id (UUID part 1) + view.setUint32(offset + 20, leaf.fontId[1], true); // font-id (UUID part 2) + view.setUint32(offset + 24, leaf.fontId[2], true); // font-id (UUID part 3) + view.setUint32(offset + 28, leaf.fontId[3], true); // font-id (UUID part 4) + view.setUint32(offset + 32, leaf.fontFamilyHash, true); // font-family hash + view.setUint32(offset + 36, leaf.fontVariantId[0], true); // font-variant-id (UUID part 1) + view.setUint32(offset + 40, leaf.fontVariantId[1], true); // font-variant-id (UUID part 2) + view.setUint32(offset + 44, leaf.fontVariantId[2], true); // font-variant-id (UUID part 3) + view.setUint32(offset + 48, leaf.fontVariantId[3], true); // font-variant-id (UUID part 4) + view.setUint32(offset + 52, leaf.textByteLength, true); // text-length + view.setUint32(offset + 56, leaf.fills.length, true); // total fills count + + leaf.fills.forEach((fill, index) => { + const fillOffset = + offset + TextLeaf.BYTE_LENGTH + index * Fill.BYTE_LENGTH; + if (fill.type === Fill.Type.SOLID) { + view.setUint8(fillOffset + 0, fill.type, true); + view.setUint32(fillOffset + 4, fill.solid.color.argb32, true); + } + }); + + offset += leaf.leafByteLength; + } + + const textBuffer = paragraph.textBuffer; + this.#module.set(ptr + offset, textBuffer.byteLength, textBuffer); + this.#module.call("set_shape_text_content"); + } + this.#module.call("update_shape_text_layout"); + } + } + + #setupShapes() { + const textShape = new Shape({ + type: Shape.Type.Text, + selrect: new Rect( + new Point(canvas.width, canvas.height), + new Point(0, 0), + ), + textContent: TextContent.fromDOM( + this.#textEditor.root, + this.#fontManager, + ), + }); + const rootShape = new Shape({ + id: UUID.ZERO, + childrenIds: [textShape.id], + }); + this.#shapes.set("text", textShape); + this.#shapes.set("root", rootShape); + this.#setShape(textShape); + this.#setShape(rootShape); + } + + #setupTextEditor() { + this.#textEditor.addEventListener("needslayout", this.#onNeedsLayout); + this.#textEditor.addEventListener("stylechange", this.#onStyleChange); + } + + async setup() { + this.#setupCanvasContext(); + this.#setupCanvas(); + this.#setupInteraction(); + this.#setupTextEditor(); + await this.#setupFonts(); + this.#setupShapes(); + this.#setupUI(); + } + + #debouncedRender = debounce( + () => this.#module.call("render", Date.now()), + 16, + ); + + render() { + this.#module.call( + "set_view", + this.#viewport.zoom, + this.#viewport.x, + this.#viewport.y, + ); + this.#module.call("render_from_cache"); + this.#debouncedRender(); + } +} + +const module = await initWasmModule(); +const canvas = document.getElementById("canvas"); + +const textEditorPlayground = new TextEditorPlayground(module, canvas, { + shouldUpdatePositionOnScroll: true +}); +await textEditorPlayground.setup(); +textEditorPlayground.render(); diff --git a/frontend/text-editor/src/playground/color.js b/frontend/text-editor/src/playground/color.js new file mode 100644 index 0000000000..c3d903d9f0 --- /dev/null +++ b/frontend/text-editor/src/playground/color.js @@ -0,0 +1,129 @@ +export class ColorChannel { + #value = 0.0; + + constructor(value = 0.0) { + this.#value = value || 0.0; + } + + get value() { + return this.#value; + } + + set value(newValue) { + if (!Number.isFinite(newValue)) + throw new TypeError("Invalid color channel value"); + this.#value = newValue; + } + + get valueAsUint8() { + return Math.max(0, Math.min(0xff, Math.floor(this.#value * 0xff))); + } + + set valueAsUint8(newValue) { + if (!Number.isFinite(newValue)) + throw new TypeError("Invalid color channel value as Uint8"); + this.#value = Math.max(0, Math.min(0xff, Math.floor(newValue))) / 0xff; + } + + toString(format) { + switch (format) { + case "hex": + return this.valueAsUint8.toString(16).padStart(2, "0"); + default: + return this.valueAsUint8.toString(); + } + } +} + +export class Color { + static fromHex(string) { + if (string.length === 4 || string.length === 5) { + const hr = string.slice(1, 2); + const hg = string.slice(2, 3); + const hb = string.slice(3, 4); + const ha = string.length === 5 ? string.slice(4, 5) : "f"; + const r = parseInt(hr + hr, 16); + const g = parseInt(hg + hg, 16); + const b = parseInt(hb + hb, 16); + const a = parseInt(ha + ha, 16); + return Color.fromUint8(r, g, b, a); + } else if (string.length === 7 || string.length === 9) { + const r = parseInt(string.slice(1, 3), 16); + const g = parseInt(string.slice(3, 5), 16); + const b = parseInt(string.slice(5, 7), 16); + const a = parseInt(string.length === 9 ? string.slice(7, 9) : "ff", 16); + return Color.fromUint8(r, g, b, a); + } else { + throw new TypeError("Invalid hex string"); + } + } + + static fromUint8(r, g, b, a) { + return new Color(r / 0xff, g / 0xff, b / 0xff, a / 0xff); + } + + static parse(string) { + if (string.startsWith("#")) { + return Color.fromHex(string); + } else if (string.startsWith("rgb")) { + throw new Error("Not implemented"); + } else if (string.startsWith("hsl")) { + throw new Error("Not implemented"); + } + } + + #r = new ColorChannel(0); + #g = new ColorChannel(0); + #b = new ColorChannel(0); + #a = new ColorChannel(1); + + constructor(r = 0, g = 0, b = 0, a = 1) { + this.#r.value = r || 0; + this.#g.value = g || 0; + this.#b.value = b || 0; + this.#a.value = a || 1; + } + + get r() { + return this.#r.value; + } + get g() { + return this.#g.value; + } + get b() { + return this.#b.value; + } + get a() { + return this.#a.value; + } + + set r(newValue) { + this.#r.value = newValue; + } + set g(newValue) { + this.#g.value = newValue; + } + set b(newValue) { + this.#b.value = newValue; + } + set a(newValue) { + this.#a.value = newValue; + } + + get r8() { + return this.#r.valueAsUint8; + } + get g8() { + return this.#g.valueAsUint8; + } + get b8() { + return this.#b.valueAsUint8; + } + get a8() { + return this.#a.valueAsUint8; + } + + get argb32() { + return ((this.a8 << 24) | (this.r8 << 16) | (this.g8 << 8) | this.b8) >>> 0; + } +} diff --git a/frontend/text-editor/src/playground/fill.js b/frontend/text-editor/src/playground/fill.js new file mode 100644 index 0000000000..937c647354 --- /dev/null +++ b/frontend/text-editor/src/playground/fill.js @@ -0,0 +1,39 @@ +import { Color } from "./color.js"; + +export class FillSolid { + color = new Color(); + + get opacity() { + return this.color.a; + } + + set opacity(newOpacity) { + this.color.a = newOpacity; + } +} + +export class Fill { + static BYTE_LENGTH = 160; + + static Type = { + SOLID: 0, + LINEAR_GRADIENT: 1, + RADIAL_GRADIENT: 2, + IMAGE: 3, + }; + + #type; + #solid = new FillSolid(); + + constructor(type = Fill.Type.SOLID) { + this.#type = type; + } + + get type() { + return this.#type; + } + + get solid() { + return this.#solid; + } +} diff --git a/frontend/text-editor/src/playground/font.js b/frontend/text-editor/src/playground/font.js new file mode 100644 index 0000000000..332d10651d --- /dev/null +++ b/frontend/text-editor/src/playground/font.js @@ -0,0 +1,177 @@ +import { UUID } from "./uuid.js"; +import { fromStyle } from "./style.js"; + +export const FontStyle = { + NORMAL: 0, + REGULAR: 0, + ITALIC: 1, + OBLIQUE: 1, + + fromStyle, +}; + +export class FontData { + #id; + #url; + #font; + #name; + #styleName; + #extension; + #weight; + #style; + #arrayBuffer; + + constructor(init) { + this.#id = new UUID(); + this.#font = init.font; + this.#url = `fonts/${this.#font}`; + const [name, styleNameAndExtension] = this.#font.split("-"); + const [styleName, extension] = styleNameAndExtension.split("."); + const isItalic = styleName.endsWith("Italic"); + this.#styleName = styleName; + this.#extension = extension; + this.#name = name; + this.#weight = styleName.replaceAll("Italic", ""); + this.#style = isItalic ? "italic" : "normal"; + this.#arrayBuffer = init.arrayBuffer; + } + + get id() { + return this.#id; + } + get url() { + return this.#url; + } + get extension() { + return this.#extension; + } + get name() { + return this.#name; + } + get weight() { + return this.#weight; + } + get style() { + return this.#style; + } + get arrayBuffer() { + return this.#arrayBuffer; + } + + get styleAsNumber() { + return FontStyle.fromStyle(this.#style) ?? 0; + } + + get weightAsNumber() { + if (this.#styleName.startsWith("Thin")) { + return 100; + } else if (this.#styleName.startsWith("ExtraLight")) { + return 200; + } else if (this.#styleName.startsWith("Light")) { + return 300; + } else if (this.#styleName.startsWith("Regular")) { + return 400; + } else if (this.#styleName.startsWith("Medium")) { + return 500; + } else if (this.#styleName.startsWith("SemiBold")) { + return 600; + } else if (this.#styleName.startsWith("Bold")) { + return 700; + } else if (this.#styleName.startsWith("ExtraBold")) { + return 800; + } else if (this.#styleName.startsWith("Black")) { + return 900; + } else { + return 400; + } + } +} + +export class FontManager { + #module; + #fonts; + + /** + * + * @param {WASMModule} module + */ + constructor(module) { + this.#module = module; + } + + get fonts() { + return this.#fonts; + } + + async #loadFontList() { + const response = await fetch("fonts/fonts.txt"); + const text = await response.text(); + const fonts = text.split("\n").filter((l) => !!l); + return fonts; + } + + async #loadFonts() { + const fontData = new Map(); + const fonts = await this.#loadFontList(); + for (const font of fonts) { + const response = await fetch(`fonts/${font}`); + const arrayBuffer = await response.arrayBuffer(); + const newFontData = new FontData({ + font: font, + arrayBuffer, + }); + if (!fontData.has(newFontData.name)) { + fontData.set(newFontData.name, []); + } + const currentFontData = fontData.get(newFontData.name); + currentFontData.push(newFontData); + } + return fontData; + } + + #store(fonts) { + for (const [fontName, fontStyles] of fonts) { + for (const font of fontStyles) { + const shapeId = UUID.ZERO; + const fontId = font.id; + const weight = font.weightAsNumber; + const style = font.styleAsNumber; + const size = font.arrayBuffer.byteLength; + const ptr = this.#module.call("alloc_bytes", size); + this.#module.set(ptr, size, new Uint8Array(font.arrayBuffer)); + const emoji = false; + const fallback = false; + this.#module.call( + "store_font", + shapeId[0], + shapeId[1], + shapeId[2], + shapeId[3], + fontId[0], + fontId[1], + fontId[2], + fontId[3], + weight, + style, + emoji, + fallback, + ); + this.#module.call( + "is_font_uploaded", + fontId[0], + fontId[1], + fontId[2], + fontId[3], + weight, + style, + emoji, + ); + } + } + } + + async load() { + this.#fonts = await this.#loadFonts(); + this.#store(this.#fonts); + } +} diff --git a/frontend/text-editor/src/playground/geom.js b/frontend/text-editor/src/playground/geom.js new file mode 100644 index 0000000000..4f9962d2ab --- /dev/null +++ b/frontend/text-editor/src/playground/geom.js @@ -0,0 +1,244 @@ +export class Point { + static create(x = 0.0, y = 0.0) { + return new Point(x, y); + } + + constructor(x = 0.0, y = 0.0) { + this.x = x || 0.0; + this.y = y || 0.0; + } + + get length() { + return Math.hypot(this.x, this.y); + } + + get lengthSquared() { + return this.x * this.x + this.y * this.y; + } + + get angle() { + return Math.atan2(this.y, this.x); + } + + set(x, y) { + this.x = x ?? this.x; + this.y = y ?? this.y; + return this; + } + + reset() { + return this.set(0, 0); + } + + copy({ x, y }) { + return this.set(x, y); + } + + clone() { + return new Point(this.x, this.y); + } + + polar(angle, length = 1.0) { + return this.set( + Math.cos(angle) * length, + Math.sin(angle) * length + ); + } + + add({ x, y }) { + return this.set(this.x + x, this.y + y); + } + + addScaled({ x, y }, sx, sy = sx) { + return this.set(this.x + x * sx, this.y + y * sy); + } + + subtract({ x, y }) { + return this.set(this.x - x, this.y - y); + } + + multiply({ x, y }) { + return this.set(this.x * x, this.y * y); + } + + divide({ x, y }) { + return this.set(this.x / x, this.y / y); + } + + scale(sx, sy = sx) { + return this.set(this.x * sx, this.y * sy); + } + + rotate(angle) { + const c = Math.cos(angle); + const s = Math.sin(angle); + return this.set(c * this.x - s * this.y, s * this.x + c * this.y); + } + + perpLeft() { + return this.set(this.y, -this.x); + } + + perpRight() { + return this.set(-this.y, this.x); + } + + normalize() { + const length = this.length; + return this.set(this.x / length, this.y / length); + } + + negate() { + return this.set(-this.x, -this.y); + } + + dot({ x, y }) { + return this.x * x + this.y * y; + } + + cross({ x, y }) { + return this.x * y + this.y * x; + } + + angleTo({ x, y }) { + return Math.atan2(this.y - y, this.x - x); + } + + distanceTo({ x, y }) { + return Math.hypot(this.x - x, this.y - y); + } + + toFixed(fractionDigits = 0) { + return `Point(${this.x.toFixed(fractionDigits)}, ${this.y.toFixed(fractionDigits)})`; + } + + toString() { + return `Point(${this.x}, ${this.y})`; + } +} + +export class Rect { + static create(x, y, width, height) { + return new Rect( + new Point(width, height), + new Point(x, y), + ); + } + + #size; + #position; + + constructor(size = new Point(), position = new Point()) { + this.#size = size ?? new Point(); + this.#position = position ?? new Point(); + } + + get x() { + return this.#position.x; + } + + set x(newValue) { + this.#position.x = newValue; + } + + get y() { + return this.#position.y; + } + + set y(newValue) { + this.#position.y = newValue; + } + + get width() { + return this.#size.x; + } + + set width(newValue) { + this.#size.x = newValue; + } + + get height() { + return this.#size.y; + } + + set height(newValue) { + this.#size.y = newValue; + } + + get left() { + return this.#position.x; + } + + set left(newValue) { + this.#position.x = newValue; + } + + get right() { + return this.#position.x + this.#size.x; + } + + set right(newValue) { + this.#size.x = newValue - this.#position.x; + } + + get top() { + return this.#position.y; + } + + set top(newValue) { + this.#position.y = newValue; + } + + get bottom() { + return this.#position.y + this.#size.y; + } + + set bottom(newValue) { + this.#size.y = newValue - this.#position.y; + } + + get aspectRatio() { + return this.width / this.height; + } + + get isHorizontal() { + return this.width > this.height; + } + + get isVertical() { + return this.width < this.height; + } + + get isSquare() { + return this.width === this.height; + } + + set(x, y, width, height) { + this.#position.set(x, y); + this.#size.set(width, height); + return this; + } + + reset() { + return this.set(0, 0, 0, 0); + } + + copy({ x, y, width, height }) { + return this.set(x, y, width, height); + } + + clone() { + return new Rect( + this.#size.clone(), + this.#position.clone(), + ); + } + + toFixed(fractionDigits = 0) { + return `Rect(${this.x.toFixed(fractionDigits)}, ${this.y.toFixed(fractionDigits)}, ${this.width.toFixed(fractionDigits)}, ${this.height.toFixed(fractionDigits)})`; + } + + toString() { + return `Rect(${this.x}, ${this.y}, ${this.width}, ${this.height})`; + } +} diff --git a/frontend/text-editor/src/playground/shape.js b/frontend/text-editor/src/playground/shape.js new file mode 100644 index 0000000000..2f462202dc --- /dev/null +++ b/frontend/text-editor/src/playground/shape.js @@ -0,0 +1,94 @@ +import { UUID } from "./uuid.js"; +import { Rect } from "./geom.js"; +import { TextContent } from "./text.js"; + +export class Shape { + static Type = { + Frame: 0, + Group: 1, + Bool: 2, + Rect: 3, + Path: 4, + Text: 5, + Circle: 6, + SVGRaw: 7, + isType(type) { + return Object.values(this).includes(type); + }, + }; + + static RootId = UUID.ZERO; + + #id; + #type; + #parentId; + #selrect; + #childrenIds = []; + #textContent = null; + #rotation = 0; + + constructor(init) { + this.#id = init?.id ?? new UUID(); + if (this.#id && !(this.#id instanceof UUID)) { + throw new TypeError("Invalid shape id"); + } + this.#type = init?.type ?? Shape.Type.Rect; + if (!Shape.Type.isType(this.#type)) { + throw new TypeError("Invalid shape type"); + } + this.#parentId = init?.parentId ?? Shape.RootId; + if (this.#parentId && !(this.#parentId instanceof UUID)) { + throw new TypeError("Invalid shape parent id"); + } + this.#selrect = init?.selrect ?? new Rect(); + if (this.#selrect && !(this.#selrect instanceof Rect)) { + throw new TypeError("Invalid shape selrect"); + } + this.#childrenIds = init?.childrenIds ?? []; + if ( + !Array.isArray(this.#childrenIds) || + !this.#childrenIds.every((id) => id instanceof UUID) + ) { + throw new TypeError("Invalid shape children ids"); + } + this.#textContent = init?.textContent ?? null; + if (this.#textContent && !(this.#textContent instanceof TextContent)) { + throw new TypeError("Invalid shape text content"); + } + } + + get id() { + return this.#id; + } + + get type() { + return this.#type; + } + + get parentId() { + return this.#parentId; + } + + get selrect() { + return this.#selrect; + } + + get childrenIds() { + return this.#childrenIds; + } + + get textContent() { + return this.#textContent; + } + + get rotation() { + return this.#rotation + } + + set rotation(newRotation) { + if (!Number.isFinite(newRotation)) { + throw new TypeError('Invalid rotation') + } + this.#rotation = newRotation + } +} diff --git a/frontend/text-editor/src/playground/style.js b/frontend/text-editor/src/playground/style.js new file mode 100644 index 0000000000..aeef4a236c --- /dev/null +++ b/frontend/text-editor/src/playground/style.js @@ -0,0 +1,14 @@ +export function fromStyleValue(styleValue) { + return styleValue.replaceAll("-', '_").toUpperCase(); +} + +export function fromStyle(style) { + const entry = Object.entries(this).find(([name, value]) => + name === fromStyleValue(style) ? value : 0, + ); + if (!entry) + return; + + const [name] = entry; + return name; +} diff --git a/frontend/text-editor/src/playground/text.js b/frontend/text-editor/src/playground/text.js new file mode 100644 index 0000000000..c0f6969962 --- /dev/null +++ b/frontend/text-editor/src/playground/text.js @@ -0,0 +1,250 @@ +import { UUID } from "./uuid.js"; +import { fromStyle } from "./style.js"; +import { FontStyle } from "./font.js"; +import { Fill } from "./fill.js"; + +export const TextAlign = { + LEFT: 0, + CENTER: 1, + RIGHT: 2, + JUSTIFY: 3, + + fromStyle, +}; + +export const TextDirection = { + LTR: 0, + RTL: 1, + + fromStyle, +}; + +export const TextDecoration = { + NONE: 0, + UNDERLINE: 1, + LINE_THROUGH: 2, + OVERLINE: 3, + + fromStyle, +}; + +export const TextTransform = { + NONE: 0, + UPPERCASE: 1, + LOWERCASE: 2, + CAPITALIZE: 3, + + fromStyle, +}; + +export class TextLeaf { + static BYTE_LENGTH = 60; + + static fromDOM(leafElement, fontManager) { + const elementStyle = leafElement.style; //window.getComputedStyle(leafElement); + const fontSize = parseFloat( + elementStyle.getPropertyValue("font-size"), + ); + const fontStyle = + FontStyle.fromStyle(elementStyle.getPropertyValue("font-style")) ?? + FontStyle.NORMAL; + const fontWeight = parseInt( + elementStyle.getPropertyValue("font-weight"), + ); + const letterSpacing = parseFloat( + elementStyle.getPropertyValue("letter-spacing"), + ); + const fontFamily = elementStyle.getPropertyValue("font-family"); + console.log("fontFamily", fontFamily); + const fontStyles = fontManager.fonts.get( + fontFamily, + ); + const textDecoration = TextDecoration.fromStyle( + elementStyle.getPropertyValue("text-decoration"), + ); + const textTransform = TextTransform.fromStyle( + elementStyle.getPropertyValue("text-transform"), + ); + const textDirection = TextDirection.fromStyle( + elementStyle.getPropertyValue("text-direction"), + ); + console.log(fontWeight, fontStyle); + const font = fontStyles.find( + (currentFontStyle) => + currentFontStyle.weightAsNumber === fontWeight && + currentFontStyle.styleAsNumber === fontStyle, + ); + if (!font) { + throw new Error(`Invalid font "${fontFamily}"`) + } + return new TextLeaf({ + fontId: font.id, // leafElement.style.getPropertyValue("--font-id"), + fontFamilyHash: 0, + fontVariantId: UUID.ZERO, // leafElement.style.getPropertyValue("--font-variant-id"), + fontStyle, + fontSize, + fontWeight, + letterSpacing, + textDecoration, + textTransform, + textDirection, + text: leafElement.textContent, + }); + } + + fontId = UUID.ZERO; + fontFamilyHash = 0; + fontVariantId = UUID.ZERO; + fontStyle = 0; + fontSize = 16; + fontWeight = 400; + letterSpacing = 0.0; + textDecoration = 0; + textTransform = 0; + textDirection = 0; + #text = ""; + fills = [new Fill()]; + + constructor(init) { + this.fontId = init?.fontId ?? UUID.ZERO; + this.fontStyle = init?.fontStyle ?? 0; + this.fontSize = init?.fontSize ?? 16; + this.fontWeight = init?.fontWeight ?? 400; + this.letterSpacing = init?.letterSpacing ?? 0; + this.textDecoration = init?.textDecoration ?? 0; + this.textTransform = init?.textTransform ?? 0; + this.textDirection = init?.textDirection ?? 0; + this.#text = init?.text ?? ""; + this.fills = init?.fills ?? [new Fill()]; + } + + get text() { + return this.#text; + } + + set text(newText) { + this.#text = newText; + } + + get textByteLength() { + const text = this.text; + const textEncoder = new TextEncoder(); + const textBuffer = textEncoder.encode(text); + return textBuffer.byteLength; + } + + get leafByteLength() { + return this.fills.length * Fill.BYTE_LENGTH + TextLeaf.BYTE_LENGTH; + } +} + +export class TextParagraph { + static BYTE_LENGTH = 48; + + static fromDOM(paragraphElement, fontManager) { + return new TextParagraph({ + textAlign: TextAlign.fromStyle( + paragraphElement.style.getPropertyValue("text-align"), + ), + textDecoration: TextDecoration.fromStyle( + paragraphElement.style.getPropertyValue("text-decoration"), + ), + textTransform: TextTransform.fromStyle( + paragraphElement.style.getPropertyValue("text-transform"), + ), + textDirection: TextDirection.fromStyle( + paragraphElement.style.getPropertyValue("text-direction"), + ), + lineHeight: parseFloat( + paragraphElement.style.getPropertyValue("line-height"), + ), + letterSpacing: parseFloat( + paragraphElement.style.getPropertyValue("letter-spacing"), + ), + leaves: Array.from(paragraphElement.children, (leafElement) => + TextLeaf.fromDOM(leafElement, fontManager), + ), + }); + } + + #leaves = []; + textAlign = 0; + textDecoration = 0; + textTransform = 0; + textDirection = 0; + lineHeight = 1.2; + letterSpacing = 0; + typographyRefFile = UUID.ZERO; + typographyRefId = UUID.ZERO; + + constructor(init) { + this.textAlign = init?.textAlign ?? TextAlign.LEFT; + this.textDecoration = init?.textDecoration ?? TextDecoration.NONE; + this.textTransform = init?.textTransform ?? TextTransform.NONE; + this.textDirection = init?.textDirection ?? TextDirection.LTR; + this.lineHeight = init?.lineHeight ?? 1.2; + this.letterSpacing = init?.letterSpacing ?? 0.0; + this.typographyRefFile = init?.typographyRefFile ?? UUID.ZERO; + this.typographyRefId = init?.typographyRefId ?? UUID.ZERO; + this.#leaves = init?.leaves ?? []; + if ( + !Array.isArray(this.#leaves) || + !this.#leaves.every((leaf) => leaf instanceof TextLeaf) + ) { + throw new TypeError("Invalid text leaves"); + } + } + + get leaves() { + return this.#leaves; + } + + get text() { + return this.#leaves.reduce((acc, leaf) => acc + leaf.text, ""); + } + + get textBuffer() { + const textEncoder = new TextEncoder(); + const textBuffer = textEncoder.encode(this.text); + return textBuffer; + } + + get byteLength() { + return ( + this.#leaves.reduce((acc, leaf) => acc + leaf.leafByteLength, 0) + + this.textBuffer.byteLength + + TextParagraph.BYTE_LENGTH + ); + } +} + +export class TextContent { + static fromDOM(rootElement, fontManager) { + const paragraphs = Array.from(rootElement.children, (paragraph) => + TextParagraph.fromDOM(paragraph, fontManager), + ); + return new TextContent({ paragraphs }); + } + + #paragraphs = []; + + constructor(init) { + this.#paragraphs = init?.paragraphs ?? []; + if ( + !Array.isArray(this.#paragraphs) || + !this.#paragraphs.every((paragraph) => paragraph instanceof TextParagraph) + ) { + throw new TypeError("Invalid text paragraphs"); + } + } + + get paragraphs() { + return this.#paragraphs; + } + + updateFromDOM(rootElement, fontManager) { + this.#paragraphs = Array.from(rootElement.children, (paragraph) => + TextParagraph.fromDOM(paragraph, fontManager), + ); + } +} diff --git a/frontend/text-editor/src/playground/uuid.js b/frontend/text-editor/src/playground/uuid.js new file mode 100644 index 0000000000..25365a5355 --- /dev/null +++ b/frontend/text-editor/src/playground/uuid.js @@ -0,0 +1,47 @@ +export class UUID extends Uint32Array { + static BYTE_LENGTH = this.BYTES_PER_ELEMENT * 4; + + /** + * @type {UUID} + */ + static ZERO = new UUID("00000000-0000-0000-0000-000000000000"); + + /** + * Constructor + * + * @param {string} [id] + */ + constructor(id = crypto.randomUUID()) { + super(4); + const hex = id.replace(/-/g, ""); + for (let i = 0; i < this.length; i++) { + this[i] = parseInt(hex.slice(i * 8, (i + 1) * 8), 16); + } + } + + [Symbol.toPrimitive]() { + return this.toString(); + } + + valueOf() { + return this.toString(); + } + + toString() { + let str = ""; + for (let i = 0; i < this.length; i++) { + str += this[i].toString(16).padStart(8, "0"); + } + return ( + str.slice(0, 8) + + "-" + + str.slice(8, 12) + + "-" + + str.slice(12, 16) + + "-" + + str.slice(16, 20) + + "-" + + str.slice(20) + ); + } +} diff --git a/frontend/text-editor/src/playground/viewport.js b/frontend/text-editor/src/playground/viewport.js new file mode 100644 index 0000000000..87d0d34462 --- /dev/null +++ b/frontend/text-editor/src/playground/viewport.js @@ -0,0 +1,44 @@ +import { Point } from './geom'; + +export class Viewport { + #zoom; + #position = new Point(); + + constructor(init) { + this.#zoom = init?.zoom || 1; + this.#position.x = init?.x || 0; + this.#position.y = init?.y || 0; + } + + get zoom() { + return this.#zoom; + } + + get x() { + return this.#position.x; + } + + get y() { + return this.#position.y; + } + + set zoom(newZoom) { + if (!Number.isFinite(newZoom)) throw new TypeError("Invalid new zoom"); + this.#zoom = newZoom; + } + + set x(newX) { + if (!Number.isFinite(newX)) throw new TypeError("Invalid new x"); + this.#position.x = newX; + } + + set y(newY) { + if (!Number.isFinite(newY)) throw new TypeError("Invalid new y"); + this.#position.y = newY; + } + + pan(dx, dy) { + this.#position.x += dx / this.#zoom + this.#position.y += dy / this.#zoom + } +} diff --git a/frontend/text-editor/src/playground/wasm.js b/frontend/text-editor/src/playground/wasm.js new file mode 100644 index 0000000000..c2ea047239 --- /dev/null +++ b/frontend/text-editor/src/playground/wasm.js @@ -0,0 +1,52 @@ +export class WASMModuleWrapper { + #module; + + constructor(module) { + this.#module = module; + } + + get module() { + return this.#module; + } + + get gl() { + return this.#module.GL; + } + + get heapu8() { + return this.#module.HEAPU8; + } + + get heapu32() { + return this.#module.HEAPU32; + } + + set(ptr, size, data, byteSize = data instanceof Uint32Array ? 4 : 1) { + const heap = byteSize === 4 ? this.#module.HEAPU32 : this.#module.HEAPU8; + const typedArray = byteSize === 4 ? Uint32Array : Uint8Array; + const length = size / byteSize; + const mem = new typedArray(heap.buffer, ptr, length); + mem.set(data); + } + + viewOf(ptr, size) { + return new DataView(this.#module.HEAPU8.buffer, ptr, size); + } + + registerContext(canvas, contextId, contextAttributes) { + const gl = this.gl; + const context = canvas.getContext(contextId, contextAttributes); + const handle = gl.registerContext(context, { majorVersion: 2 }); + gl.makeContextCurrent(handle); + return context; + } + + call(name, ...args) { + const _name = `_${name}`; + if (!(_name in this.#module)) { + throw new Error(`${name} not found in WASM module`); + } + console.log("Calling", name, ...args); + return this.#module[_name](...args); + } +} diff --git a/frontend/text-editor/src/style.css b/frontend/text-editor/src/style.css index 659dbc2e16..d7a4f630a2 100644 --- a/frontend/text-editor/src/style.css +++ b/frontend/text-editor/src/style.css @@ -1,22 +1,49 @@ :root { - background-color: #333; - color: #eee; + width: 100dvw; + height: 100dvh; } -html, body { +body { margin: 0; padding: 0; + display: grid; + /* overflow: hidden; */ + width: 100dvw; + height: 100dvh; +} + +#playground { + color: #c0c0c2; + background-color: #18181a; + display: grid; + max-height: 100cqh; +} + +#info { + /* background-color: #f00; */ +} + +form { + display: flex; +} + +#app { + /* background-color: #0f0; */ } canvas { - width: 100cqw; + width: 100%; + height: max-content; } .text-editor-container { - background-color: white; + position: fixed; + min-width: 1rem; + min-height: 1rem; + transform-origin: top left; + color: transparent; } -.playground { - display: grid; - max-width: 1280px; +.text-editor-content { + outline: 1px solid red !important; } diff --git a/frontend/text-editor/src/wasm/lib.js b/frontend/text-editor/src/wasm/lib.js deleted file mode 100644 index dc64c5951a..0000000000 --- a/frontend/text-editor/src/wasm/lib.js +++ /dev/null @@ -1,595 +0,0 @@ -let Module = null; - -let scale = 1; -let offsetX = 0; -let offsetY = 0; - -let isPanning = false; -let lastX = 0; -let lastY = 0; - -export function init(moduleInstance) { - Module = moduleInstance; -} - -export function assignCanvas(canvas) { - const glModule = Module.GL; - const context = canvas.getContext("webgl2", { - antialias: true, - depth: true, - alpha: false, - stencil: true, - preserveDrawingBuffer: true, - }); - - const handle = glModule.registerContext(context, { majorVersion: 2 }); - glModule.makeContextCurrent(handle); - context.getExtension("WEBGL_debug_renderer_info"); - - Module._init(canvas.width, canvas.height); - Module._set_render_options(0, 1); -} - -export function hexToU32ARGB(hex, opacity = 1) { - const rgb = parseInt(hex.slice(1), 16); - const a = Math.floor(opacity * 0xFF); - const argb = (a << 24) | rgb; - return argb >>> 0; -} - -export function getRandomInt(min, max) { - return Math.floor(Math.random() * (max - min)) + min; -} - -export function getRandomColor() { - const r = getRandomInt(0, 256).toString(16).padStart(2, '0'); - const g = getRandomInt(0, 256).toString(16).padStart(2, '0'); - const b = getRandomInt(0, 256).toString(16).padStart(2, '0'); - return `#${r}${g}${b}`; -} - -export function getRandomFloat(min, max) { - return Math.random() * (max - min) + min; -} - -function getU32(id) { - const hex = id.replace(/-/g, ""); - const buffer = new Uint32Array(4); - for (let i = 0; i < 4; i++) { - buffer[i] = parseInt(hex.slice(i * 8, (i + 1) * 8), 16); - } - return buffer; -} - -function heapU32SetUUID(id, heap, offset) { - const buffer = getU32(id); - heap.set(buffer, offset); - return buffer; -} - -function ptr8ToPtr32(ptr8) { - return ptr8 >>> 2; -} - -export function allocBytes(size) { - return Module._alloc_bytes(size); -} - -export function getHeapU32() { - return Module.HEAPU32; -} - -export function clearShapeFills() { - Module._clear_shape_fills(); -} - -export function addShapeSolidFill(argb) { - const ptr = allocBytes(160); - const heap = getHeapU32(); - const dv = new DataView(heap.buffer); - dv.setUint8(ptr, 0x00, true); - dv.setUint32(ptr + 4, argb, true); - Module._add_shape_fill(); -} - -export function addShapeSolidStrokeFill(argb) { - const ptr = allocBytes(160); - const heap = getHeapU32(); - const dv = new DataView(heap.buffer); - dv.setUint8(ptr, 0x00, true); - dv.setUint32(ptr + 4, argb, true); - Module._add_shape_stroke_fill(); -} - -function serializePathAttrs(svgAttrs) { - return Object.entries(svgAttrs).reduce((acc, [key, value]) => { - return acc + key + '\0' + value + '\0'; - }, ''); -} - -export function draw_star(x, y, width, height) { - const len = 11; // 1 MOVE + 9 LINE + 1 CLOSE - const ptr = allocBytes(len * 28); - const heap = getHeapU32(); - const dv = new DataView(heap.buffer); - - const cx = x + width / 2; - const cy = y + height / 2; - const outerRadius = Math.min(width, height) / 2; - const innerRadius = outerRadius * 0.4; - - const star = []; - for (let i = 0; i < 10; i++) { - const angle = Math.PI / 5 * i - Math.PI / 2; - const r = i % 2 === 0 ? outerRadius : innerRadius; - const px = cx + r * Math.cos(angle); - const py = cy + r * Math.sin(angle); - star.push([px, py]); - } - - let offset = 0; - - // MOVE to first point - dv.setUint16(ptr + offset + 0, 1, true); // MOVE - dv.setFloat32(ptr + offset + 20, star[0][0], true); - dv.setFloat32(ptr + offset + 24, star[0][1], true); - offset += 28; - - // LINE to remaining points - for (let i = 1; i < star.length; i++) { - dv.setUint16(ptr + offset + 0, 2, true); // LINE - dv.setFloat32(ptr + offset + 20, star[i][0], true); - dv.setFloat32(ptr + offset + 24, star[i][1], true); - offset += 28; - } - - // CLOSE the path - dv.setUint16(ptr + offset + 0, 4, true); // CLOSE - - Module._set_shape_path_content(); - - const str = serializePathAttrs({ - "fill": "none", - "stroke-linecap": "round", - "stroke-linejoin": "round", - }); - const size = str.length; - offset = allocBytes(size); - Module.stringToUTF8(str, offset, size); - Module._set_shape_path_attrs(3); -} - - -export function setShapeChildren(shapeIds) { - const offset = allocBytes(shapeIds.length * 16); - const heap = getHeapU32(); - let currentOffset = offset; - for (const id of shapeIds) { - heapU32SetUUID(id, heap, ptr8ToPtr32(currentOffset)); - currentOffset += 16; - } - return Module._set_children(); -} - -export function useShape(id) { - const buffer = getU32(id); - Module._use_shape(...buffer); -} - -export function set_parent(id) { - const buffer = getU32(id); - Module._set_parent(...buffer); -} - -export function render() { - Module._set_view(1, 0, 0); - Module._render_from_cache(); - debouncedRender(); -} - -function debounce(fn, delay) { - let timeout; - return (...args) => { - clearTimeout(timeout); - timeout = setTimeout(() => fn(...args), delay); - }; -} - -const debouncedRender = debounce(() => { - Module._render(Date.now()); -}, 100); - -export function setupInteraction(canvas) { - canvas.addEventListener("wheel", (e) => { - e.preventDefault(); - const zoomFactor = e.deltaY < 0 ? 1.1 : 0.9; - scale *= zoomFactor; - const mouseX = e.offsetX; - const mouseY = e.offsetY; - offsetX -= (mouseX - offsetX) * (zoomFactor - 1); - offsetY -= (mouseY - offsetY) * (zoomFactor - 1); - Module._set_view(scale, offsetX, offsetY); - Module._render_from_cache(); - debouncedRender(); - }); - - canvas.addEventListener("mousedown", (e) => { - isPanning = true; - lastX = e.offsetX; - lastY = e.offsetY; - }); - - canvas.addEventListener("mousemove", (e) => { - if (isPanning) { - const dx = e.offsetX - lastX; - const dy = e.offsetY - lastY; - offsetX += dx; - offsetY += dy; - lastX = e.offsetX; - lastY = e.offsetY; - Module._set_view(scale, offsetX, offsetY); - Module._render_from_cache(); - debouncedRender(); - } - }); - - canvas.addEventListener("mouseup", () => { isPanning = false; }); - canvas.addEventListener("mouseout", () => { isPanning = false; }); -} - -const TextAlign = { - 'left': 0, - 'center': 1, - 'right': 2, - 'justify': 3, -} - -function getTextAlign(textAlign) { - if (textAlign in TextAlign) { - return TextAlign[textAlign]; - } - return 0; -} - -function getTextDirection(textDirection) { - switch (textDirection) { - default: - case 'LTR': return 0; - case 'RTL': return 1; - } -} - -function getTextDecoration(textDecoration) { - switch (textDecoration) { - default: - case 'none': return 0; - case 'underline': return 1; - case 'line-through': return 2; - case 'overline': return 3; - } -} - -function getTextTransform(textTransform) { - switch (textTransform) { - default: - case 'none': return 0; - case 'uppercase': return 1; - case 'lowercase': return 2; - case 'capitalize': return 3; - } -} - -function getFontStyle(fontStyle) { - switch (fontStyle) { - default: - case 'normal': - case 'regular': - return 0; - case 'oblique': - case 'italic': - return 1; - } -} - -const PARAGRAPH_ATTR_SIZE = 48; -const LEAF_ATTR_SIZE = 60; -const FILL_SIZE = 160; - -function setParagraphData(dview, { numLeaves, textAlign, textDirection, textDecoration, textTransform, lineHeight, letterSpacing }) { - // Set number of leaves - dview.setUint32(0, numLeaves, true); - - // Serialize paragraph attributes - dview.setUint8(4, textAlign, true); // text-align: left - dview.setUint8(5, textDirection, true); // text-direction: LTR - dview.setUint8(6, textDecoration, true); // text-decoration: none - dview.setUint8(7, textTransform, true); // text-transform: none - dview.setFloat32(8, lineHeight, true); // line-height - dview.setFloat32(12, letterSpacing, true); // letter-spacing - dview.setUint32(16, 0, true); // typography-ref-file (UUID part 1) - dview.setUint32(20, 0, true); // typography-ref-file (UUID part 2) - dview.setUint32(24, 0, true); // typography-ref-file (UUID part 3) - dview.setUint32(28, 0, true); // typography-ref-file (UUID part 4) - dview.setUint32(32, 0, true); // typography-ref-id (UUID part 1) - dview.setUint32(36, 0, true); // typography-ref-id (UUID part 2) - dview.setUint32(40, 0, true); // typography-ref-id (UUID part 3) - dview.setUint32(44, 0, true); // typography-ref-id (UUID part 4) -} - -function setLeafData(dview, leafOffset, { - fontId, - fontStyle, - fontSize, - fontWeight, - letterSpacing, - textSize, - totalFills -}) { - // Serialize leaf attributes - dview.setUint8(leafOffset + 0, fontStyle, true); // font-style: normal - dview.setUint8(leafOffset + 1, 0, true); // text-decoration: none - dview.setUint8(leafOffset + 2, 0, true); // text-transform: none - dview.setUint8(leafOffset + 3, 0, true); // text-direction: ltr - dview.setFloat32(leafOffset + 4, fontSize, true); // font-size - dview.setFloat32(leafOffset + 8, letterSpacing, true); // letter-spacing - dview.setInt32(leafOffset + 12, fontWeight, true); // font-weight: normal - dview.setUint32(leafOffset + 16, fontId[0], true); // font-id (UUID part 1) - dview.setUint32(leafOffset + 20, fontId[1], true); // font-id (UUID part 2) - dview.setUint32(leafOffset + 24, fontId[2], true); // font-id (UUID part 3) - dview.setUint32(leafOffset + 28, fontId[3], true); // font-id (UUID part 4) - dview.setUint32(leafOffset + 32, 0, true); // font-family hash - dview.setUint32(leafOffset + 36, 0, true); // font-variant-id (UUID part 1) - dview.setUint32(leafOffset + 40, 0, true); // font-variant-id (UUID part 2) - dview.setUint32(leafOffset + 44, 0, true); // font-variant-id (UUID part 3) - dview.setUint32(leafOffset + 48, 0, true); // font-variant-id (UUID part 4) - dview.setUint32(leafOffset + 52, textSize, true); // text-length - dview.setUint32(leafOffset + 56, totalFills, true); // total fills count -} - -function getFontFrom(fontFamily, fontWeight, fontStyle, fonts) { - const fontList = fonts.get(fontFamily) - if (!fontList) { - return null - } - return fontList.find(fontData => fontData.weight === fontWeight && fontStyle === fontData.style) -} - -export function updateTextShape(root, fonts) { - // Calculate fills - const fills = [ - { - type: "solid", - color: "#ff00ff", - opacity: 1.0, - }, - ]; - - const totalFills = fills.length; - const totalFillsSize = totalFills * FILL_SIZE; - - const paragraphs = root.children; - - Module._clear_shape_text(); - for (const paragraph of paragraphs) { - let totalSize = PARAGRAPH_ATTR_SIZE; - - const leaves = paragraph.children; - const numLeaves = leaves.length; - - for (const leaf of leaves) { - const text = leaf.textContent; - const textBuffer = new TextEncoder().encode(text); - const textSize = textBuffer.byteLength; - totalSize += LEAF_ATTR_SIZE + totalFillsSize; - } - - totalSize += paragraph.textContent.length; - - // Allocate buffer - const bufferPtr = allocBytes(totalSize); - const heap = new Uint8Array(Module.HEAPU8.buffer, bufferPtr, totalSize); - const dview = new DataView(heap.buffer, bufferPtr, totalSize); - - const textAlign = getTextAlign( - paragraph.style.getPropertyValue("text-align"), - ); - const textDirection = getTextDirection( - paragraph.style.getPropertyValue("text-direction"), - ); - const textDecoration = getTextDecoration( - paragraph.style.getPropertyValue("text-decoration"), - ); - const textTransform = getTextTransform( - paragraph.style.getPropertyValue("text-transform"), - ); - const lineHeight = parseFloat( - paragraph.style.getPropertyValue("line-height"), - ); - const letterSpacing = parseFloat( - paragraph.style.getPropertyValue("letter-spacing"), - ); - - setParagraphData(dview, { - numLeaves, - textAlign, - textDecoration, - textTransform, - textDirection, - lineHeight, - letterSpacing - }) - let leafOffset = PARAGRAPH_ATTR_SIZE; - for (const leaf of leaves) { - const fontStyle = leaf.style.getPropertyValue("font-style"); - const fontStyleSerialized = getFontStyle(fontStyle); - const fontSize = parseFloat(leaf.style.getPropertyValue("font-size")); - const letterSpacing = parseFloat(leaf.style.getPropertyValue("letter-spacing")) - const fontWeight = parseInt( - leaf.style.getPropertyValue("font-weight"), - 10, - ); - - const text = leaf.textContent; - const textBuffer = new TextEncoder().encode(text); - const textSize = textBuffer.byteLength; - const fontFamily = leaf.style.getPropertyValue('font-family'); - const fontData = getFontFrom(fontFamily, fontWeight, fontStyle, fonts) - const defaultFontId = new Uint32Array([0, 0, 0, 0]) - const fontId = fontData ? getU32(fontData.id) : defaultFontId - setLeafData(dview, leafOffset, { - fontId, - fontStyle: fontStyleSerialized, - textDecoration: 0, - textTransform: 0, - textDirection: 0, - fontSize, - fontWeight, - letterSpacing, - textSize, - totalFills, - }); - - // Serialize fills - let fillOffset = leafOffset + LEAF_ATTR_SIZE; - fills.forEach((fill) => { - if (fill.type === "solid") { - const argb = hexToU32ARGB(fill.color, fill.opacity); - dview.setUint8(fillOffset + 0, 0x00, true); // Fill type: solid - dview.setUint32(fillOffset + 4, argb, true); - fillOffset += FILL_SIZE; // Move to the next fill - } - }); - leafOffset += LEAF_ATTR_SIZE + totalFillsSize; - } - - const text = paragraph.textContent; - const textBuffer = new TextEncoder().encode(text); - - // Add text content - const textOffset = leafOffset; - heap.set(textBuffer, textOffset); - - Module._set_shape_text_content(); - } - Module._update_shape_text_layout(); -} - -export function addTextShape(text, fonts) { - const numLeaves = 1; // Single text leaf for simplicity - const textBuffer = new TextEncoder().encode(text); - const textSize = textBuffer.byteLength; - - // Calculate fills - const fills = [ - { - type: "solid", - color: "#ff00ff", - opacity: 1.0, - }, - ]; - const totalFills = fills.length; - const totalFillsSize = totalFills * FILL_SIZE; - - // Calculate metadata and total buffer size - const metadataSize = PARAGRAPH_ATTR_SIZE + LEAF_ATTR_SIZE + totalFillsSize; - const totalSize = metadataSize + textSize; - - // Allocate buffer - const bufferPtr = allocBytes(totalSize); - const heap = new Uint8Array(Module.HEAPU8.buffer, bufferPtr, totalSize); - const dview = new DataView(heap.buffer, bufferPtr, totalSize); - - setParagraphData(dview, { - numLeaves, - textAlign: 0, - textDecoration: 0, - textTransform: 0, - textDirection: 0, - lineHeight: 1.2, - letterSpacing: 0, - }); - - // Serialize leaf attributes - const leafOffset = PARAGRAPH_ATTR_SIZE; - const fontStyle = getFontStyle('normal'); - const fontStyleSerialized = getFontStyle(fontStyle); - const fontSize = 14; - const letterSpacing = 0; - const fontWeight = 400; - const fontFamily = 'MontserratAlternates'; - const fontData = getFontFrom(fontFamily, fontWeight, fontStyle, fonts); - const defaultFontId = new Uint32Array([0, 0, 0, 0]); - const fontId = fontData ? getU32(fontData.id) : defaultFontId; - - setLeafData(dview, leafOffset, { - fontId, - fontSize, - fontStyle: fontStyleSerialized, - fontWeight, - textDecoration: 0, - textDirection: 0, - textTransform: 0, - letterSpacing, - textSize, - totalFills, - }); - - // Serialize fills - let fillOffset = leafOffset + LEAF_ATTR_SIZE; - fills.forEach((fill) => { - if (fill.type === "solid") { - const argb = hexToU32ARGB(fill.color, fill.opacity); - dview.setUint8(fillOffset, 0x00, true); // Fill type: solid - dview.setUint32(fillOffset + 4, argb, true); - fillOffset += FILL_SIZE; // Move to the next fill - } - }); - - // Add text content - const textOffset = metadataSize; - heap.set(textBuffer, textOffset); - - // Call the WebAssembly function - Module._set_shape_text_content(); -} - -export function storeFonts(fonts) { - for (const [fontName, fontStyles] of fonts) { - for (const font of fontStyles) { - const shapeId = getU32('00000000-0000-0000-0000-000000000000'); - const fontId = getU32(font.id); - const weight = font.weight; - const style = getFontStyle(font.style); - const size = font.arrayBuffer.byteLength; - const ptr = Module._alloc_bytes(size); - const heap = Module.HEAPU8; - const mem = new Uint8Array(heap.buffer, ptr, size); - mem.set(new Uint8Array(font.arrayBuffer)); - const emoji = false - const fallback = false - Module._store_font( - shapeId[0], - shapeId[1], - shapeId[2], - shapeId[3], - fontId[0], - fontId[1], - fontId[2], - fontId[3], - weight, - style, - emoji, - fallback, - ); - Module._is_font_uploaded( - fontId[0], - fontId[1], - fontId[2], - fontId[3], - weight, - style, - emoji, - ); - } - } -} diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 06ad75eb3d..a8b8bb036c 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -77,6 +77,20 @@ macro_rules! with_current_shape { }; } +#[macro_export] +macro_rules! with_state_mut_current_shape { + ($state:ident, |$shape:ident: &Shape| $block:block) => { + let $state = unsafe { + #[allow(static_mut_refs)] + STATE.as_mut() + } + .expect("Got an invalid state pointer"); + if let Some($shape) = $state.current_shape() { + $block + } + }; +} + /// This is called from JS after the WebGL context has been created. #[no_mangle] pub extern "C" fn init(width: i32, height: i32) { diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 37c04900f3..c2be912422 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -945,7 +945,17 @@ impl RenderState { ) -> Result<(), String> { performance::begin_measure!("process_animation_frame"); if self.render_in_progress { - self.render_shape_tree_partial(tree, modifiers, structure, scale_content, timestamp)?; + if tree.len() != 0 { + self.render_shape_tree_partial( + tree, + modifiers, + structure, + scale_content, + timestamp, + )?; + } else { + println!("Empty tree"); + } self.flush_and_submit(); if self.render_in_progress { diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index 4f69d38926..9ef7c36380 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -223,10 +223,15 @@ fn draw_text( let mut group_offset_y = global_offset_y; let group_len = paragraph_builder_group.len(); - for paragraph_builder in paragraph_builder_group.iter_mut() { + for (paragraph_index, paragraph_builder) in paragraph_builder_group.iter_mut().enumerate() { let mut paragraph = paragraph_builder.build(); paragraph.layout(paragraph_width); - let paragraph_height = paragraph.height(); + let _paragraph_height = paragraph.height(); + // FIXME: I've kept the _paragraph_height variable to have + // a reminder in the future to keep digging why the ideographic_baseline + // works so well and not the paragraph_height. I think we should test + // this more. + let ideographic_baseline = paragraph.ideographic_baseline(); let xy = (shape.selrect().x(), shape.selrect().y() + group_offset_y); paragraph.paint(canvas, xy); @@ -234,18 +239,17 @@ fn draw_text( render_text_decoration(canvas, ¶graph, paragraph_builder, line_metrics, xy); } + #[allow(clippy::collapsible_else_if)] if group_len == 1 { - group_offset_y += paragraph_height; + group_offset_y += ideographic_baseline; + } else { + if paragraph_index == 0 { + group_offset_y += ideographic_baseline; + } } } - if group_len > 1 { - let mut first_paragraph = paragraph_builder_group[0].build(); - first_paragraph.layout(paragraph_width); - global_offset_y += first_paragraph.height(); - } else { - global_offset_y = group_offset_y; - } + global_offset_y = group_offset_y; } } diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 4c2980ebce..6deb5bbd14 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -1110,8 +1110,7 @@ impl Shape { if let Some(path) = shape_type.path_mut() { path.transform(transform); } - } - if let Type::Text(text) = &mut self.shape_type { + } else if let Type::Text(text) = &mut self.shape_type { text.transform(transform); } } diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index e31bac1195..98fbcd8313 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -85,6 +85,30 @@ impl TextContentSize { } } +#[derive(Debug, Clone, Copy)] +pub struct TextPositionWithAffinity { + pub position_with_affinity: PositionWithAffinity, + pub paragraph: i32, + pub leaf: i32, + pub offset: i32, +} + +impl TextPositionWithAffinity { + pub fn new( + position_with_affinity: PositionWithAffinity, + paragraph: i32, + leaf: i32, + offset: i32, + ) -> Self { + Self { + position_with_affinity, + paragraph, + leaf, + offset, + } + } +} + #[derive(Debug)] pub struct TextContentLayoutResult( Vec, @@ -95,7 +119,7 @@ pub struct TextContentLayoutResult( #[derive(Debug)] pub struct TextContentLayout { pub paragraph_builders: Vec, - pub paragraphs: Vec>, + pub paragraphs: Vec>, } impl Clone for TextContentLayout { @@ -223,18 +247,49 @@ impl TextContent { self.bounds = Rect::from_ltrb(p1.x, p1.y, p2.x, p2.y); } - pub fn get_caret_position_at(&self, point: &Point) -> Option { + pub fn get_caret_position_at(&self, point: &Point) -> Option { let mut offset_y = 0.0; - let paragraphs = self.layout.paragraphs.iter().flatten(); + let layout_paragraphs = self.layout.paragraphs.iter().flatten(); - for paragraph in paragraphs { + let mut paragraph_index: i32 = -1; + let mut leaf_index: i32 = -1; + for layout_paragraph in layout_paragraphs { + paragraph_index += 1; let start_y = offset_y; - let end_y = offset_y + paragraph.height(); + let end_y = offset_y + layout_paragraph.height(); + + // We only test against paragraphs that can contain the current y + // coordinate. if point.y > start_y && point.y < end_y { - let position_with_affinity = paragraph.get_glyph_position_at_coordinate(*point); - return Some(position_with_affinity); + let position_with_affinity = + layout_paragraph.get_glyph_position_at_coordinate(*point); + if let Some(paragraph) = self.paragraphs().get(paragraph_index as usize) { + // Computed position keeps the current position in terms + // of number of characters of text. This is used to know + // in which leaf we are. + let mut computed_position = 0; + let mut leaf_offset = 0; + for leaf in paragraph.children() { + leaf_index += 1; + let length = leaf.text.len(); + let start_position = computed_position; + let end_position = computed_position + length; + let current_position = position_with_affinity.position as usize; + if start_position <= current_position && end_position >= current_position { + leaf_offset = position_with_affinity.position - start_position as i32; + break; + } + computed_position += length; + } + return Some(TextPositionWithAffinity::new( + position_with_affinity, + paragraph_index, + leaf_index, + leaf_offset, + )); + } } - offset_y += paragraph.height(); + offset_y += layout_paragraph.height(); } None } diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index bab9e60add..967c9d0b95 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -2,7 +2,9 @@ use skia_safe::{self as skia, textlayout::FontCollection, Path, Point}; use std::collections::HashMap; mod shapes_pool; +mod text_editor; pub use shapes_pool::*; +pub use text_editor::*; use crate::render::RenderState; use crate::shapes::Shape; @@ -19,6 +21,7 @@ use crate::shapes::modifiers::grid_layout::grid_cell_data; /// must not be shared between different Web Workers. pub(crate) struct State { pub render_state: RenderState, + pub text_editor_state: TextEditorState, pub current_id: Option, pub shapes: ShapesPool, pub modifiers: HashMap, @@ -30,6 +33,7 @@ impl State { pub fn new(width: i32, height: i32) -> Self { State { render_state: RenderState::new(width, height), + text_editor_state: TextEditorState::new(), current_id: None, shapes: ShapesPool::new(), modifiers: HashMap::new(), @@ -50,6 +54,16 @@ impl State { &self.render_state } + #[allow(dead_code)] + pub fn text_editor_state_mut(&mut self) -> &mut TextEditorState { + &mut self.text_editor_state + } + + #[allow(dead_code)] + pub fn text_editor_state(&self) -> &TextEditorState { + &self.text_editor_state + } + pub fn render_from_cache(&mut self) { self.render_state .render_from_cache(&self.shapes, &self.modifiers, &self.structure); diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs new file mode 100644 index 0000000000..c32c648260 --- /dev/null +++ b/render-wasm/src/state/text_editor.rs @@ -0,0 +1,103 @@ +#![allow(dead_code)] + +use crate::shapes::TextPositionWithAffinity; + +/// TODO: Now this is just a tuple with 2 i32 working +/// as indices (paragraph and leaf). +#[derive(Debug, PartialEq, Clone, Copy)] +pub struct TextNodePosition { + pub paragraph: i32, + pub leaf: i32, +} + +impl TextNodePosition { + pub fn new(paragraph: i32, leaf: i32) -> Self { + Self { paragraph, leaf } + } + + #[allow(dead_code)] + pub fn is_invalid(&self) -> bool { + self.paragraph < 0 || self.leaf < 0 + } +} + +pub struct TextPosition { + node: Option, + offset: i32, +} + +impl TextPosition { + pub fn new() -> Self { + Self { + node: None, + offset: -1, + } + } + + pub fn set(&mut self, node: Option, offset: i32) { + self.node = node; + self.offset = offset; + } +} + +pub struct TextSelection { + focus: TextPosition, + anchor: TextPosition, +} + +impl TextSelection { + pub fn new() -> Self { + Self { + focus: TextPosition::new(), + anchor: TextPosition::new(), + } + } + + #[allow(dead_code)] + pub fn is_caret(&self) -> bool { + self.focus.node == self.anchor.node && self.focus.offset == self.anchor.offset + } + + #[allow(dead_code)] + pub fn is_selection(&self) -> bool { + !self.is_caret() + } + + pub fn set_focus(&mut self, node: Option, offset: i32) { + self.focus.set(node, offset); + } + + pub fn set_anchor(&mut self, node: Option, offset: i32) { + self.anchor.set(node, offset); + } + + pub fn set(&mut self, node: Option, offset: i32) { + self.set_focus(node, offset); + self.set_anchor(node, offset); + } +} + +pub struct TextEditorState { + selection: TextSelection, +} + +impl TextEditorState { + pub fn new() -> Self { + Self { + selection: TextSelection::new(), + } + } + + pub fn set_caret_position_from( + &mut self, + text_position_with_affinity: TextPositionWithAffinity, + ) { + self.selection.set( + Some(TextNodePosition::new( + text_position_with_affinity.paragraph, + text_position_with_affinity.leaf, + )), + text_position_with_affinity.offset, + ); + } +} diff --git a/render-wasm/src/wasm/text.rs b/render-wasm/src/wasm/text.rs index aaa4a309d7..f53680cf84 100644 --- a/render-wasm/src/wasm/text.rs +++ b/render-wasm/src/wasm/text.rs @@ -7,7 +7,7 @@ use crate::shapes::{ self, GrowType, TextAlign, TextDecoration, TextDirection, TextTransform, Type, }; use crate::utils::{uuid_from_u32, uuid_from_u32_quartet}; -use crate::{with_current_shape, with_current_shape_mut, with_state_mut, STATE}; +use crate::{with_current_shape_mut, with_state_mut, with_state_mut_current_shape, STATE}; const RAW_LEAF_DATA_SIZE: usize = std::mem::size_of::(); const RAW_PARAGRAPH_DATA_SIZE: usize = std::mem::size_of::(); @@ -368,7 +368,7 @@ pub extern "C" fn update_shape_text_layout_for_all() { #[no_mangle] pub extern "C" fn get_caret_position_at(x: f32, y: f32) -> i32 { - with_current_shape!(state, |shape: &Shape| { + with_state_mut_current_shape!(state, |shape: &Shape| { if let Type::Text(text_content) = &shape.shape_type { let mut matrix = Matrix::new_identity(); let shape_matrix = shape.get_concatenated_matrix(&state.shapes); @@ -382,11 +382,11 @@ pub extern "C" fn get_caret_position_at(x: f32, y: f32) -> i32 { if let Some(position_with_affinity) = text_content.get_caret_position_at(&mapped_point) { - return position_with_affinity.position; + return position_with_affinity.position_with_affinity.position; } } } else { - panic!("Trying to update grow type in a shape that it's not a text shape"); + panic!("Trying to get caret position of a shape that it's not a text shape"); } }); -1