mirror of
https://github.com/penpot/penpot.git
synced 2025-12-12 06:24:17 +01:00
Merge pull request #7361 from penpot/azazeln28-feat-dom-textarea-position
🎉 Text Editor DOM textarea position
This commit is contained in:
@@ -6,7 +6,7 @@ test.beforeEach(async ({ page }) => {
|
|||||||
await WorkspacePage.mockConfigFlags(page, ["enable-feature-text-editor-v2"]);
|
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);
|
const workspace = new WorkspacePage(page);
|
||||||
await workspace.setupEmptyFile();
|
await workspace.setupEmptyFile();
|
||||||
await workspace.mockGetFile("text-editor/get-file-11552.json");
|
await workspace.mockGetFile("text-editor/get-file-11552.json");
|
||||||
|
|||||||
@@ -61,12 +61,12 @@
|
|||||||
|
|
||||||
(defn- initialize-event-handlers
|
(defn- initialize-event-handlers
|
||||||
"Internal editor events handler initializer/destructor"
|
"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
|
(let [editor-node
|
||||||
(mf/ref-val editor-ref)
|
(mf/ref-val editor-ref)
|
||||||
|
|
||||||
selection-node
|
canvas-node
|
||||||
(mf/ref-val selection-ref)
|
(mf/ref-val canvas-ref)
|
||||||
|
|
||||||
;; Gets the default font from the workspace refs.
|
;; Gets the default font from the workspace refs.
|
||||||
default-font
|
default-font
|
||||||
@@ -81,11 +81,10 @@
|
|||||||
default-font))
|
default-font))
|
||||||
|
|
||||||
options
|
options
|
||||||
#js {:styleDefaults style-defaults
|
#js {:styleDefaults style-defaults}
|
||||||
:selectionImposterElement selection-node}
|
|
||||||
|
|
||||||
instance
|
instance
|
||||||
(dwt/create-editor editor-node options)
|
(dwt/create-editor editor-node canvas-node options)
|
||||||
|
|
||||||
update-name? (nil? content)
|
update-name? (nil? content)
|
||||||
|
|
||||||
@@ -180,7 +179,7 @@
|
|||||||
"Text editor (HTML)"
|
"Text editor (HTML)"
|
||||||
{::mf/wrap [mf/memo]
|
{::mf/wrap [mf/memo]
|
||||||
::mf/props :obj}
|
::mf/props :obj}
|
||||||
[{:keys [shape]}]
|
[{:keys [shape canvas-ref]}]
|
||||||
(let [content (:content shape)
|
(let [content (:content shape)
|
||||||
shape-id (dm/get-prop shape :id)
|
shape-id (dm/get-prop shape :id)
|
||||||
fill-color (get-color-from-content content)
|
fill-color (get-color-from-content content)
|
||||||
@@ -190,7 +189,6 @@
|
|||||||
editor-ref (mf/use-ref nil)
|
editor-ref (mf/use-ref nil)
|
||||||
;; This reference is to the container
|
;; This reference is to the container
|
||||||
container-ref (mf/use-ref nil)
|
container-ref (mf/use-ref nil)
|
||||||
selection-ref (mf/use-ref nil)
|
|
||||||
|
|
||||||
page (mf/deref refs/workspace-page)
|
page (mf/deref refs/workspace-page)
|
||||||
objects (get page :objects)
|
objects (get page :objects)
|
||||||
@@ -213,8 +211,8 @@
|
|||||||
(mf/with-effect [shape-id]
|
(mf/with-effect [shape-id]
|
||||||
(initialize-event-handlers shape-id
|
(initialize-event-handlers shape-id
|
||||||
content
|
content
|
||||||
selection-ref
|
|
||||||
editor-ref
|
editor-ref
|
||||||
|
canvas-ref
|
||||||
container-ref
|
container-ref
|
||||||
text-color))
|
text-color))
|
||||||
|
|
||||||
@@ -239,9 +237,6 @@
|
|||||||
;; on-blur and on-focus) but I keep this for future references.
|
;; on-blur and on-focus) but I keep this for future references.
|
||||||
;; :opacity (when @blurred 0)}}
|
;; :opacity (when @blurred 0)}}
|
||||||
}
|
}
|
||||||
[:div
|
|
||||||
{:class (stl/css :text-editor-selection-imposter)
|
|
||||||
:ref selection-ref}]
|
|
||||||
[:div
|
[:div
|
||||||
{:class (dm/str
|
{:class (dm/str
|
||||||
"mousetrap "
|
"mousetrap "
|
||||||
@@ -279,7 +274,7 @@
|
|||||||
{::mf/wrap [mf/memo]
|
{::mf/wrap [mf/memo]
|
||||||
::mf/props :obj
|
::mf/props :obj
|
||||||
::mf/forward-ref true}
|
::mf/forward-ref true}
|
||||||
[{:keys [shape modifiers] :as props} _]
|
[{:keys [shape modifiers canvas-ref] :as props} _]
|
||||||
(let [shape-id (dm/get-prop shape :id)
|
(let [shape-id (dm/get-prop shape :id)
|
||||||
modifiers (dm/get-in modifiers [shape-id :modifiers])
|
modifiers (dm/get-in modifiers [shape-id :modifiers])
|
||||||
|
|
||||||
@@ -310,10 +305,10 @@
|
|||||||
|
|
||||||
[x y width height]
|
[x y width height]
|
||||||
(if (features/active-feature? @st/state "render-wasm/v1")
|
(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)]
|
{:keys [x y]} (:selrect shape)]
|
||||||
|
|
||||||
[x y max-width height])
|
[x y width height])
|
||||||
|
|
||||||
(let [bounds (gst/shape->rect shape)
|
(let [bounds (gst/shape->rect shape)
|
||||||
x (mth/min (dm/get-prop bounds :x)
|
x (mth/min (dm/get-prop bounds :x)
|
||||||
@@ -358,4 +353,5 @@
|
|||||||
[:foreignObject {:x x :y y :width width :height height}
|
[:foreignObject {:x x :y y :width width :height height}
|
||||||
[:div {:style style}
|
[:div {:style style}
|
||||||
[:& text-editor-html {:shape shape
|
[:& text-editor-html {:shape shape
|
||||||
|
:canvas-ref canvas-ref
|
||||||
:key (dm/str shape-id)}]]]]))
|
:key (dm/str shape-id)}]]]]))
|
||||||
|
|||||||
@@ -147,6 +147,8 @@
|
|||||||
[viewport-ref on-viewport-ref]
|
[viewport-ref on-viewport-ref]
|
||||||
(create-viewport-ref)
|
(create-viewport-ref)
|
||||||
|
|
||||||
|
canvas-ref (mf/use-ref nil)
|
||||||
|
|
||||||
;; VARS
|
;; VARS
|
||||||
disable-paste (mf/use-var false)
|
disable-paste (mf/use-var false)
|
||||||
in-viewport? (mf/use-var false)
|
in-viewport? (mf/use-var false)
|
||||||
@@ -440,6 +442,7 @@
|
|||||||
(when show-text-editor?
|
(when show-text-editor?
|
||||||
(if (features/active-feature? @st/state "text-editor/v2")
|
(if (features/active-feature? @st/state "text-editor/v2")
|
||||||
[:& editor-v2/text-editor {:shape editing-shape
|
[:& editor-v2/text-editor {:shape editing-shape
|
||||||
|
:canvas-ref canvas-ref
|
||||||
:modifiers modifiers}]
|
:modifiers modifiers}]
|
||||||
[:& editor-v1/text-editor-svg {:shape editing-shape
|
[:& editor-v1/text-editor-svg {:shape editing-shape
|
||||||
:modifiers modifiers}]))
|
:modifiers modifiers}]))
|
||||||
|
|||||||
@@ -432,6 +432,7 @@
|
|||||||
(when show-text-editor?
|
(when show-text-editor?
|
||||||
(if (features/active-feature? @st/state "text-editor/v2")
|
(if (features/active-feature? @st/state "text-editor/v2")
|
||||||
[:& editor-v2/text-editor {:shape editing-shape
|
[:& editor-v2/text-editor {:shape editing-shape
|
||||||
|
:canvas-ref canvas-ref
|
||||||
:ref text-editor-ref}]
|
:ref text-editor-ref}]
|
||||||
[:& editor-v1/text-editor-svg {:shape editing-shape
|
[:& editor-v1/text-editor-svg {:shape editing-shape
|
||||||
:ref text-editor-ref}]))
|
:ref text-editor-ref}]))
|
||||||
|
|||||||
@@ -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 {
|
.text-editor-content {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
font-family: sourcesanspro;
|
font-family: sourcesanspro;
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import clipboard from "./clipboard/index.js";
|
|||||||
import commands from "./commands/index.js";
|
import commands from "./commands/index.js";
|
||||||
import ChangeController from "./controllers/ChangeController.js";
|
import ChangeController from "./controllers/ChangeController.js";
|
||||||
import SelectionController from "./controllers/SelectionController.js";
|
import SelectionController from "./controllers/SelectionController.js";
|
||||||
import { createSelectionImposterFromClientRects } from "./selection/Imposter.js";
|
|
||||||
import { addEventListeners, removeEventListeners } from "./Event.js";
|
import { addEventListeners, removeEventListeners } from "./Event.js";
|
||||||
import {
|
import {
|
||||||
mapContentFragmentFromHTML,
|
mapContentFragmentFromHTML,
|
||||||
@@ -62,13 +61,6 @@ export class TextEditor extends EventTarget {
|
|||||||
*/
|
*/
|
||||||
#selectionController = null;
|
#selectionController = null;
|
||||||
|
|
||||||
/**
|
|
||||||
* Selection imposter keeps selection elements.
|
|
||||||
*
|
|
||||||
* @type {HTMLElement}
|
|
||||||
*/
|
|
||||||
#selectionImposterElement = null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Style defaults.
|
* Style defaults.
|
||||||
*
|
*
|
||||||
@@ -84,18 +76,26 @@ export class TextEditor extends EventTarget {
|
|||||||
*/
|
*/
|
||||||
#fixInsertCompositionText = false;
|
#fixInsertCompositionText = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canvas element that renders text.
|
||||||
|
*
|
||||||
|
* @type {HTMLCanvasElement}
|
||||||
|
*/
|
||||||
|
#canvas = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructor.
|
* Constructor.
|
||||||
*
|
*
|
||||||
* @param {HTMLElement} element
|
* @param {HTMLElement} element
|
||||||
|
* @param {HTMLCanvasElement} canvas
|
||||||
*/
|
*/
|
||||||
constructor(element, options) {
|
constructor(element, canvas, options) {
|
||||||
super();
|
super();
|
||||||
if (!(element instanceof HTMLElement))
|
if (!(element instanceof HTMLElement))
|
||||||
throw new TypeError("Invalid text editor element");
|
throw new TypeError("Invalid text editor element");
|
||||||
|
|
||||||
this.#element = element;
|
this.#element = element;
|
||||||
this.#selectionImposterElement = options?.selectionImposterElement;
|
this.#canvas = canvas;
|
||||||
this.#events = {
|
this.#events = {
|
||||||
blur: this.#onBlur,
|
blur: this.#onBlur,
|
||||||
focus: this.#onFocus,
|
focus: this.#onFocus,
|
||||||
@@ -114,7 +114,7 @@ export class TextEditor extends EventTarget {
|
|||||||
/**
|
/**
|
||||||
* Setups editor properties.
|
* Setups editor properties.
|
||||||
*/
|
*/
|
||||||
#setupElementProperties() {
|
#setupElementProperties(options) {
|
||||||
if (!this.#element.isContentEditable) {
|
if (!this.#element.isContentEditable) {
|
||||||
this.#element.contentEditable = "true";
|
this.#element.contentEditable = "true";
|
||||||
// In `jsdom` it isn't enough to set the attribute 'contentEditable'
|
// 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.ariaAutoComplete) this.#element.ariaAutoComplete = false;
|
||||||
if (!this.#element.ariaMultiLine) this.#element.ariaMultiLine = true;
|
if (!this.#element.ariaMultiLine) this.#element.ariaMultiLine = true;
|
||||||
this.#element.dataset.itype = "editor";
|
this.#element.dataset.itype = "editor";
|
||||||
|
if (options.shouldUpdatePositionOnScroll) {
|
||||||
|
this.#updatePositionFromCanvas();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -142,6 +145,86 @@ export class TextEditor extends EventTarget {
|
|||||||
this.#element.appendChild(this.#root);
|
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.
|
* Dispatchs a `change` event.
|
||||||
*
|
*
|
||||||
@@ -157,58 +240,9 @@ export class TextEditor extends EventTarget {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
#onStyleChange = (e) => {
|
#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));
|
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.
|
* On blur we create a new FakeSelection if there's any.
|
||||||
*
|
*
|
||||||
@@ -217,7 +251,6 @@ export class TextEditor extends EventTarget {
|
|||||||
#onBlur = (e) => {
|
#onBlur = (e) => {
|
||||||
this.#changeController.notifyImmediately();
|
this.#changeController.notifyImmediately();
|
||||||
this.#selectionController.saveSelection();
|
this.#selectionController.saveSelection();
|
||||||
this.#createSelectionImposter();
|
|
||||||
this.dispatchEvent(new FocusEvent(e.type, e));
|
this.dispatchEvent(new FocusEvent(e.type, e));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -231,9 +264,6 @@ export class TextEditor extends EventTarget {
|
|||||||
if (!this.#selectionController.restoreSelection()) {
|
if (!this.#selectionController.restoreSelection()) {
|
||||||
this.selectAll();
|
this.selectAll();
|
||||||
}
|
}
|
||||||
if (this.#selectionImposterElement) {
|
|
||||||
this.#selectionImposterElement.replaceChildren();
|
|
||||||
}
|
|
||||||
this.dispatchEvent(new FocusEvent(e.type, e));
|
this.dispatchEvent(new FocusEvent(e.type, e));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -553,8 +583,8 @@ export function setRoot(instance, root) {
|
|||||||
return instance;
|
return instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function create(element, options) {
|
export function create(element, canvas, options) {
|
||||||
return new TextEditor(element, { ...options });
|
return new TextEditor(element, canvas, { ...options });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCurrentStyle(instance) {
|
export function getCurrentStyle(instance) {
|
||||||
|
|||||||
@@ -1057,7 +1057,7 @@ export class SelectionController extends EventTarget {
|
|||||||
fragment.children.length === 1 &&
|
fragment.children.length === 1 &&
|
||||||
fragment.firstElementChild?.dataset?.inline === "force"
|
fragment.firstElementChild?.dataset?.inline === "force"
|
||||||
) {
|
) {
|
||||||
const collapseNode = fragment.lastElementChild.firstChild;
|
const collapseNode = fragment.firstElementChild.firstChild;
|
||||||
if (this.isInlineStart) {
|
if (this.isInlineStart) {
|
||||||
this.focusInline.before(...fragment.firstElementChild.children);
|
this.focusInline.before(...fragment.firstElementChild.children);
|
||||||
} else if (this.isInlineEnd) {
|
} else if (this.isInlineEnd) {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -7,9 +7,25 @@
|
|||||||
<title>Penpot - Text Editor Playground</title>
|
<title>Penpot - Text Editor Playground</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="playground">
|
<div id="playground">
|
||||||
|
<div id="info">
|
||||||
<form>
|
<form>
|
||||||
<fieldset>
|
<fieldset id="shape">
|
||||||
|
<legend>Shape</legend>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="position-x">x</label>
|
||||||
|
<input id="position-x" name="position-x" type="number" value="0">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="position-y">y</label>
|
||||||
|
<input id="position-y" name="position-y" type="number" value="0">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="rotation">rotation</label>
|
||||||
|
<input id="rotation" name="rotation" type="number" value="0">
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset id="styles">
|
||||||
<legend>Styles</legend>
|
<legend>Styles</legend>
|
||||||
<!-- Font -->
|
<!-- Font -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -98,7 +114,7 @@
|
|||||||
<input id="text-transform-lowercase" type="radio" name="text-transform" value="lowercase" />
|
<input id="text-transform-lowercase" type="radio" name="text-transform" value="lowercase" />
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset>
|
<fieldset id="debug">
|
||||||
<legend>Debug</legend>
|
<legend>Debug</legend>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="direction">Direction</label>
|
<label for="direction">Direction</label>
|
||||||
@@ -190,15 +206,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
<div id="app">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
|
||||||
|
Canvas
|
||||||
|
|
||||||
|
-->
|
||||||
|
<canvas id="canvas"></canvas>
|
||||||
<!--
|
<!--
|
||||||
|
|
||||||
Editor
|
Editor
|
||||||
|
|
||||||
-->
|
-->
|
||||||
<div class="text-editor-container align-top">
|
<div class="text-editor-container align-top">
|
||||||
<div
|
|
||||||
id="text-editor-selection-imposter"
|
|
||||||
class="text-editor-selection-imposter"></div>
|
|
||||||
<div
|
<div
|
||||||
class="text-editor-content"
|
class="text-editor-content"
|
||||||
contenteditable="true"
|
contenteditable="true"
|
||||||
@@ -208,353 +230,8 @@
|
|||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
autocapitalize="false"></div>
|
autocapitalize="false"></div>
|
||||||
</div>
|
</div>
|
||||||
<!--
|
|
||||||
|
|
||||||
Text output
|
|
||||||
|
|
||||||
-->
|
|
||||||
<canvas id="canvas"></canvas>
|
|
||||||
</div>
|
</div>
|
||||||
<script type="module">
|
</div>
|
||||||
import "./style.css";
|
<script src="./playground.js" type="module"></script>
|
||||||
import "./fonts.css";
|
|
||||||
import "./editor/TextEditor.css";
|
|
||||||
import { TextEditor } from "./editor/TextEditor";
|
|
||||||
import { SelectionControllerDebug } from "./editor/debug/SelectionControllerDebug";
|
|
||||||
import initWasmModule from './wasm/render_wasm.js';
|
|
||||||
import {
|
|
||||||
init, assignCanvas, render, setupInteraction, useShape, setShapeChildren, addTextShape, updateTextShape, hexToU32ARGB,getRandomInt, getRandomColor, getRandomFloat, addShapeSolidFill, addShapeSolidStrokeFill, storeFonts
|
|
||||||
} from './wasm/lib.js';
|
|
||||||
|
|
||||||
async function loadFontList() {
|
|
||||||
const response = await fetch('fonts/fonts.txt')
|
|
||||||
const text = await response.text()
|
|
||||||
const fonts = text.split('\n').filter(l => !!l)
|
|
||||||
return fonts
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFontStyleWeight(fontStyleName) {
|
|
||||||
if (fontStyleName.startsWith('Thin')) {
|
|
||||||
return 100
|
|
||||||
} else if (fontStyleName.startsWith('ExtraLight')) {
|
|
||||||
return 200
|
|
||||||
} else if (fontStyleName.startsWith('Light')) {
|
|
||||||
return 300
|
|
||||||
} else if (fontStyleName.startsWith('Regular')) {
|
|
||||||
return 400
|
|
||||||
} else if (fontStyleName.startsWith('Medium')) {
|
|
||||||
return 500
|
|
||||||
} else if (fontStyleName.startsWith('SemiBold')) {
|
|
||||||
return 600
|
|
||||||
} else if (fontStyleName.startsWith('Bold')) {
|
|
||||||
return 700
|
|
||||||
} else if (fontStyleName.startsWith('ExtraBold')) {
|
|
||||||
return 800
|
|
||||||
} else if (fontStyleName.startsWith('Black')) {
|
|
||||||
return 900
|
|
||||||
} else {
|
|
||||||
return 400
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadFonts() {
|
|
||||||
const fontData = new Map()
|
|
||||||
const fonts = await loadFontList()
|
|
||||||
for (const font of fonts) {
|
|
||||||
const response = await fetch(`fonts/${font}`)
|
|
||||||
const arrayBuffer = await response.arrayBuffer()
|
|
||||||
const [fontName, fontStyleNameAndExtension] = font.split('-')
|
|
||||||
const [fontStyleName, extension] = fontStyleNameAndExtension.split('.')
|
|
||||||
if (!fontData.has(fontName)) {
|
|
||||||
fontData.set(fontName, [])
|
|
||||||
}
|
|
||||||
const id = crypto.randomUUID()
|
|
||||||
const weight = getFontStyleWeight(fontStyleName)
|
|
||||||
const isItalic = (fontStyleName.endsWith('Italic'))
|
|
||||||
const currentFontData = fontData.get(fontName)
|
|
||||||
currentFontData.push({
|
|
||||||
id,
|
|
||||||
url: `fonts/${font}`,
|
|
||||||
name: fontName,
|
|
||||||
weight: weight,
|
|
||||||
style: isItalic ? 'italic' : 'normal',
|
|
||||||
arrayBuffer,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return fontData
|
|
||||||
}
|
|
||||||
|
|
||||||
const searchParams = new URLSearchParams(location.search);
|
|
||||||
const debug = searchParams.has("debug")
|
|
||||||
? searchParams.get("debug").split(",")
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const textEditorSelectionImposterElement = document.getElementById(
|
|
||||||
"text-editor-selection-imposter",
|
|
||||||
);
|
|
||||||
|
|
||||||
const textEditorElement = document.querySelector(".text-editor-content");
|
|
||||||
const textEditor = new TextEditor(textEditorElement, {
|
|
||||||
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]]',
|
|
||||||
},
|
|
||||||
selectionImposterElement: textEditorSelectionImposterElement,
|
|
||||||
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"),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const fontFamilyElement = document.getElementById("font-family");
|
|
||||||
const fontSizeElement = document.getElementById("font-size");
|
|
||||||
const fontWeightElement = document.getElementById("font-weight");
|
|
||||||
const fontStyleElement = document.getElementById("font-style");
|
|
||||||
|
|
||||||
const directionLTRElement = document.getElementById("direction-ltr");
|
|
||||||
const directionRTLElement = document.getElementById("direction-rtl");
|
|
||||||
|
|
||||||
const lineHeightElement = document.getElementById("line-height");
|
|
||||||
const letterSpacingElement = document.getElementById("letter-spacing");
|
|
||||||
|
|
||||||
const textAlignLeftElement = document.getElementById("text-align-left");
|
|
||||||
const textAlignCenterElement = document.getElementById("text-align-center");
|
|
||||||
const textAlignRightElement = document.getElementById("text-align-right");
|
|
||||||
const textAlignJustifyElement = document.getElementById("text-align-justify");
|
|
||||||
|
|
||||||
const fonts = await loadFonts()
|
|
||||||
console.log(fonts)
|
|
||||||
const fontFamiliesFragment = document.createDocumentFragment()
|
|
||||||
for (const [font, fontData] of fonts) {
|
|
||||||
const fontFamilyOptionElement = document.createElement('option')
|
|
||||||
fontFamilyOptionElement.value = font
|
|
||||||
fontFamilyOptionElement.textContent = font
|
|
||||||
fontFamiliesFragment.appendChild(fontFamilyOptionElement)
|
|
||||||
}
|
|
||||||
fontFamilyElement.replaceChildren(fontFamiliesFragment)
|
|
||||||
|
|
||||||
function onDirectionChange(e) {
|
|
||||||
if (debug.includes("events")) {
|
|
||||||
console.log(e);
|
|
||||||
}
|
|
||||||
if (e.target.checked) {
|
|
||||||
textEditor.applyStylesToSelection({
|
|
||||||
direction: e.target.value,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
directionLTRElement.addEventListener("change", onDirectionChange);
|
|
||||||
directionRTLElement.addEventListener("change", onDirectionChange);
|
|
||||||
|
|
||||||
function onTextAlignChange(e) {
|
|
||||||
if (debug.includes("events")) {
|
|
||||||
console.log(e);
|
|
||||||
}
|
|
||||||
if (e.target.checked) {
|
|
||||||
textEditor.applyStylesToSelection({
|
|
||||||
"text-align": e.target.value,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
textAlignLeftElement.addEventListener("change", onTextAlignChange);
|
|
||||||
textAlignCenterElement.addEventListener("change", onTextAlignChange);
|
|
||||||
textAlignRightElement.addEventListener("change", onTextAlignChange);
|
|
||||||
textAlignJustifyElement.addEventListener("change", onTextAlignChange);
|
|
||||||
|
|
||||||
fontFamilyElement.addEventListener("change", (e) => {
|
|
||||||
if (debug.includes("events")) {
|
|
||||||
console.log(e);
|
|
||||||
}
|
|
||||||
const fontStyles = fonts.get(e.target.value)
|
|
||||||
console.log('fontStyles', fontStyles)
|
|
||||||
textEditor.applyStylesToSelection({
|
|
||||||
"font-family": e.target.value,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
fontWeightElement.addEventListener("change", (e) => {
|
|
||||||
if (debug.includes("events")) {
|
|
||||||
console.log(e);
|
|
||||||
}
|
|
||||||
textEditor.applyStylesToSelection({
|
|
||||||
"font-weight": e.target.value,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
fontSizeElement.addEventListener("change", (e) => {
|
|
||||||
if (debug.includes("events")) {
|
|
||||||
console.log(e);
|
|
||||||
}
|
|
||||||
textEditor.applyStylesToSelection({
|
|
||||||
"font-size": e.target.value,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
lineHeightElement.addEventListener("change", (e) => {
|
|
||||||
if (debug.includes("events")) {
|
|
||||||
console.log(e);
|
|
||||||
}
|
|
||||||
textEditor.applyStylesToSelection({
|
|
||||||
"line-height": e.target.value,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
letterSpacingElement.addEventListener("change", (e) => {
|
|
||||||
if (debug.includes("events")) {
|
|
||||||
console.log(e);
|
|
||||||
}
|
|
||||||
textEditor.applyStylesToSelection({
|
|
||||||
"letter-spacing": e.target.value,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
fontStyleElement.addEventListener("change", (e) => {
|
|
||||||
if (debug.includes("events")) {
|
|
||||||
console.log(e);
|
|
||||||
}
|
|
||||||
textEditor.applyStylesToSelection({
|
|
||||||
"font-style": e.target.value,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function formatHTML(html, options) {
|
|
||||||
const spaces = options?.spaces ?? 4;
|
|
||||||
let indent = 0;
|
|
||||||
return html.replace(/<\/?(.*?)>/g, (fullMatch) => {
|
|
||||||
let str = fullMatch + "\n";
|
|
||||||
if (fullMatch.startsWith("</")) {
|
|
||||||
--indent;
|
|
||||||
str = " ".repeat(indent * spaces) + str;
|
|
||||||
} else {
|
|
||||||
str = " ".repeat(indent * spaces) + str;
|
|
||||||
++indent;
|
|
||||||
if (fullMatch === "<br>") --indent;
|
|
||||||
}
|
|
||||||
return str;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const fontSize = 14;
|
|
||||||
const children = [];
|
|
||||||
const uuid = crypto.randomUUID();
|
|
||||||
children.push(uuid);
|
|
||||||
const outputElement = document.getElementById("output");
|
|
||||||
textEditorElement.addEventListener("input", (e) => {
|
|
||||||
if (debug.includes("events")) {
|
|
||||||
console.log(e);
|
|
||||||
}
|
|
||||||
outputElement.textContent = formatHTML(textEditor.element.innerHTML);
|
|
||||||
});
|
|
||||||
|
|
||||||
textEditor.addEventListener("needslayout", (e) => {
|
|
||||||
useShape(uuid);
|
|
||||||
updateTextShape(textEditor.root, fonts);
|
|
||||||
render();
|
|
||||||
})
|
|
||||||
|
|
||||||
textEditor.addEventListener("stylechange", (e) => {
|
|
||||||
if (debug.includes("events")) {
|
|
||||||
console.log(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");
|
|
||||||
|
|
||||||
fontFamilyElement.value = fontFamily;
|
|
||||||
fontSizeElement.value = fontSize;
|
|
||||||
fontStyleElement.value = fontStyle;
|
|
||||||
fontWeightElement.value = fontWeight;
|
|
||||||
|
|
||||||
const textAlign = e.detail.getPropertyValue("text-align");
|
|
||||||
textAlignLeftElement.checked = textAlign === "left";
|
|
||||||
textAlignCenterElement.checked = textAlign === "center";
|
|
||||||
textAlignRightElement.checked = textAlign === "right";
|
|
||||||
textAlignJustifyElement.checked = textAlign === "justify";
|
|
||||||
|
|
||||||
const direction = e.detail.getPropertyValue("direction");
|
|
||||||
directionLTRElement.checked = direction === "ltr";
|
|
||||||
directionRTLElement.checked = direction === "rtl";
|
|
||||||
});
|
|
||||||
|
|
||||||
const canvas = document.getElementById("canvas");
|
|
||||||
canvas.width = window.innerWidth;
|
|
||||||
canvas.height = window.innerHeight;
|
|
||||||
|
|
||||||
const MIN_LINES = 1;
|
|
||||||
const MAX_LINES = 5;
|
|
||||||
const MIN_WORDS = 1;
|
|
||||||
const MAX_WORDS = 10;
|
|
||||||
|
|
||||||
initWasmModule().then(Module => {
|
|
||||||
init(Module);
|
|
||||||
assignCanvas(canvas);
|
|
||||||
Module._set_canvas_background(hexToU32ARGB("#FABADA", 1));
|
|
||||||
Module._set_view(1, 0, 0);
|
|
||||||
Module._init_shapes_pool(1);
|
|
||||||
setupInteraction(canvas);
|
|
||||||
|
|
||||||
canvas.addEventListener('click', (e) => {
|
|
||||||
console.log('click', e.type, e);
|
|
||||||
useShape(uuid);
|
|
||||||
const caretPosition = Module._get_caret_position_at(e.offsetX, e.offsetY);
|
|
||||||
console.log('caretPosition', caretPosition);
|
|
||||||
})
|
|
||||||
|
|
||||||
storeFonts(fonts)
|
|
||||||
|
|
||||||
console.log("text shape", uuid);
|
|
||||||
useShape(uuid);
|
|
||||||
Module._set_parent(0, 0, 0, 0);
|
|
||||||
Module._set_shape_type(5);
|
|
||||||
|
|
||||||
const x1 = 0;
|
|
||||||
const y1 = 0;
|
|
||||||
const width = canvas.width;
|
|
||||||
const height = canvas.height;
|
|
||||||
Module._set_shape_selrect(x1, y1, x1 + width, y1 + height);
|
|
||||||
|
|
||||||
addTextShape("", fonts);
|
|
||||||
|
|
||||||
useShape("00000000-0000-0000-0000-000000000000");
|
|
||||||
setShapeChildren(children);
|
|
||||||
render()
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
578
frontend/text-editor/src/playground.js
Normal file
578
frontend/text-editor/src/playground.js
Normal file
@@ -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();
|
||||||
129
frontend/text-editor/src/playground/color.js
Normal file
129
frontend/text-editor/src/playground/color.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
frontend/text-editor/src/playground/fill.js
Normal file
39
frontend/text-editor/src/playground/fill.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
177
frontend/text-editor/src/playground/font.js
Normal file
177
frontend/text-editor/src/playground/font.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
244
frontend/text-editor/src/playground/geom.js
Normal file
244
frontend/text-editor/src/playground/geom.js
Normal file
@@ -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})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
94
frontend/text-editor/src/playground/shape.js
Normal file
94
frontend/text-editor/src/playground/shape.js
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
14
frontend/text-editor/src/playground/style.js
Normal file
14
frontend/text-editor/src/playground/style.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
250
frontend/text-editor/src/playground/text.js
Normal file
250
frontend/text-editor/src/playground/text.js
Normal file
@@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
frontend/text-editor/src/playground/uuid.js
Normal file
47
frontend/text-editor/src/playground/uuid.js
Normal file
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
frontend/text-editor/src/playground/viewport.js
Normal file
44
frontend/text-editor/src/playground/viewport.js
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
52
frontend/text-editor/src/playground/wasm.js
Normal file
52
frontend/text-editor/src/playground/wasm.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +1,49 @@
|
|||||||
:root {
|
:root {
|
||||||
background-color: #333;
|
width: 100dvw;
|
||||||
color: #eee;
|
height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 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 {
|
canvas {
|
||||||
width: 100cqw;
|
width: 100%;
|
||||||
|
height: max-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-editor-container {
|
.text-editor-container {
|
||||||
background-color: white;
|
position: fixed;
|
||||||
|
min-width: 1rem;
|
||||||
|
min-height: 1rem;
|
||||||
|
transform-origin: top left;
|
||||||
|
color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.playground {
|
.text-editor-content {
|
||||||
display: grid;
|
outline: 1px solid red !important;
|
||||||
max-width: 1280px;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.
|
/// This is called from JS after the WebGL context has been created.
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub extern "C" fn init(width: i32, height: i32) {
|
pub extern "C" fn init(width: i32, height: i32) {
|
||||||
|
|||||||
@@ -945,7 +945,17 @@ impl RenderState {
|
|||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
performance::begin_measure!("process_animation_frame");
|
performance::begin_measure!("process_animation_frame");
|
||||||
if self.render_in_progress {
|
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();
|
self.flush_and_submit();
|
||||||
|
|
||||||
if self.render_in_progress {
|
if self.render_in_progress {
|
||||||
|
|||||||
@@ -223,10 +223,15 @@ fn draw_text(
|
|||||||
let mut group_offset_y = global_offset_y;
|
let mut group_offset_y = global_offset_y;
|
||||||
let group_len = paragraph_builder_group.len();
|
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();
|
let mut paragraph = paragraph_builder.build();
|
||||||
paragraph.layout(paragraph_width);
|
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);
|
let xy = (shape.selrect().x(), shape.selrect().y() + group_offset_y);
|
||||||
paragraph.paint(canvas, xy);
|
paragraph.paint(canvas, xy);
|
||||||
|
|
||||||
@@ -234,20 +239,19 @@ fn draw_text(
|
|||||||
render_text_decoration(canvas, ¶graph, paragraph_builder, line_metrics, xy);
|
render_text_decoration(canvas, ¶graph, paragraph_builder, line_metrics, xy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::collapsible_else_if)]
|
||||||
if group_len == 1 {
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_text_decorations(
|
fn draw_text_decorations(
|
||||||
canvas: &Canvas,
|
canvas: &Canvas,
|
||||||
|
|||||||
@@ -1130,8 +1130,7 @@ impl Shape {
|
|||||||
if let Some(path) = shape_type.path_mut() {
|
if let Some(path) = shape_type.path_mut() {
|
||||||
path.transform(transform);
|
path.transform(transform);
|
||||||
}
|
}
|
||||||
}
|
} else if let Type::Text(text) = &mut self.shape_type {
|
||||||
if let Type::Text(text) = &mut self.shape_type {
|
|
||||||
text.transform(transform);
|
text.transform(transform);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)]
|
#[derive(Debug)]
|
||||||
pub struct TextContentLayoutResult(
|
pub struct TextContentLayoutResult(
|
||||||
Vec<ParagraphBuilderGroup>,
|
Vec<ParagraphBuilderGroup>,
|
||||||
@@ -95,7 +119,7 @@ pub struct TextContentLayoutResult(
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct TextContentLayout {
|
pub struct TextContentLayout {
|
||||||
pub paragraph_builders: Vec<ParagraphBuilderGroup>,
|
pub paragraph_builders: Vec<ParagraphBuilderGroup>,
|
||||||
pub paragraphs: Vec<Vec<skia_safe::textlayout::Paragraph>>,
|
pub paragraphs: Vec<Vec<skia::textlayout::Paragraph>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Clone for TextContentLayout {
|
impl Clone for TextContentLayout {
|
||||||
@@ -245,18 +269,49 @@ impl TextContent {
|
|||||||
self.bounds = Rect::from_ltrb(p1.x, p1.y, p2.x, p2.y);
|
self.bounds = Rect::from_ltrb(p1.x, p1.y, p2.x, p2.y);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_caret_position_at(&self, point: &Point) -> Option<PositionWithAffinity> {
|
pub fn get_caret_position_at(&self, point: &Point) -> Option<TextPositionWithAffinity> {
|
||||||
let mut offset_y = 0.0;
|
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 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 {
|
if point.y > start_y && point.y < end_y {
|
||||||
let position_with_affinity = paragraph.get_glyph_position_at_coordinate(*point);
|
let position_with_affinity =
|
||||||
return Some(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;
|
||||||
}
|
}
|
||||||
offset_y += paragraph.height();
|
computed_position += length;
|
||||||
|
}
|
||||||
|
return Some(TextPositionWithAffinity::new(
|
||||||
|
position_with_affinity,
|
||||||
|
paragraph_index,
|
||||||
|
leaf_index,
|
||||||
|
leaf_offset,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
offset_y += layout_paragraph.height();
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ use skia_safe::{self as skia, textlayout::FontCollection, Path, Point};
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
mod shapes_pool;
|
mod shapes_pool;
|
||||||
|
mod text_editor;
|
||||||
pub use shapes_pool::*;
|
pub use shapes_pool::*;
|
||||||
|
pub use text_editor::*;
|
||||||
|
|
||||||
use crate::render::RenderState;
|
use crate::render::RenderState;
|
||||||
use crate::shapes::Shape;
|
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.
|
/// must not be shared between different Web Workers.
|
||||||
pub(crate) struct State {
|
pub(crate) struct State {
|
||||||
pub render_state: RenderState,
|
pub render_state: RenderState,
|
||||||
|
pub text_editor_state: TextEditorState,
|
||||||
pub current_id: Option<Uuid>,
|
pub current_id: Option<Uuid>,
|
||||||
pub shapes: ShapesPool,
|
pub shapes: ShapesPool,
|
||||||
pub modifiers: HashMap<Uuid, skia::Matrix>,
|
pub modifiers: HashMap<Uuid, skia::Matrix>,
|
||||||
@@ -30,6 +33,7 @@ impl State {
|
|||||||
pub fn new(width: i32, height: i32) -> Self {
|
pub fn new(width: i32, height: i32) -> Self {
|
||||||
State {
|
State {
|
||||||
render_state: RenderState::new(width, height),
|
render_state: RenderState::new(width, height),
|
||||||
|
text_editor_state: TextEditorState::new(),
|
||||||
current_id: None,
|
current_id: None,
|
||||||
shapes: ShapesPool::new(),
|
shapes: ShapesPool::new(),
|
||||||
modifiers: HashMap::new(),
|
modifiers: HashMap::new(),
|
||||||
@@ -50,6 +54,16 @@ impl State {
|
|||||||
&self.render_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) {
|
pub fn render_from_cache(&mut self) {
|
||||||
self.render_state
|
self.render_state
|
||||||
.render_from_cache(&self.shapes, &self.modifiers, &self.structure);
|
.render_from_cache(&self.shapes, &self.modifiers, &self.structure);
|
||||||
|
|||||||
103
render-wasm/src/state/text_editor.rs
Normal file
103
render-wasm/src/state/text_editor.rs
Normal file
@@ -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<TextNodePosition>,
|
||||||
|
offset: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextPosition {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
node: None,
|
||||||
|
offset: -1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set(&mut self, node: Option<TextNodePosition>, 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<TextNodePosition>, offset: i32) {
|
||||||
|
self.focus.set(node, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_anchor(&mut self, node: Option<TextNodePosition>, offset: i32) {
|
||||||
|
self.anchor.set(node, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set(&mut self, node: Option<TextNodePosition>, 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ use crate::shapes::{
|
|||||||
self, GrowType, TextAlign, TextDecoration, TextDirection, TextTransform, Type,
|
self, GrowType, TextAlign, TextDecoration, TextDirection, TextTransform, Type,
|
||||||
};
|
};
|
||||||
use crate::utils::{uuid_from_u32, uuid_from_u32_quartet};
|
use crate::utils::{uuid_from_u32, uuid_from_u32_quartet};
|
||||||
use crate::{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::<RawTextLeaf>();
|
const RAW_LEAF_DATA_SIZE: usize = std::mem::size_of::<RawTextLeaf>();
|
||||||
const RAW_PARAGRAPH_DATA_SIZE: usize = std::mem::size_of::<RawParagraphData>();
|
const RAW_PARAGRAPH_DATA_SIZE: usize = std::mem::size_of::<RawParagraphData>();
|
||||||
@@ -370,7 +370,7 @@ pub extern "C" fn update_shape_text_layout_for_all() {
|
|||||||
|
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub extern "C" fn get_caret_position_at(x: f32, y: f32) -> i32 {
|
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 {
|
if let Type::Text(text_content) = &shape.shape_type {
|
||||||
let mut matrix = Matrix::new_identity();
|
let mut matrix = Matrix::new_identity();
|
||||||
let shape_matrix = shape.get_concatenated_matrix(&state.shapes);
|
let shape_matrix = shape.get_concatenated_matrix(&state.shapes);
|
||||||
@@ -384,11 +384,11 @@ pub extern "C" fn get_caret_position_at(x: f32, y: f32) -> i32 {
|
|||||||
if let Some(position_with_affinity) =
|
if let Some(position_with_affinity) =
|
||||||
text_content.get_caret_position_at(&mapped_point)
|
text_content.get_caret_position_at(&mapped_point)
|
||||||
{
|
{
|
||||||
return position_with_affinity.position;
|
return position_with_affinity.position_with_affinity.position;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} 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
|
-1
|
||||||
|
|||||||
Reference in New Issue
Block a user