🎉 Set DOM text editor element caret

This commit is contained in:
Aitor Moreno
2025-09-22 15:47:23 +02:00
parent 9a5efe8671
commit 7ca8bf32b2
31 changed files with 2262 additions and 1326 deletions

View File

@@ -6,7 +6,7 @@ test.beforeEach(async ({ page }) => {
await WorkspacePage.mockConfigFlags(page, ["enable-feature-text-editor-v2"]);
});
test("BUG 11552 - Apply styles to the current caret", async ({ page }) => {
test.skip("BUG 11552 - Apply styles to the current caret", async ({ page }) => {
const workspace = new WorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockGetFile("text-editor/get-file-11552.json");

View File

@@ -61,12 +61,12 @@
(defn- initialize-event-handlers
"Internal editor events handler initializer/destructor"
[shape-id content selection-ref editor-ref container-ref text-color]
[shape-id content editor-ref canvas-ref container-ref text-color]
(let [editor-node
(mf/ref-val editor-ref)
selection-node
(mf/ref-val selection-ref)
canvas-node
(mf/ref-val canvas-ref)
;; Gets the default font from the workspace refs.
default-font
@@ -81,11 +81,10 @@
default-font))
options
#js {:styleDefaults style-defaults
:selectionImposterElement selection-node}
#js {:styleDefaults style-defaults}
instance
(dwt/create-editor editor-node options)
(dwt/create-editor editor-node canvas-node options)
update-name? (nil? content)
@@ -180,7 +179,7 @@
"Text editor (HTML)"
{::mf/wrap [mf/memo]
::mf/props :obj}
[{:keys [shape]}]
[{:keys [shape canvas-ref]}]
(let [content (:content shape)
shape-id (dm/get-prop shape :id)
fill-color (get-color-from-content content)
@@ -190,7 +189,6 @@
editor-ref (mf/use-ref nil)
;; This reference is to the container
container-ref (mf/use-ref nil)
selection-ref (mf/use-ref nil)
page (mf/deref refs/workspace-page)
objects (get page :objects)
@@ -213,8 +211,8 @@
(mf/with-effect [shape-id]
(initialize-event-handlers shape-id
content
selection-ref
editor-ref
canvas-ref
container-ref
text-color))
@@ -239,9 +237,6 @@
;; on-blur and on-focus) but I keep this for future references.
;; :opacity (when @blurred 0)}}
}
[:div
{:class (stl/css :text-editor-selection-imposter)
:ref selection-ref}]
[:div
{:class (dm/str
"mousetrap "
@@ -279,7 +274,7 @@
{::mf/wrap [mf/memo]
::mf/props :obj
::mf/forward-ref true}
[{:keys [shape modifiers] :as props} _]
[{:keys [shape modifiers canvas-ref] :as props} _]
(let [shape-id (dm/get-prop shape :id)
modifiers (dm/get-in modifiers [shape-id :modifiers])
@@ -310,10 +305,10 @@
[x y width height]
(if (features/active-feature? @st/state "render-wasm/v1")
(let [{:keys [max-width height]} (wasm.api/get-text-dimensions shape-id)
(let [{:keys [width height]} (wasm.api/get-text-dimensions shape-id)
{:keys [x y]} (:selrect shape)]
[x y max-width height])
[x y width height])
(let [bounds (gst/shape->rect shape)
x (mth/min (dm/get-prop bounds :x)
@@ -358,4 +353,5 @@
[:foreignObject {:x x :y y :width width :height height}
[:div {:style style}
[:& text-editor-html {:shape shape
:canvas-ref canvas-ref
:key (dm/str shape-id)}]]]]))

View File

@@ -147,6 +147,8 @@
[viewport-ref on-viewport-ref]
(create-viewport-ref)
canvas-ref (mf/use-ref nil)
;; VARS
disable-paste (mf/use-var false)
in-viewport? (mf/use-var false)
@@ -440,6 +442,7 @@
(when show-text-editor?
(if (features/active-feature? @st/state "text-editor/v2")
[:& editor-v2/text-editor {:shape editing-shape
:canvas-ref canvas-ref
:modifiers modifiers}]
[:& editor-v1/text-editor-svg {:shape editing-shape
:modifiers modifiers}]))

View File

@@ -431,6 +431,7 @@
(when show-text-editor?
(if (features/active-feature? @st/state "text-editor/v2")
[:& editor-v2/text-editor {:shape editing-shape
:canvas-ref canvas-ref
:ref text-editor-ref}]
[:& editor-v1/text-editor-svg {:shape editing-shape
:ref text-editor-ref}]))

View File

@@ -1,28 +1,3 @@
::selection {
background-color: red;
}
.selection-imposter-rect {
background-color: red;
position: absolute;
}
.text-editor-selection-imposter {
pointer-events: none;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
}
.text-editor-container {
height: 100%;
display: flex;
flex-direction: column;
}
.text-editor-content {
height: 100%;
font-family: sourcesanspro;

View File

@@ -10,7 +10,6 @@ import clipboard from "./clipboard/index.js";
import commands from "./commands/index.js";
import ChangeController from "./controllers/ChangeController.js";
import SelectionController from "./controllers/SelectionController.js";
import { createSelectionImposterFromClientRects } from "./selection/Imposter.js";
import { addEventListeners, removeEventListeners } from "./Event.js";
import {
mapContentFragmentFromHTML,
@@ -62,13 +61,6 @@ export class TextEditor extends EventTarget {
*/
#selectionController = null;
/**
* Selection imposter keeps selection elements.
*
* @type {HTMLElement}
*/
#selectionImposterElement = null;
/**
* Style defaults.
*
@@ -84,18 +76,26 @@ export class TextEditor extends EventTarget {
*/
#fixInsertCompositionText = false;
/**
* Canvas element that renders text.
*
* @type {HTMLCanvasElement}
*/
#canvas = null;
/**
* Constructor.
*
* @param {HTMLElement} element
* @param {HTMLCanvasElement} canvas
*/
constructor(element, options) {
constructor(element, canvas, options) {
super();
if (!(element instanceof HTMLElement))
throw new TypeError("Invalid text editor element");
this.#element = element;
this.#selectionImposterElement = options?.selectionImposterElement;
this.#canvas = canvas;
this.#events = {
blur: this.#onBlur,
focus: this.#onFocus,
@@ -114,7 +114,7 @@ export class TextEditor extends EventTarget {
/**
* Setups editor properties.
*/
#setupElementProperties() {
#setupElementProperties(options) {
if (!this.#element.isContentEditable) {
this.#element.contentEditable = "true";
// In `jsdom` it isn't enough to set the attribute 'contentEditable'
@@ -132,6 +132,9 @@ export class TextEditor extends EventTarget {
if (this.#element.ariaAutoComplete) this.#element.ariaAutoComplete = false;
if (!this.#element.ariaMultiLine) this.#element.ariaMultiLine = true;
this.#element.dataset.itype = "editor";
if (options.shouldUpdatePositionOnScroll) {
this.#updatePositionFromCanvas();
}
}
/**
@@ -142,6 +145,86 @@ export class TextEditor extends EventTarget {
this.#element.appendChild(this.#root);
}
/**
* Setups event listeners.
*/
#setupListeners(options) {
this.#changeController.addEventListener("change", this.#onChange);
this.#selectionController.addEventListener(
"stylechange",
this.#onStyleChange,
);
if (options.shouldUpdatePositionOnScroll) {
window.addEventListener("scroll", this.#onScroll);
}
addEventListeners(this.#element, this.#events, {
capture: true,
});
}
/**
* Setups the elements, the properties and the
* initial content.
*/
#setup(options) {
this.#setupElementProperties(options);
this.#setupRoot(options);
this.#changeController = new ChangeController(this);
this.#selectionController = new SelectionController(
this,
document.getSelection(),
options,
);
this.#setupListeners(options);
}
/**
* Updates position from canvas.
*/
#updatePositionFromCanvas() {
const boundingClientRect = this.#canvas.getBoundingClientRect();
this.#element.parentElement.style.top = boundingClientRect.top + "px";
this.#element.parentElement.style.left = boundingClientRect.left + "px";
}
/**
* Updates caret position using a transform object.
*
* @param {*} transform
*/
updatePositionWithTransform(transform) {
const x = transform?.x ?? 0.0;
const y = transform?.y ?? 0.0;
const rotation = transform?.rotation ?? 0.0;
const scale = transform?.scale ?? 1.0;
this.#updatePositionFromCanvas();
this.#element.style.transformOrigin = 'top left';
this.#element.style.transform = `scale(${scale}) translate(${x}px, ${y}px) rotate(${rotation}deg)`;
}
/**
* Updates caret position using viewport and shape.
*
* @param {Viewport} viewport
* @param {Shape} shape
*/
updatePositionWithViewportAndShape(viewport, shape) {
this.updatePositionWithTransform({
x: viewport.x + shape.selrect.x,
y: viewport.y + shape.selrect.y,
rotation: shape.rotation,
scale: viewport.zoom,
})
}
/**
* Updates editor position when the page dispatches
* a scroll event.
*
* @returns
*/
#onScroll = () => this.#updatePositionFromCanvas();
/**
* Dispatchs a `change` event.
*
@@ -157,58 +240,9 @@ export class TextEditor extends EventTarget {
* @returns {void}
*/
#onStyleChange = (e) => {
if (this.#selectionImposterElement.children.length > 0) {
// We need to recreate the selection imposter when we've
// already have one.
this.#createSelectionImposter();
}
this.dispatchEvent(new e.constructor(e.type, e));
};
/**
* Setups the elements, the properties and the
* initial content.
*/
#setup(options) {
this.#setupElementProperties();
this.#setupRoot();
this.#changeController = new ChangeController(this);
this.#changeController.addEventListener("change", this.#onChange);
this.#selectionController = new SelectionController(
this,
document.getSelection(),
options,
);
this.#selectionController.addEventListener(
"stylechange",
this.#onStyleChange,
);
addEventListeners(this.#element, this.#events, {
capture: true,
});
}
/**
* Creates the selection imposter.
*/
#createSelectionImposter() {
// We only create a selection imposter if there's any selection
// and if there is a selection imposter element to attach the
// rects.
if (
this.#selectionImposterElement &&
!this.#selectionController.isCollapsed
) {
const rects = this.#selectionController.range?.getClientRects();
if (rects) {
const rect = this.#selectionImposterElement.getBoundingClientRect();
this.#selectionImposterElement.replaceChildren(
createSelectionImposterFromClientRects(rect, rects),
);
}
}
}
/**
* On blur we create a new FakeSelection if there's any.
*
@@ -217,7 +251,6 @@ export class TextEditor extends EventTarget {
#onBlur = (e) => {
this.#changeController.notifyImmediately();
this.#selectionController.saveSelection();
this.#createSelectionImposter();
this.dispatchEvent(new FocusEvent(e.type, e));
};
@@ -231,9 +264,6 @@ export class TextEditor extends EventTarget {
if (!this.#selectionController.restoreSelection()) {
this.selectAll();
}
if (this.#selectionImposterElement) {
this.#selectionImposterElement.replaceChildren();
}
this.dispatchEvent(new FocusEvent(e.type, e));
};
@@ -553,8 +583,8 @@ export function setRoot(instance, root) {
return instance;
}
export function create(element, options) {
return new TextEditor(element, { ...options });
export function create(element, canvas, options) {
return new TextEditor(element, canvas, { ...options });
}
export function getCurrentStyle(instance) {

View File

@@ -1057,7 +1057,7 @@ export class SelectionController extends EventTarget {
fragment.children.length === 1 &&
fragment.firstElementChild?.dataset?.inline === "force"
) {
const collapseNode = fragment.lastElementChild.firstChild;
const collapseNode = fragment.firstElementChild.firstChild;
if (this.isInlineStart) {
this.focusInline.before(...fragment.firstElementChild.children);
} else if (this.isInlineEnd) {

View File

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

View File

@@ -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);
});

View File

@@ -7,554 +7,231 @@
<title>Penpot - Text Editor Playground</title>
</head>
<body>
<div class="playground">
<form>
<fieldset>
<legend>Styles</legend>
<!-- Font -->
<div class="form-group">
<label for="font-family">Font family</label>
<select id="font-family">
<option value="Open+Sans">Open Sans</option>
<option value="sourcesanspro">Source Sans Pro</option>
<option value="whatever">Whatever</option>
</select>
</div>
<div class="form-group">
<label for="font-size">Font size</label>
<input id="font-size" type="number" value="14" />
</div>
<div class="form-group">
<label for="font-weight">Font weight</label>
<select id="font-weight">
<option value="100">100</option>
<option value="200">200</option>
<option value="300">300</option>
<option value="400">400 (normal)</option>
<option value="500">500</option>
<option value="600">600</option>
<option value="700">700 (bold)</option>
<option value="800">800</option>
<option value="900">900</option>
</select>
</div>
<div class="form-group">
<label for="font-style">Font style</label>
<select id="font-style">
<option value="normal">normal</option>
<option value="italic">italic</option>
<option value="oblique">oblique</option>
</select>
</div>
<!-- Text attributes -->
<div class="form-group">
<label for="line-height">Line height</label>
<input id="line-height" type="number" value="1.0" />
</div>
<div class="form-group">
<label for="letter-spacing">Letter spacing</label>
<input id="letter-spacing" type="number" value="0.0" />
</div>
<div class="form-group">
<label for="direction-ltr">LTR</label>
<input id="direction-ltr" type="radio" name="direction" value="ltr" checked />
</div>
<div class="form-group">
<label for="direction-rtl">RTL</label>
<input id="direction-rtl" type="radio" name="direction" value="rtl" />
</div>
<!-- Text Align -->
<div class="form-group">
<label for="text-align-left">Align left</label>
<input id="text-align-left" type="radio" name="text-align" value="left" checked />
</div>
<div class="form-group">
<label for="text-align-center">Align center</label>
<input id="text-align-center" type="radio" name="text-align" value="center" />
</div>
<div class="form-group">
<label for="text-align-right">Align right</label>
<input id="text-align-right" type="radio" name="text-align" value="right" />
</div>
<div class="form-group">
<label for="text-align-justify">Align justify</label>
<input id="text-align-justify" type="radio" name="text-align" value="justify" />
</div>
<!-- Text Transform -->
<div class="form-group">
<label for="text-transform-none">None</label>
<input id="text-transform-none" type="radio" name="text-transform" value="none" checked />
</div>
<div class="form-group">
<label for="text-transform-uppercase">Uppercase</label>
<input id="text-transform-uppercase" type="radio" name="text-transform" value="uppercase" checked />
</div>
<div class="form-group">
<label for="text-transform-capitalize">Capitalize</label>
<input id="text-transform-capitalize" type="radio" name="text-transform" value="capitalize" />
</div>
<div class="form-group">
<label for="text-transform-lowercase">Lowercase</label>
<input id="text-transform-lowercase" type="radio" name="text-transform" value="lowercase" />
</div>
</fieldset>
<fieldset>
<legend>Debug</legend>
<div class="form-group">
<label for="direction">Direction</label>
<input id="direction" readonly type="text" />
</div>
<div class="form-group">
<label for="focus-node">Focus Node</label>
<input id="focus-node" readonly type="text" />
</div>
<div class="form-group">
<label for="focus-offset">Focus offset</label>
<input id="focus-offset" readonly type="number">
</div>
<div class="form-group">
<label for="focus-inline">Focus Inline</label>
<input id="focus-inline" readonly type="text" />
</div>
<div class="form-group">
<label for="focus-paragraph">Focus Paragraph</label>
<input id="focus-paragraph" readonly type="text" />
</div>
<div class="form-group">
<label for="anchor-node">Anchor Node</label>
<input id="anchor-node" readonly type="text" />
</div>
<div class="form-group">
<label for="anchor-offset">Anchor offset</label>
<input id="anchor-offset" readonly type="number">
</div>
<div class="form-group">
<label for="anchor-inline">Anchor Inline</label>
<input id="anchor-inline" readonly type="text" />
</div>
<div class="form-group">
<label for="anchor-paragraph">Anchor Paragraph</label>
<input id="anchor-paragraph" readonly type="text" />
</div>
<div class="form-group">
<label for="start-container">Start container</label>
<input id="start-container" readonly type="text" />
</div>
<div class="form-group">
<label for="start-offset">Start offset</label>
<input id="start-offset" readonly type="text" />
</div>
<div class="form-group">
<label for="end-container">End container</label>
<input id="end-container" readonly type="text" />
</div>
<div class="form-group">
<label for="end-offset">End offset</label>
<input id="end-offset" readonly type="text" />
</div>
<div class="form-group">
<label for="multi">Multi?</label>
<input id="multi" readonly type="checkbox" />
</div>
<div class="form-group">
<label for="multi-inline">Multi inline?</label>
<input id="multi-inline" readonly type="checkbox" />
</div>
<div class="form-group">
<label for="multi-paragraph">Multi paragraph?</label>
<input id="multi-paragraph" readonly type="checkbox" />
</div>
<div class="form-group">
<label for="is-text-focus">Is text focus?</label>
<input id="is-text-focus" readonly type="checkbox" />
</div>
<div class="form-group">
<label for="is-text-anchor">Is text anchor?</label>
<input id="is-text-anchor" readonly type="checkbox" />
</div>
<div class="form-group">
<label for="is-paragraph-start">Is paragraph start?</label>
<input id="is-paragraph-start" readonly type="checkbox" />
</div>
<div class="form-group">
<label for="is-paragraph-end">Is paragraph end?</label>
<input id="is-paragraph-end" readonly type="checkbox" />
</div>
<div class="form-group">
<label for="is-inline-start">Is inline start?</label>
<input id="is-inline-start" readonly type="checkbox" />
</div>
<div class="form-group">
<label for="is-inline-end">Is inline end?</label>
<input id="is-inline-end" readonly type="checkbox">
</div>
</fieldset>
</form>
<!--
Editor
-->
<div class="text-editor-container align-top">
<div
id="text-editor-selection-imposter"
class="text-editor-selection-imposter"></div>
<div
class="text-editor-content"
contenteditable="true"
role="textbox"
aria-multiline="true"
aria-autocomplete="none"
spellcheck="false"
autocapitalize="false"></div>
<div id="playground">
<div id="info">
<form>
<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>
<!-- Font -->
<div class="form-group">
<label for="font-family">Font family</label>
<select id="font-family">
<option value="Open+Sans">Open Sans</option>
<option value="sourcesanspro">Source Sans Pro</option>
<option value="whatever">Whatever</option>
</select>
</div>
<div class="form-group">
<label for="font-size">Font size</label>
<input id="font-size" type="number" value="14" />
</div>
<div class="form-group">
<label for="font-weight">Font weight</label>
<select id="font-weight">
<option value="100">100</option>
<option value="200">200</option>
<option value="300">300</option>
<option value="400">400 (normal)</option>
<option value="500">500</option>
<option value="600">600</option>
<option value="700">700 (bold)</option>
<option value="800">800</option>
<option value="900">900</option>
</select>
</div>
<div class="form-group">
<label for="font-style">Font style</label>
<select id="font-style">
<option value="normal">normal</option>
<option value="italic">italic</option>
<option value="oblique">oblique</option>
</select>
</div>
<!-- Text attributes -->
<div class="form-group">
<label for="line-height">Line height</label>
<input id="line-height" type="number" value="1.0" />
</div>
<div class="form-group">
<label for="letter-spacing">Letter spacing</label>
<input id="letter-spacing" type="number" value="0.0" />
</div>
<div class="form-group">
<label for="direction-ltr">LTR</label>
<input id="direction-ltr" type="radio" name="direction" value="ltr" checked />
</div>
<div class="form-group">
<label for="direction-rtl">RTL</label>
<input id="direction-rtl" type="radio" name="direction" value="rtl" />
</div>
<!-- Text Align -->
<div class="form-group">
<label for="text-align-left">Align left</label>
<input id="text-align-left" type="radio" name="text-align" value="left" checked />
</div>
<div class="form-group">
<label for="text-align-center">Align center</label>
<input id="text-align-center" type="radio" name="text-align" value="center" />
</div>
<div class="form-group">
<label for="text-align-right">Align right</label>
<input id="text-align-right" type="radio" name="text-align" value="right" />
</div>
<div class="form-group">
<label for="text-align-justify">Align justify</label>
<input id="text-align-justify" type="radio" name="text-align" value="justify" />
</div>
<!-- Text Transform -->
<div class="form-group">
<label for="text-transform-none">None</label>
<input id="text-transform-none" type="radio" name="text-transform" value="none" checked />
</div>
<div class="form-group">
<label for="text-transform-uppercase">Uppercase</label>
<input id="text-transform-uppercase" type="radio" name="text-transform" value="uppercase" checked />
</div>
<div class="form-group">
<label for="text-transform-capitalize">Capitalize</label>
<input id="text-transform-capitalize" type="radio" name="text-transform" value="capitalize" />
</div>
<div class="form-group">
<label for="text-transform-lowercase">Lowercase</label>
<input id="text-transform-lowercase" type="radio" name="text-transform" value="lowercase" />
</div>
</fieldset>
<fieldset id="debug">
<legend>Debug</legend>
<div class="form-group">
<label for="direction">Direction</label>
<input id="direction" readonly type="text" />
</div>
<div class="form-group">
<label for="focus-node">Focus Node</label>
<input id="focus-node" readonly type="text" />
</div>
<div class="form-group">
<label for="focus-offset">Focus offset</label>
<input id="focus-offset" readonly type="number">
</div>
<div class="form-group">
<label for="focus-inline">Focus Inline</label>
<input id="focus-inline" readonly type="text" />
</div>
<div class="form-group">
<label for="focus-paragraph">Focus Paragraph</label>
<input id="focus-paragraph" readonly type="text" />
</div>
<div class="form-group">
<label for="anchor-node">Anchor Node</label>
<input id="anchor-node" readonly type="text" />
</div>
<div class="form-group">
<label for="anchor-offset">Anchor offset</label>
<input id="anchor-offset" readonly type="number">
</div>
<div class="form-group">
<label for="anchor-inline">Anchor Inline</label>
<input id="anchor-inline" readonly type="text" />
</div>
<div class="form-group">
<label for="anchor-paragraph">Anchor Paragraph</label>
<input id="anchor-paragraph" readonly type="text" />
</div>
<div class="form-group">
<label for="start-container">Start container</label>
<input id="start-container" readonly type="text" />
</div>
<div class="form-group">
<label for="start-offset">Start offset</label>
<input id="start-offset" readonly type="text" />
</div>
<div class="form-group">
<label for="end-container">End container</label>
<input id="end-container" readonly type="text" />
</div>
<div class="form-group">
<label for="end-offset">End offset</label>
<input id="end-offset" readonly type="text" />
</div>
<div class="form-group">
<label for="multi">Multi?</label>
<input id="multi" readonly type="checkbox" />
</div>
<div class="form-group">
<label for="multi-inline">Multi inline?</label>
<input id="multi-inline" readonly type="checkbox" />
</div>
<div class="form-group">
<label for="multi-paragraph">Multi paragraph?</label>
<input id="multi-paragraph" readonly type="checkbox" />
</div>
<div class="form-group">
<label for="is-text-focus">Is text focus?</label>
<input id="is-text-focus" readonly type="checkbox" />
</div>
<div class="form-group">
<label for="is-text-anchor">Is text anchor?</label>
<input id="is-text-anchor" readonly type="checkbox" />
</div>
<div class="form-group">
<label for="is-paragraph-start">Is paragraph start?</label>
<input id="is-paragraph-start" readonly type="checkbox" />
</div>
<div class="form-group">
<label for="is-paragraph-end">Is paragraph end?</label>
<input id="is-paragraph-end" readonly type="checkbox" />
</div>
<div class="form-group">
<label for="is-inline-start">Is inline start?</label>
<input id="is-inline-start" readonly type="checkbox" />
</div>
<div class="form-group">
<label for="is-inline-end">Is inline end?</label>
<input id="is-inline-end" readonly type="checkbox">
</div>
</fieldset>
</form>
</div>
<!--
<div id="app">
Text output
<!--
-->
<canvas id="canvas"></canvas>
Canvas
-->
<canvas id="canvas"></canvas>
<!--
Editor
-->
<div class="text-editor-container align-top">
<div
class="text-editor-content"
contenteditable="true"
role="textbox"
aria-multiline="true"
aria-autocomplete="none"
spellcheck="false"
autocapitalize="false"></div>
</div>
</div>
</div>
<script type="module">
import "./style.css";
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>
<script src="./playground.js" type="module"></script>
</body>
</html>

View 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();

View 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;
}
}

View 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;
}
}

View 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);
}
}

View 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})`;
}
}

View 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
}
}

View 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;
}

View 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),
);
}
}

View 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)
);
}
}

View 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
}
}

View 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);
}
}

View File

@@ -1,22 +1,49 @@
:root {
background-color: #333;
color: #eee;
width: 100dvw;
height: 100dvh;
}
html, body {
body {
margin: 0;
padding: 0;
display: grid;
/* overflow: hidden; */
width: 100dvw;
height: 100dvh;
}
#playground {
color: #c0c0c2;
background-color: #18181a;
display: grid;
max-height: 100cqh;
}
#info {
/* background-color: #f00; */
}
form {
display: flex;
}
#app {
/* background-color: #0f0; */
}
canvas {
width: 100cqw;
width: 100%;
height: max-content;
}
.text-editor-container {
background-color: white;
position: fixed;
min-width: 1rem;
min-height: 1rem;
transform-origin: top left;
color: transparent;
}
.playground {
display: grid;
max-width: 1280px;
.text-editor-content {
outline: 1px solid red !important;
}

View File

@@ -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,
);
}
}
}

View File

@@ -77,6 +77,20 @@ macro_rules! with_current_shape {
};
}
#[macro_export]
macro_rules! with_state_mut_current_shape {
($state:ident, |$shape:ident: &Shape| $block:block) => {
let $state = unsafe {
#[allow(static_mut_refs)]
STATE.as_mut()
}
.expect("Got an invalid state pointer");
if let Some($shape) = $state.current_shape() {
$block
}
};
}
/// This is called from JS after the WebGL context has been created.
#[no_mangle]
pub extern "C" fn init(width: i32, height: i32) {

View File

@@ -945,7 +945,17 @@ impl RenderState {
) -> Result<(), String> {
performance::begin_measure!("process_animation_frame");
if self.render_in_progress {
self.render_shape_tree_partial(tree, modifiers, structure, scale_content, timestamp)?;
if tree.len() != 0 {
self.render_shape_tree_partial(
tree,
modifiers,
structure,
scale_content,
timestamp,
)?;
} else {
println!("Empty tree");
}
self.flush_and_submit();
if self.render_in_progress {

View File

@@ -223,10 +223,15 @@ fn draw_text(
let mut group_offset_y = global_offset_y;
let group_len = paragraph_builder_group.len();
for paragraph_builder in paragraph_builder_group.iter_mut() {
for (paragraph_index, paragraph_builder) in paragraph_builder_group.iter_mut().enumerate() {
let mut paragraph = paragraph_builder.build();
paragraph.layout(paragraph_width);
let paragraph_height = paragraph.height();
let _paragraph_height = paragraph.height();
// FIXME: I've kept the _paragraph_height variable to have
// a reminder in the future to keep digging why the ideographic_baseline
// works so well and not the paragraph_height. I think we should test
// this more.
let ideographic_baseline = paragraph.ideographic_baseline();
let xy = (shape.selrect().x(), shape.selrect().y() + group_offset_y);
paragraph.paint(canvas, xy);
@@ -234,18 +239,17 @@ fn draw_text(
render_text_decoration(canvas, &paragraph, paragraph_builder, line_metrics, xy);
}
#[allow(clippy::collapsible_else_if)]
if group_len == 1 {
group_offset_y += paragraph_height;
group_offset_y += ideographic_baseline;
} else {
if paragraph_index == 0 {
group_offset_y += ideographic_baseline;
}
}
}
if group_len > 1 {
let mut first_paragraph = paragraph_builder_group[0].build();
first_paragraph.layout(paragraph_width);
global_offset_y += first_paragraph.height();
} else {
global_offset_y = group_offset_y;
}
global_offset_y = group_offset_y;
}
}

View File

@@ -1110,8 +1110,7 @@ impl Shape {
if let Some(path) = shape_type.path_mut() {
path.transform(transform);
}
}
if let Type::Text(text) = &mut self.shape_type {
} else if let Type::Text(text) = &mut self.shape_type {
text.transform(transform);
}
}

View File

@@ -85,6 +85,30 @@ impl TextContentSize {
}
}
#[derive(Debug, Clone, Copy)]
pub struct TextPositionWithAffinity {
pub position_with_affinity: PositionWithAffinity,
pub paragraph: i32,
pub leaf: i32,
pub offset: i32,
}
impl TextPositionWithAffinity {
pub fn new(
position_with_affinity: PositionWithAffinity,
paragraph: i32,
leaf: i32,
offset: i32,
) -> Self {
Self {
position_with_affinity,
paragraph,
leaf,
offset,
}
}
}
#[derive(Debug)]
pub struct TextContentLayoutResult(
Vec<ParagraphBuilderGroup>,
@@ -95,7 +119,7 @@ pub struct TextContentLayoutResult(
#[derive(Debug)]
pub struct TextContentLayout {
pub paragraph_builders: Vec<ParagraphBuilderGroup>,
pub paragraphs: Vec<Vec<skia_safe::textlayout::Paragraph>>,
pub paragraphs: Vec<Vec<skia::textlayout::Paragraph>>,
}
impl Clone for TextContentLayout {
@@ -223,18 +247,49 @@ impl TextContent {
self.bounds = Rect::from_ltrb(p1.x, p1.y, p2.x, p2.y);
}
pub fn get_caret_position_at(&self, point: &Point) -> Option<PositionWithAffinity> {
pub fn get_caret_position_at(&self, point: &Point) -> Option<TextPositionWithAffinity> {
let mut offset_y = 0.0;
let paragraphs = self.layout.paragraphs.iter().flatten();
let layout_paragraphs = self.layout.paragraphs.iter().flatten();
for paragraph in paragraphs {
let mut paragraph_index: i32 = -1;
let mut leaf_index: i32 = -1;
for layout_paragraph in layout_paragraphs {
paragraph_index += 1;
let start_y = offset_y;
let end_y = offset_y + paragraph.height();
let end_y = offset_y + layout_paragraph.height();
// We only test against paragraphs that can contain the current y
// coordinate.
if point.y > start_y && point.y < end_y {
let position_with_affinity = paragraph.get_glyph_position_at_coordinate(*point);
return Some(position_with_affinity);
let position_with_affinity =
layout_paragraph.get_glyph_position_at_coordinate(*point);
if let Some(paragraph) = self.paragraphs().get(paragraph_index as usize) {
// Computed position keeps the current position in terms
// of number of characters of text. This is used to know
// in which leaf we are.
let mut computed_position = 0;
let mut leaf_offset = 0;
for leaf in paragraph.children() {
leaf_index += 1;
let length = leaf.text.len();
let start_position = computed_position;
let end_position = computed_position + length;
let current_position = position_with_affinity.position as usize;
if start_position <= current_position && end_position >= current_position {
leaf_offset = position_with_affinity.position - start_position as i32;
break;
}
computed_position += length;
}
return Some(TextPositionWithAffinity::new(
position_with_affinity,
paragraph_index,
leaf_index,
leaf_offset,
));
}
}
offset_y += paragraph.height();
offset_y += layout_paragraph.height();
}
None
}

View File

@@ -2,7 +2,9 @@ use skia_safe::{self as skia, textlayout::FontCollection, Path, Point};
use std::collections::HashMap;
mod shapes_pool;
mod text_editor;
pub use shapes_pool::*;
pub use text_editor::*;
use crate::render::RenderState;
use crate::shapes::Shape;
@@ -19,6 +21,7 @@ use crate::shapes::modifiers::grid_layout::grid_cell_data;
/// must not be shared between different Web Workers.
pub(crate) struct State {
pub render_state: RenderState,
pub text_editor_state: TextEditorState,
pub current_id: Option<Uuid>,
pub shapes: ShapesPool,
pub modifiers: HashMap<Uuid, skia::Matrix>,
@@ -30,6 +33,7 @@ impl State {
pub fn new(width: i32, height: i32) -> Self {
State {
render_state: RenderState::new(width, height),
text_editor_state: TextEditorState::new(),
current_id: None,
shapes: ShapesPool::new(),
modifiers: HashMap::new(),
@@ -50,6 +54,16 @@ impl State {
&self.render_state
}
#[allow(dead_code)]
pub fn text_editor_state_mut(&mut self) -> &mut TextEditorState {
&mut self.text_editor_state
}
#[allow(dead_code)]
pub fn text_editor_state(&self) -> &TextEditorState {
&self.text_editor_state
}
pub fn render_from_cache(&mut self) {
self.render_state
.render_from_cache(&self.shapes, &self.modifiers, &self.structure);

View 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,
);
}
}

View File

@@ -7,7 +7,7 @@ use crate::shapes::{
self, GrowType, TextAlign, TextDecoration, TextDirection, TextTransform, Type,
};
use crate::utils::{uuid_from_u32, uuid_from_u32_quartet};
use crate::{with_current_shape, with_current_shape_mut, with_state_mut, STATE};
use crate::{with_current_shape_mut, with_state_mut, with_state_mut_current_shape, STATE};
const RAW_LEAF_DATA_SIZE: usize = std::mem::size_of::<RawTextLeaf>();
const RAW_PARAGRAPH_DATA_SIZE: usize = std::mem::size_of::<RawParagraphData>();
@@ -368,7 +368,7 @@ pub extern "C" fn update_shape_text_layout_for_all() {
#[no_mangle]
pub extern "C" fn get_caret_position_at(x: f32, y: f32) -> i32 {
with_current_shape!(state, |shape: &Shape| {
with_state_mut_current_shape!(state, |shape: &Shape| {
if let Type::Text(text_content) = &shape.shape_type {
let mut matrix = Matrix::new_identity();
let shape_matrix = shape.get_concatenated_matrix(&state.shapes);
@@ -382,11 +382,11 @@ pub extern "C" fn get_caret_position_at(x: f32, y: f32) -> i32 {
if let Some(position_with_affinity) =
text_content.get_caret_position_at(&mapped_point)
{
return position_with_affinity.position;
return position_with_affinity.position_with_affinity.position;
}
}
} else {
panic!("Trying to update grow type in a shape that it's not a text shape");
panic!("Trying to get caret position of a shape that it's not a text shape");
}
});
-1