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 @@