mirror of
https://github.com/penpot/penpot.git
synced 2025-12-11 22:14:05 +01:00
🎉 Set DOM text editor element caret
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Copyright (c) KALEIDOS INC
|
||||
*/
|
||||
|
||||
/**
|
||||
* Creates a new selection imposter from a list of client rects.
|
||||
*
|
||||
* @param {DOMRect} referenceRect
|
||||
* @param {DOMRectList} clientRects
|
||||
* @returns {DocumentFragment}
|
||||
*/
|
||||
export function createSelectionImposterFromClientRects(
|
||||
referenceRect,
|
||||
clientRects
|
||||
) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const rect of clientRects) {
|
||||
const rectElement = document.createElement("div");
|
||||
rectElement.className = "selection-imposter-rect";
|
||||
rectElement.style.left = `${rect.x - referenceRect.x}px`;
|
||||
rectElement.style.top = `${rect.y - referenceRect.y}px`;
|
||||
rectElement.style.width = `${rect.width}px`;
|
||||
rectElement.style.height = `${rect.height}px`;
|
||||
fragment.appendChild(rectElement);
|
||||
}
|
||||
return fragment;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { createSelectionImposterFromClientRects } from "./Imposter.js";
|
||||
|
||||
/* @vitest-environment jsdom */
|
||||
test("Create selection DOM rects from client rects", () => {
|
||||
const rect = new DOMRect(20, 20, 100, 50);
|
||||
const clientRects = [
|
||||
new DOMRect(20, 20, 100, 20),
|
||||
new DOMRect(20, 50, 50, 20),
|
||||
];
|
||||
const fragment = createSelectionImposterFromClientRects(rect, clientRects);
|
||||
expect(fragment).toBeInstanceOf(DocumentFragment);
|
||||
expect(fragment.childNodes).toHaveLength(2);
|
||||
});
|
||||
@@ -7,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>
|
||||
|
||||
578
frontend/text-editor/src/playground.js
Normal file
578
frontend/text-editor/src/playground.js
Normal file
@@ -0,0 +1,578 @@
|
||||
import "./style.css";
|
||||
import "./fonts.css";
|
||||
import "./editor/TextEditor.css";
|
||||
import initWasmModule from "./wasm/render_wasm.js";
|
||||
import { UUID } from "./playground/uuid.js";
|
||||
import { Rect, Point } from "./playground/geom.js";
|
||||
import { WASMModuleWrapper } from "./playground/wasm.js";
|
||||
import { FontManager } from "./playground/font.js";
|
||||
import { TextContent, TextParagraph, TextLeaf } from "./playground/text.js";
|
||||
import { Viewport } from "./playground/viewport.js";
|
||||
import { Fill } from "./playground/fill.js";
|
||||
import { Shape } from "./playground/shape.js";
|
||||
import { Color } from "./playground/color.js";
|
||||
import { TextEditor } from "./editor/TextEditor.js";
|
||||
import { SelectionControllerDebug } from "./editor/debug/SelectionControllerDebug.js";
|
||||
|
||||
function debounce(fn, delay) {
|
||||
let timeout;
|
||||
return (...args) => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => fn(...args), delay);
|
||||
};
|
||||
}
|
||||
|
||||
class TextEditorPlayground {
|
||||
#module;
|
||||
#canvas;
|
||||
#textEditor;
|
||||
#fontManager;
|
||||
#viewport = new Viewport();
|
||||
#isPanning = false;
|
||||
#shapes = new Map();
|
||||
#ui;
|
||||
#resizeObserver;
|
||||
|
||||
constructor(module, canvas) {
|
||||
this.#canvas = canvas;
|
||||
this.#ui = {
|
||||
appElement: document.getElementById("app"),
|
||||
textEditorElement: document.querySelector(".text-editor-content"),
|
||||
shapePositionXElement: document.getElementById("position-x"),
|
||||
shapePositionYElement: document.getElementById("position-y"),
|
||||
shapeRotationElement: document.getElementById("rotation"),
|
||||
|
||||
fontFamilyElement: document.getElementById("font-family"),
|
||||
fontSizeElement: document.getElementById("font-size"),
|
||||
fontWeightElement: document.getElementById("font-weight"),
|
||||
fontStyleElement: document.getElementById("font-style"),
|
||||
|
||||
directionLTRElement: document.getElementById("direction-ltr"),
|
||||
directionRTLElement: document.getElementById("direction-rtl"),
|
||||
|
||||
lineHeightElement: document.getElementById("line-height"),
|
||||
letterSpacingElement: document.getElementById("letter-spacing"),
|
||||
|
||||
textAlignLeftElement: document.getElementById("text-align-left"),
|
||||
textAlignCenterElement: document.getElementById("text-align-center"),
|
||||
textAlignRightElement: document.getElementById("text-align-right"),
|
||||
textAlignJustifyElement: document.getElementById("text-align-justify"),
|
||||
};
|
||||
this.#module = new WASMModuleWrapper(module);
|
||||
this.#fontManager = new FontManager(this.#module);
|
||||
this.#textEditor = new TextEditor(this.#ui.textEditorElement, canvas, {
|
||||
styleDefaults: {
|
||||
"font-family": "MontserratAlternates",
|
||||
"font-size": "14",
|
||||
"font-weight": "500",
|
||||
"font-style": "normal",
|
||||
"line-height": "1.2",
|
||||
"letter-spacing": "0",
|
||||
"direction": "ltr",
|
||||
"text-align": "left",
|
||||
"text-transform": "none",
|
||||
"text-decoration": "none",
|
||||
"--typography-ref-id": '["~#\'",null]',
|
||||
"--typography-ref-file": '["~#\'",null]',
|
||||
"--font-id": '["~#\'","MontserratAlternates"]',
|
||||
"--fills": '[["^ ","~:fill-color","#000000","~:fill-opacity",1]]',
|
||||
},
|
||||
debug: new SelectionControllerDebug({
|
||||
direction: document.getElementById("direction"),
|
||||
multiElement: document.getElementById("multi"),
|
||||
multiInlineElement: document.getElementById("multi-inline"),
|
||||
multiParagraphElement: document.getElementById("multi-paragraph"),
|
||||
isParagraphStart: document.getElementById("is-paragraph-start"),
|
||||
isParagraphEnd: document.getElementById("is-paragraph-end"),
|
||||
isInlineStart: document.getElementById("is-inline-start"),
|
||||
isInlineEnd: document.getElementById("is-inline-end"),
|
||||
isTextAnchor: document.getElementById("is-text-anchor"),
|
||||
isTextFocus: document.getElementById("is-text-focus"),
|
||||
focusNode: document.getElementById("focus-node"),
|
||||
focusOffset: document.getElementById("focus-offset"),
|
||||
focusInline: document.getElementById("focus-inline"),
|
||||
focusParagraph: document.getElementById("focus-paragraph"),
|
||||
anchorNode: document.getElementById("anchor-node"),
|
||||
anchorOffset: document.getElementById("anchor-offset"),
|
||||
anchorInline: document.getElementById("anchor-inline"),
|
||||
anchorParagraph: document.getElementById("anchor-paragraph"),
|
||||
startContainer: document.getElementById("start-container"),
|
||||
startOffset: document.getElementById("start-offset"),
|
||||
endContainer: document.getElementById("end-container"),
|
||||
endOffset: document.getElementById("end-offset"),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
get canvas() {
|
||||
return this.#canvas;
|
||||
}
|
||||
|
||||
#onWheel = (e) => {
|
||||
e.preventDefault();
|
||||
const textShape = this.#shapes.get("text");
|
||||
if (!textShape) {
|
||||
console.warn("Text shape not found");
|
||||
return;
|
||||
}
|
||||
const zoomFactor = e.deltaY < 0 ? 1.1 : 0.9;
|
||||
this.#viewport.zoom *= zoomFactor;
|
||||
this.#viewport.pan(e.movementX, e.movementY);
|
||||
this.#textEditor.updatePositionWithViewportAndShape(
|
||||
this.#viewport,
|
||||
textShape,
|
||||
);
|
||||
this.render();
|
||||
};
|
||||
|
||||
#onPointer = (e) => {
|
||||
switch (e.type) {
|
||||
case "pointermove":
|
||||
if (this.#isPanning) {
|
||||
this.#viewport.pan(e.movementX, e.movementY);
|
||||
const textShape = this.#shapes.get("text");
|
||||
if (!textShape) {
|
||||
console.warn("Text shape not found");
|
||||
return;
|
||||
}
|
||||
this.#textEditor.updatePositionWithViewportAndShape(
|
||||
this.#viewport,
|
||||
textShape,
|
||||
);
|
||||
this.render();
|
||||
}
|
||||
break;
|
||||
case "pointerdown":
|
||||
this.#isPanning = true;
|
||||
break;
|
||||
case "pointerleave":
|
||||
case "pointerup":
|
||||
this.#isPanning = false;
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
#onClick = (e) => {
|
||||
console.log("click", e.type, e);
|
||||
const caretPosition = this.#module.call(
|
||||
"get_caret_position_at",
|
||||
e.offsetX,
|
||||
e.offsetY,
|
||||
);
|
||||
console.log("caretPosition", caretPosition);
|
||||
};
|
||||
|
||||
#onResize = (_entries) => {
|
||||
this.#resizeCanvas();
|
||||
this.#module.call(
|
||||
"resize_viewbox",
|
||||
this.#canvas.width,
|
||||
this.#canvas.height,
|
||||
);
|
||||
this.render();
|
||||
};
|
||||
|
||||
#onNeedsLayout = (_e) => {
|
||||
const textShape = this.#shapes.get("text");
|
||||
if (!textShape) {
|
||||
console.warn("Text shape not found");
|
||||
return;
|
||||
}
|
||||
textShape.textContent.updateFromDOM(
|
||||
this.#textEditor.root,
|
||||
this.#fontManager,
|
||||
);
|
||||
this.#setShape(textShape);
|
||||
this.render();
|
||||
};
|
||||
|
||||
#onStyleChange = (e) => {
|
||||
const fontSize = parseInt(e.detail.getPropertyValue("font-size"), 10);
|
||||
const fontWeight = e.detail.getPropertyValue("font-weight");
|
||||
const fontStyle = e.detail.getPropertyValue("font-style");
|
||||
const fontFamily = e.detail.getPropertyValue("font-family");
|
||||
|
||||
this.#ui.fontFamilyElement.value = fontFamily;
|
||||
this.#ui.fontSizeElement.value = fontSize;
|
||||
this.#ui.fontStyleElement.value = fontStyle;
|
||||
this.#ui.fontWeightElement.value = fontWeight;
|
||||
|
||||
const textAlign = e.detail.getPropertyValue("text-align");
|
||||
this.#ui.textAlignLeftElement.checked = textAlign === "left";
|
||||
this.#ui.textAlignCenterElement.checked = textAlign === "center";
|
||||
this.#ui.textAlignRightElement.checked = textAlign === "right";
|
||||
this.#ui.textAlignJustifyElement.checked = textAlign === "justify";
|
||||
|
||||
const direction = e.detail.getPropertyValue("direction");
|
||||
this.#ui.directionLTRElement.checked = direction === "ltr";
|
||||
this.#ui.directionRTLElement.checked = direction === "rtl";
|
||||
};
|
||||
|
||||
#resizeCanvas(
|
||||
width = Math.floor(this.#canvas.clientWidth),
|
||||
height = Math.floor(this.#canvas.clientHeight),
|
||||
) {
|
||||
let resized = false;
|
||||
if (this.#canvas.width !== width) {
|
||||
this.#canvas.width = width;
|
||||
resized = true;
|
||||
}
|
||||
if (this.#canvas.height !== height) {
|
||||
this.#canvas.height = height;
|
||||
resized = true;
|
||||
}
|
||||
return resized;
|
||||
}
|
||||
|
||||
#setupCanvasContext() {
|
||||
this.#module.registerContext(this.#canvas, "webgl2", {
|
||||
antialias: true,
|
||||
depth: true,
|
||||
alpha: false,
|
||||
stencil: true,
|
||||
preserveDrawingBuffer: true,
|
||||
});
|
||||
this.#resizeCanvas();
|
||||
this.#module.call("init", this.#canvas.width, this.#canvas.height);
|
||||
this.#module.call("set_render_options", 0, 1);
|
||||
}
|
||||
|
||||
#setupCanvas() {
|
||||
this.#resizeObserver = new ResizeObserver(this.#onResize);
|
||||
this.#resizeObserver.observe(this.#canvas);
|
||||
this.#module.call("set_canvas_background", Color.parse("#FABADA").argb32);
|
||||
this.#module.call("set_view", 1, 0, 0);
|
||||
this.#module.call("init_shapes_pool", 1);
|
||||
}
|
||||
|
||||
#setupInteraction() {
|
||||
this.#canvas.addEventListener("wheel", this.#onWheel);
|
||||
this.#canvas.addEventListener("pointerdown", this.#onPointer);
|
||||
this.#canvas.addEventListener("pointermove", this.#onPointer);
|
||||
this.#canvas.addEventListener("pointerup", this.#onPointer);
|
||||
this.#canvas.addEventListener("pointerleave", this.#onPointer);
|
||||
this.#canvas.addEventListener("click", this.#onClick);
|
||||
}
|
||||
|
||||
async #setupFonts() {
|
||||
await this.#fontManager.load();
|
||||
}
|
||||
|
||||
#onDirectionChange = (e) => {
|
||||
if (e.target.checked) {
|
||||
this.#textEditor.applyStylesToSelection({
|
||||
direction: e.target.value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
#onTextAlignChange = (e) => {
|
||||
if (e.target.checked) {
|
||||
this.#textEditor.applyStylesToSelection({
|
||||
"text-align": e.target.value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
#onFontFamilyChange = (e) => {
|
||||
const fontStyles = this.#fontManager.fonts.get(e.target.value);
|
||||
for (const fontStyle of fontStyles) {
|
||||
console.log("fontStyle", fontStyle);
|
||||
}
|
||||
this.#textEditor.applyStylesToSelection({
|
||||
"font-family": e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
#onFontWeightChange = (e) => {
|
||||
this.#textEditor.applyStylesToSelection({
|
||||
"font-weight": e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
#onFontSizeChange = (e) => {
|
||||
this.#textEditor.applyStylesToSelection({
|
||||
"font-size": e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
#onFontStyleChange = (e) => {
|
||||
this.#textEditor.applyStylesToSelection({
|
||||
"font-style": e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
#onLineHeightChange = (e) => {
|
||||
this.#textEditor.applyStylesToSelection({
|
||||
"line-height": e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
#onLetterSpacingChange = (e) => {
|
||||
this.#textEditor.applyStylesToSelection({
|
||||
"letter-spacing": e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
#onShapePositionChange = (_e) => {
|
||||
const textShape = this.#shapes.get("text");
|
||||
if (!textShape) {
|
||||
console.warn("Text shape not found")
|
||||
return;
|
||||
}
|
||||
textShape.selrect.left = this.#ui.shapePositionXElement.valueAsNumber;
|
||||
textShape.selrect.top = this.#ui.shapePositionYElement.valueAsNumber;
|
||||
this.#module.call(
|
||||
"set_shape_selrect",
|
||||
textShape.selrect.left,
|
||||
textShape.selrect.top,
|
||||
textShape.selrect.right,
|
||||
textShape.selrect.bottom,
|
||||
);
|
||||
this.#textEditor.updatePositionWithViewportAndShape(this.#viewport, textShape);
|
||||
this.render();
|
||||
};
|
||||
|
||||
#onShapeRotationChange = (e) => {
|
||||
const textShape = this.#shapes.get("text");
|
||||
if (!textShape) {
|
||||
console.warn("Text shape not found");
|
||||
return;
|
||||
}
|
||||
textShape.rotation = e.target.valueAsNumber;
|
||||
this.#module.call("set_shape_rotation", textShape.rotation);
|
||||
this.#textEditor.updatePositionWithViewportAndShape(
|
||||
this.#viewport,
|
||||
textShape,
|
||||
);
|
||||
this.render();
|
||||
}
|
||||
|
||||
#setupUI() {
|
||||
const fontFamiliesFragment = document.createDocumentFragment();
|
||||
for (const [font, fontData] of this.#fontManager.fonts) {
|
||||
const fontFamilyOptionElement = document.createElement("option");
|
||||
fontFamilyOptionElement.value = font;
|
||||
fontFamilyOptionElement.textContent = font;
|
||||
fontFamiliesFragment.appendChild(fontFamilyOptionElement);
|
||||
}
|
||||
this.#ui.fontFamilyElement.replaceChildren(fontFamiliesFragment);
|
||||
|
||||
this.#ui.shapePositionXElement.addEventListener(
|
||||
"change",
|
||||
this.#onShapePositionChange
|
||||
);
|
||||
this.#ui.shapePositionYElement.addEventListener(
|
||||
"change",
|
||||
this.#onShapePositionChange
|
||||
);
|
||||
this.#ui.shapeRotationElement.addEventListener(
|
||||
"change",
|
||||
this.#onShapeRotationChange
|
||||
);
|
||||
|
||||
this.#ui.directionLTRElement.addEventListener(
|
||||
"change",
|
||||
this.#onDirectionChange,
|
||||
);
|
||||
this.#ui.directionRTLElement.addEventListener(
|
||||
"change",
|
||||
this.#onDirectionChange,
|
||||
);
|
||||
|
||||
this.#ui.textAlignLeftElement.addEventListener(
|
||||
"change",
|
||||
this.#onTextAlignChange,
|
||||
);
|
||||
this.#ui.textAlignCenterElement.addEventListener(
|
||||
"change",
|
||||
this.#onTextAlignChange,
|
||||
);
|
||||
this.#ui.textAlignRightElement.addEventListener(
|
||||
"change",
|
||||
this.#onTextAlignChange,
|
||||
);
|
||||
this.#ui.textAlignJustifyElement.addEventListener(
|
||||
"change",
|
||||
this.#onTextAlignChange,
|
||||
);
|
||||
|
||||
this.#ui.fontFamilyElement.addEventListener(
|
||||
"change",
|
||||
this.#onFontFamilyChange,
|
||||
);
|
||||
this.#ui.fontWeightElement.addEventListener(
|
||||
"change",
|
||||
this.#onFontWeightChange,
|
||||
);
|
||||
this.#ui.fontSizeElement.addEventListener(
|
||||
"change",
|
||||
this.#onFontSizeChange
|
||||
);
|
||||
this.#ui.fontStyleElement.addEventListener(
|
||||
"change",
|
||||
this.#onFontStyleChange,
|
||||
);
|
||||
this.#ui.lineHeightElement.addEventListener(
|
||||
"change",
|
||||
this.#onLineHeightChange,
|
||||
);
|
||||
this.#ui.letterSpacingElement.addEventListener(
|
||||
"change",
|
||||
this.#onLetterSpacingChange,
|
||||
);
|
||||
}
|
||||
|
||||
#setShape(shape) {
|
||||
this.#module.call("use_shape", ...shape.id);
|
||||
this.#module.call("set_parent", ...shape.parentId);
|
||||
this.#module.call("set_shape_type", shape.type);
|
||||
this.#module.call("set_shape_rotation", shape.rotation);
|
||||
this.#module.call(
|
||||
"set_shape_selrect",
|
||||
shape.selrect.left,
|
||||
shape.selrect.top,
|
||||
shape.selrect.right,
|
||||
shape.selrect.bottom,
|
||||
);
|
||||
if (shape.childrenIds.length > 0 && Array.isArray(shape.childrenIds)) {
|
||||
let ptr = this.#module.call(
|
||||
"alloc_bytes",
|
||||
shape.childrenIds.length * UUID.BYTE_LENGTH,
|
||||
);
|
||||
for (const childrenId of shape.childrenIds) {
|
||||
this.#module.set(ptr, UUID.BYTE_LENGTH, childrenId);
|
||||
ptr += UUID.BYTE_LENGTH;
|
||||
}
|
||||
this.#module.call("set_children");
|
||||
}
|
||||
if (shape.textContent && shape.textContent instanceof TextContent) {
|
||||
this.#module.call("clear_shape_text");
|
||||
for (const paragraph of shape.textContent.paragraphs) {
|
||||
const ptr = this.#module.call("alloc_bytes", paragraph.byteLength);
|
||||
const view = this.#module.viewOf(ptr, paragraph.byteLength);
|
||||
// Number of text leaves in the paragraph.
|
||||
view.setUint32(0, paragraph.leaves.length, true);
|
||||
|
||||
console.log('lineHeight', paragraph.lineHeight);
|
||||
|
||||
// Serialize paragraph attributes
|
||||
view.setUint8(4, paragraph.textAlign, true); // text-align: left
|
||||
view.setUint8(5, paragraph.textDirection, true); // text-direction: LTR
|
||||
view.setUint8(6, paragraph.textDecoration, true); // text-decoration: none
|
||||
view.setUint8(7, paragraph.textTransform, true); // text-transform: none
|
||||
view.setFloat32(8, paragraph.lineHeight, true); // line-height: 1.2
|
||||
view.setFloat32(12, paragraph.letterSpacing, true); // letter-spacing: 0.0
|
||||
view.setUint32(16, paragraph.typographyRefFile[0], true); // typography-ref-file (UUID part 1)
|
||||
view.setUint32(20, paragraph.typographyRefFile[1], true); // typography-ref-file (UUID part 2)
|
||||
view.setUint32(24, paragraph.typographyRefFile[2], true); // typography-ref-file (UUID part 3)
|
||||
view.setUint32(28, paragraph.typographyRefFile[3], true); // typography-ref-file (UUID part 4)
|
||||
view.setUint32(32, paragraph.typographyRefId[0], true); // typography-ref-id (UUID part 1)
|
||||
view.setUint32(36, paragraph.typographyRefId[1], true); // typography-ref-id (UUID part 2)
|
||||
view.setUint32(40, paragraph.typographyRefId[2], true); // typography-ref-id (UUID part 3)
|
||||
view.setUint32(44, paragraph.typographyRefId[3], true); // typography-ref-id (UUID part 4)
|
||||
|
||||
let offset = TextParagraph.BYTE_LENGTH;
|
||||
for (const leaf of paragraph.leaves) {
|
||||
// Serialize leaf attributes
|
||||
view.setUint8(offset + 0, leaf.fontStyle, true); // font-style: normal
|
||||
view.setUint8(offset + 1, leaf.textDecoration, true); // text-decoration: none
|
||||
view.setUint8(offset + 2, leaf.textTransform, true); // text-transform: none
|
||||
view.setUint8(offset + 3, leaf.textDirection, true); // text-direction: ltr
|
||||
view.setFloat32(offset + 4, leaf.fontSize, true); // font-size
|
||||
view.setFloat32(offset + 8, leaf.letterSpacing, true); // letter-spacing
|
||||
view.setInt32(offset + 12, leaf.fontWeight, true); // font-weight: normal
|
||||
view.setUint32(offset + 16, leaf.fontId[0], true); // font-id (UUID part 1)
|
||||
view.setUint32(offset + 20, leaf.fontId[1], true); // font-id (UUID part 2)
|
||||
view.setUint32(offset + 24, leaf.fontId[2], true); // font-id (UUID part 3)
|
||||
view.setUint32(offset + 28, leaf.fontId[3], true); // font-id (UUID part 4)
|
||||
view.setUint32(offset + 32, leaf.fontFamilyHash, true); // font-family hash
|
||||
view.setUint32(offset + 36, leaf.fontVariantId[0], true); // font-variant-id (UUID part 1)
|
||||
view.setUint32(offset + 40, leaf.fontVariantId[1], true); // font-variant-id (UUID part 2)
|
||||
view.setUint32(offset + 44, leaf.fontVariantId[2], true); // font-variant-id (UUID part 3)
|
||||
view.setUint32(offset + 48, leaf.fontVariantId[3], true); // font-variant-id (UUID part 4)
|
||||
view.setUint32(offset + 52, leaf.textByteLength, true); // text-length
|
||||
view.setUint32(offset + 56, leaf.fills.length, true); // total fills count
|
||||
|
||||
leaf.fills.forEach((fill, index) => {
|
||||
const fillOffset =
|
||||
offset + TextLeaf.BYTE_LENGTH + index * Fill.BYTE_LENGTH;
|
||||
if (fill.type === Fill.Type.SOLID) {
|
||||
view.setUint8(fillOffset + 0, fill.type, true);
|
||||
view.setUint32(fillOffset + 4, fill.solid.color.argb32, true);
|
||||
}
|
||||
});
|
||||
|
||||
offset += leaf.leafByteLength;
|
||||
}
|
||||
|
||||
const textBuffer = paragraph.textBuffer;
|
||||
this.#module.set(ptr + offset, textBuffer.byteLength, textBuffer);
|
||||
this.#module.call("set_shape_text_content");
|
||||
}
|
||||
this.#module.call("update_shape_text_layout");
|
||||
}
|
||||
}
|
||||
|
||||
#setupShapes() {
|
||||
const textShape = new Shape({
|
||||
type: Shape.Type.Text,
|
||||
selrect: new Rect(
|
||||
new Point(canvas.width, canvas.height),
|
||||
new Point(0, 0),
|
||||
),
|
||||
textContent: TextContent.fromDOM(
|
||||
this.#textEditor.root,
|
||||
this.#fontManager,
|
||||
),
|
||||
});
|
||||
const rootShape = new Shape({
|
||||
id: UUID.ZERO,
|
||||
childrenIds: [textShape.id],
|
||||
});
|
||||
this.#shapes.set("text", textShape);
|
||||
this.#shapes.set("root", rootShape);
|
||||
this.#setShape(textShape);
|
||||
this.#setShape(rootShape);
|
||||
}
|
||||
|
||||
#setupTextEditor() {
|
||||
this.#textEditor.addEventListener("needslayout", this.#onNeedsLayout);
|
||||
this.#textEditor.addEventListener("stylechange", this.#onStyleChange);
|
||||
}
|
||||
|
||||
async setup() {
|
||||
this.#setupCanvasContext();
|
||||
this.#setupCanvas();
|
||||
this.#setupInteraction();
|
||||
this.#setupTextEditor();
|
||||
await this.#setupFonts();
|
||||
this.#setupShapes();
|
||||
this.#setupUI();
|
||||
}
|
||||
|
||||
#debouncedRender = debounce(
|
||||
() => this.#module.call("render", Date.now()),
|
||||
16,
|
||||
);
|
||||
|
||||
render() {
|
||||
this.#module.call(
|
||||
"set_view",
|
||||
this.#viewport.zoom,
|
||||
this.#viewport.x,
|
||||
this.#viewport.y,
|
||||
);
|
||||
this.#module.call("render_from_cache");
|
||||
this.#debouncedRender();
|
||||
}
|
||||
}
|
||||
|
||||
const module = await initWasmModule();
|
||||
const canvas = document.getElementById("canvas");
|
||||
|
||||
const textEditorPlayground = new TextEditorPlayground(module, canvas, {
|
||||
shouldUpdatePositionOnScroll: true
|
||||
});
|
||||
await textEditorPlayground.setup();
|
||||
textEditorPlayground.render();
|
||||
129
frontend/text-editor/src/playground/color.js
Normal file
129
frontend/text-editor/src/playground/color.js
Normal file
@@ -0,0 +1,129 @@
|
||||
export class ColorChannel {
|
||||
#value = 0.0;
|
||||
|
||||
constructor(value = 0.0) {
|
||||
this.#value = value || 0.0;
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this.#value;
|
||||
}
|
||||
|
||||
set value(newValue) {
|
||||
if (!Number.isFinite(newValue))
|
||||
throw new TypeError("Invalid color channel value");
|
||||
this.#value = newValue;
|
||||
}
|
||||
|
||||
get valueAsUint8() {
|
||||
return Math.max(0, Math.min(0xff, Math.floor(this.#value * 0xff)));
|
||||
}
|
||||
|
||||
set valueAsUint8(newValue) {
|
||||
if (!Number.isFinite(newValue))
|
||||
throw new TypeError("Invalid color channel value as Uint8");
|
||||
this.#value = Math.max(0, Math.min(0xff, Math.floor(newValue))) / 0xff;
|
||||
}
|
||||
|
||||
toString(format) {
|
||||
switch (format) {
|
||||
case "hex":
|
||||
return this.valueAsUint8.toString(16).padStart(2, "0");
|
||||
default:
|
||||
return this.valueAsUint8.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class Color {
|
||||
static fromHex(string) {
|
||||
if (string.length === 4 || string.length === 5) {
|
||||
const hr = string.slice(1, 2);
|
||||
const hg = string.slice(2, 3);
|
||||
const hb = string.slice(3, 4);
|
||||
const ha = string.length === 5 ? string.slice(4, 5) : "f";
|
||||
const r = parseInt(hr + hr, 16);
|
||||
const g = parseInt(hg + hg, 16);
|
||||
const b = parseInt(hb + hb, 16);
|
||||
const a = parseInt(ha + ha, 16);
|
||||
return Color.fromUint8(r, g, b, a);
|
||||
} else if (string.length === 7 || string.length === 9) {
|
||||
const r = parseInt(string.slice(1, 3), 16);
|
||||
const g = parseInt(string.slice(3, 5), 16);
|
||||
const b = parseInt(string.slice(5, 7), 16);
|
||||
const a = parseInt(string.length === 9 ? string.slice(7, 9) : "ff", 16);
|
||||
return Color.fromUint8(r, g, b, a);
|
||||
} else {
|
||||
throw new TypeError("Invalid hex string");
|
||||
}
|
||||
}
|
||||
|
||||
static fromUint8(r, g, b, a) {
|
||||
return new Color(r / 0xff, g / 0xff, b / 0xff, a / 0xff);
|
||||
}
|
||||
|
||||
static parse(string) {
|
||||
if (string.startsWith("#")) {
|
||||
return Color.fromHex(string);
|
||||
} else if (string.startsWith("rgb")) {
|
||||
throw new Error("Not implemented");
|
||||
} else if (string.startsWith("hsl")) {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
}
|
||||
|
||||
#r = new ColorChannel(0);
|
||||
#g = new ColorChannel(0);
|
||||
#b = new ColorChannel(0);
|
||||
#a = new ColorChannel(1);
|
||||
|
||||
constructor(r = 0, g = 0, b = 0, a = 1) {
|
||||
this.#r.value = r || 0;
|
||||
this.#g.value = g || 0;
|
||||
this.#b.value = b || 0;
|
||||
this.#a.value = a || 1;
|
||||
}
|
||||
|
||||
get r() {
|
||||
return this.#r.value;
|
||||
}
|
||||
get g() {
|
||||
return this.#g.value;
|
||||
}
|
||||
get b() {
|
||||
return this.#b.value;
|
||||
}
|
||||
get a() {
|
||||
return this.#a.value;
|
||||
}
|
||||
|
||||
set r(newValue) {
|
||||
this.#r.value = newValue;
|
||||
}
|
||||
set g(newValue) {
|
||||
this.#g.value = newValue;
|
||||
}
|
||||
set b(newValue) {
|
||||
this.#b.value = newValue;
|
||||
}
|
||||
set a(newValue) {
|
||||
this.#a.value = newValue;
|
||||
}
|
||||
|
||||
get r8() {
|
||||
return this.#r.valueAsUint8;
|
||||
}
|
||||
get g8() {
|
||||
return this.#g.valueAsUint8;
|
||||
}
|
||||
get b8() {
|
||||
return this.#b.valueAsUint8;
|
||||
}
|
||||
get a8() {
|
||||
return this.#a.valueAsUint8;
|
||||
}
|
||||
|
||||
get argb32() {
|
||||
return ((this.a8 << 24) | (this.r8 << 16) | (this.g8 << 8) | this.b8) >>> 0;
|
||||
}
|
||||
}
|
||||
39
frontend/text-editor/src/playground/fill.js
Normal file
39
frontend/text-editor/src/playground/fill.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Color } from "./color.js";
|
||||
|
||||
export class FillSolid {
|
||||
color = new Color();
|
||||
|
||||
get opacity() {
|
||||
return this.color.a;
|
||||
}
|
||||
|
||||
set opacity(newOpacity) {
|
||||
this.color.a = newOpacity;
|
||||
}
|
||||
}
|
||||
|
||||
export class Fill {
|
||||
static BYTE_LENGTH = 160;
|
||||
|
||||
static Type = {
|
||||
SOLID: 0,
|
||||
LINEAR_GRADIENT: 1,
|
||||
RADIAL_GRADIENT: 2,
|
||||
IMAGE: 3,
|
||||
};
|
||||
|
||||
#type;
|
||||
#solid = new FillSolid();
|
||||
|
||||
constructor(type = Fill.Type.SOLID) {
|
||||
this.#type = type;
|
||||
}
|
||||
|
||||
get type() {
|
||||
return this.#type;
|
||||
}
|
||||
|
||||
get solid() {
|
||||
return this.#solid;
|
||||
}
|
||||
}
|
||||
177
frontend/text-editor/src/playground/font.js
Normal file
177
frontend/text-editor/src/playground/font.js
Normal file
@@ -0,0 +1,177 @@
|
||||
import { UUID } from "./uuid.js";
|
||||
import { fromStyle } from "./style.js";
|
||||
|
||||
export const FontStyle = {
|
||||
NORMAL: 0,
|
||||
REGULAR: 0,
|
||||
ITALIC: 1,
|
||||
OBLIQUE: 1,
|
||||
|
||||
fromStyle,
|
||||
};
|
||||
|
||||
export class FontData {
|
||||
#id;
|
||||
#url;
|
||||
#font;
|
||||
#name;
|
||||
#styleName;
|
||||
#extension;
|
||||
#weight;
|
||||
#style;
|
||||
#arrayBuffer;
|
||||
|
||||
constructor(init) {
|
||||
this.#id = new UUID();
|
||||
this.#font = init.font;
|
||||
this.#url = `fonts/${this.#font}`;
|
||||
const [name, styleNameAndExtension] = this.#font.split("-");
|
||||
const [styleName, extension] = styleNameAndExtension.split(".");
|
||||
const isItalic = styleName.endsWith("Italic");
|
||||
this.#styleName = styleName;
|
||||
this.#extension = extension;
|
||||
this.#name = name;
|
||||
this.#weight = styleName.replaceAll("Italic", "");
|
||||
this.#style = isItalic ? "italic" : "normal";
|
||||
this.#arrayBuffer = init.arrayBuffer;
|
||||
}
|
||||
|
||||
get id() {
|
||||
return this.#id;
|
||||
}
|
||||
get url() {
|
||||
return this.#url;
|
||||
}
|
||||
get extension() {
|
||||
return this.#extension;
|
||||
}
|
||||
get name() {
|
||||
return this.#name;
|
||||
}
|
||||
get weight() {
|
||||
return this.#weight;
|
||||
}
|
||||
get style() {
|
||||
return this.#style;
|
||||
}
|
||||
get arrayBuffer() {
|
||||
return this.#arrayBuffer;
|
||||
}
|
||||
|
||||
get styleAsNumber() {
|
||||
return FontStyle.fromStyle(this.#style) ?? 0;
|
||||
}
|
||||
|
||||
get weightAsNumber() {
|
||||
if (this.#styleName.startsWith("Thin")) {
|
||||
return 100;
|
||||
} else if (this.#styleName.startsWith("ExtraLight")) {
|
||||
return 200;
|
||||
} else if (this.#styleName.startsWith("Light")) {
|
||||
return 300;
|
||||
} else if (this.#styleName.startsWith("Regular")) {
|
||||
return 400;
|
||||
} else if (this.#styleName.startsWith("Medium")) {
|
||||
return 500;
|
||||
} else if (this.#styleName.startsWith("SemiBold")) {
|
||||
return 600;
|
||||
} else if (this.#styleName.startsWith("Bold")) {
|
||||
return 700;
|
||||
} else if (this.#styleName.startsWith("ExtraBold")) {
|
||||
return 800;
|
||||
} else if (this.#styleName.startsWith("Black")) {
|
||||
return 900;
|
||||
} else {
|
||||
return 400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class FontManager {
|
||||
#module;
|
||||
#fonts;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {WASMModule} module
|
||||
*/
|
||||
constructor(module) {
|
||||
this.#module = module;
|
||||
}
|
||||
|
||||
get fonts() {
|
||||
return this.#fonts;
|
||||
}
|
||||
|
||||
async #loadFontList() {
|
||||
const response = await fetch("fonts/fonts.txt");
|
||||
const text = await response.text();
|
||||
const fonts = text.split("\n").filter((l) => !!l);
|
||||
return fonts;
|
||||
}
|
||||
|
||||
async #loadFonts() {
|
||||
const fontData = new Map();
|
||||
const fonts = await this.#loadFontList();
|
||||
for (const font of fonts) {
|
||||
const response = await fetch(`fonts/${font}`);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const newFontData = new FontData({
|
||||
font: font,
|
||||
arrayBuffer,
|
||||
});
|
||||
if (!fontData.has(newFontData.name)) {
|
||||
fontData.set(newFontData.name, []);
|
||||
}
|
||||
const currentFontData = fontData.get(newFontData.name);
|
||||
currentFontData.push(newFontData);
|
||||
}
|
||||
return fontData;
|
||||
}
|
||||
|
||||
#store(fonts) {
|
||||
for (const [fontName, fontStyles] of fonts) {
|
||||
for (const font of fontStyles) {
|
||||
const shapeId = UUID.ZERO;
|
||||
const fontId = font.id;
|
||||
const weight = font.weightAsNumber;
|
||||
const style = font.styleAsNumber;
|
||||
const size = font.arrayBuffer.byteLength;
|
||||
const ptr = this.#module.call("alloc_bytes", size);
|
||||
this.#module.set(ptr, size, new Uint8Array(font.arrayBuffer));
|
||||
const emoji = false;
|
||||
const fallback = false;
|
||||
this.#module.call(
|
||||
"store_font",
|
||||
shapeId[0],
|
||||
shapeId[1],
|
||||
shapeId[2],
|
||||
shapeId[3],
|
||||
fontId[0],
|
||||
fontId[1],
|
||||
fontId[2],
|
||||
fontId[3],
|
||||
weight,
|
||||
style,
|
||||
emoji,
|
||||
fallback,
|
||||
);
|
||||
this.#module.call(
|
||||
"is_font_uploaded",
|
||||
fontId[0],
|
||||
fontId[1],
|
||||
fontId[2],
|
||||
fontId[3],
|
||||
weight,
|
||||
style,
|
||||
emoji,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async load() {
|
||||
this.#fonts = await this.#loadFonts();
|
||||
this.#store(this.#fonts);
|
||||
}
|
||||
}
|
||||
244
frontend/text-editor/src/playground/geom.js
Normal file
244
frontend/text-editor/src/playground/geom.js
Normal file
@@ -0,0 +1,244 @@
|
||||
export class Point {
|
||||
static create(x = 0.0, y = 0.0) {
|
||||
return new Point(x, y);
|
||||
}
|
||||
|
||||
constructor(x = 0.0, y = 0.0) {
|
||||
this.x = x || 0.0;
|
||||
this.y = y || 0.0;
|
||||
}
|
||||
|
||||
get length() {
|
||||
return Math.hypot(this.x, this.y);
|
||||
}
|
||||
|
||||
get lengthSquared() {
|
||||
return this.x * this.x + this.y * this.y;
|
||||
}
|
||||
|
||||
get angle() {
|
||||
return Math.atan2(this.y, this.x);
|
||||
}
|
||||
|
||||
set(x, y) {
|
||||
this.x = x ?? this.x;
|
||||
this.y = y ?? this.y;
|
||||
return this;
|
||||
}
|
||||
|
||||
reset() {
|
||||
return this.set(0, 0);
|
||||
}
|
||||
|
||||
copy({ x, y }) {
|
||||
return this.set(x, y);
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new Point(this.x, this.y);
|
||||
}
|
||||
|
||||
polar(angle, length = 1.0) {
|
||||
return this.set(
|
||||
Math.cos(angle) * length,
|
||||
Math.sin(angle) * length
|
||||
);
|
||||
}
|
||||
|
||||
add({ x, y }) {
|
||||
return this.set(this.x + x, this.y + y);
|
||||
}
|
||||
|
||||
addScaled({ x, y }, sx, sy = sx) {
|
||||
return this.set(this.x + x * sx, this.y + y * sy);
|
||||
}
|
||||
|
||||
subtract({ x, y }) {
|
||||
return this.set(this.x - x, this.y - y);
|
||||
}
|
||||
|
||||
multiply({ x, y }) {
|
||||
return this.set(this.x * x, this.y * y);
|
||||
}
|
||||
|
||||
divide({ x, y }) {
|
||||
return this.set(this.x / x, this.y / y);
|
||||
}
|
||||
|
||||
scale(sx, sy = sx) {
|
||||
return this.set(this.x * sx, this.y * sy);
|
||||
}
|
||||
|
||||
rotate(angle) {
|
||||
const c = Math.cos(angle);
|
||||
const s = Math.sin(angle);
|
||||
return this.set(c * this.x - s * this.y, s * this.x + c * this.y);
|
||||
}
|
||||
|
||||
perpLeft() {
|
||||
return this.set(this.y, -this.x);
|
||||
}
|
||||
|
||||
perpRight() {
|
||||
return this.set(-this.y, this.x);
|
||||
}
|
||||
|
||||
normalize() {
|
||||
const length = this.length;
|
||||
return this.set(this.x / length, this.y / length);
|
||||
}
|
||||
|
||||
negate() {
|
||||
return this.set(-this.x, -this.y);
|
||||
}
|
||||
|
||||
dot({ x, y }) {
|
||||
return this.x * x + this.y * y;
|
||||
}
|
||||
|
||||
cross({ x, y }) {
|
||||
return this.x * y + this.y * x;
|
||||
}
|
||||
|
||||
angleTo({ x, y }) {
|
||||
return Math.atan2(this.y - y, this.x - x);
|
||||
}
|
||||
|
||||
distanceTo({ x, y }) {
|
||||
return Math.hypot(this.x - x, this.y - y);
|
||||
}
|
||||
|
||||
toFixed(fractionDigits = 0) {
|
||||
return `Point(${this.x.toFixed(fractionDigits)}, ${this.y.toFixed(fractionDigits)})`;
|
||||
}
|
||||
|
||||
toString() {
|
||||
return `Point(${this.x}, ${this.y})`;
|
||||
}
|
||||
}
|
||||
|
||||
export class Rect {
|
||||
static create(x, y, width, height) {
|
||||
return new Rect(
|
||||
new Point(width, height),
|
||||
new Point(x, y),
|
||||
);
|
||||
}
|
||||
|
||||
#size;
|
||||
#position;
|
||||
|
||||
constructor(size = new Point(), position = new Point()) {
|
||||
this.#size = size ?? new Point();
|
||||
this.#position = position ?? new Point();
|
||||
}
|
||||
|
||||
get x() {
|
||||
return this.#position.x;
|
||||
}
|
||||
|
||||
set x(newValue) {
|
||||
this.#position.x = newValue;
|
||||
}
|
||||
|
||||
get y() {
|
||||
return this.#position.y;
|
||||
}
|
||||
|
||||
set y(newValue) {
|
||||
this.#position.y = newValue;
|
||||
}
|
||||
|
||||
get width() {
|
||||
return this.#size.x;
|
||||
}
|
||||
|
||||
set width(newValue) {
|
||||
this.#size.x = newValue;
|
||||
}
|
||||
|
||||
get height() {
|
||||
return this.#size.y;
|
||||
}
|
||||
|
||||
set height(newValue) {
|
||||
this.#size.y = newValue;
|
||||
}
|
||||
|
||||
get left() {
|
||||
return this.#position.x;
|
||||
}
|
||||
|
||||
set left(newValue) {
|
||||
this.#position.x = newValue;
|
||||
}
|
||||
|
||||
get right() {
|
||||
return this.#position.x + this.#size.x;
|
||||
}
|
||||
|
||||
set right(newValue) {
|
||||
this.#size.x = newValue - this.#position.x;
|
||||
}
|
||||
|
||||
get top() {
|
||||
return this.#position.y;
|
||||
}
|
||||
|
||||
set top(newValue) {
|
||||
this.#position.y = newValue;
|
||||
}
|
||||
|
||||
get bottom() {
|
||||
return this.#position.y + this.#size.y;
|
||||
}
|
||||
|
||||
set bottom(newValue) {
|
||||
this.#size.y = newValue - this.#position.y;
|
||||
}
|
||||
|
||||
get aspectRatio() {
|
||||
return this.width / this.height;
|
||||
}
|
||||
|
||||
get isHorizontal() {
|
||||
return this.width > this.height;
|
||||
}
|
||||
|
||||
get isVertical() {
|
||||
return this.width < this.height;
|
||||
}
|
||||
|
||||
get isSquare() {
|
||||
return this.width === this.height;
|
||||
}
|
||||
|
||||
set(x, y, width, height) {
|
||||
this.#position.set(x, y);
|
||||
this.#size.set(width, height);
|
||||
return this;
|
||||
}
|
||||
|
||||
reset() {
|
||||
return this.set(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
copy({ x, y, width, height }) {
|
||||
return this.set(x, y, width, height);
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new Rect(
|
||||
this.#size.clone(),
|
||||
this.#position.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
toFixed(fractionDigits = 0) {
|
||||
return `Rect(${this.x.toFixed(fractionDigits)}, ${this.y.toFixed(fractionDigits)}, ${this.width.toFixed(fractionDigits)}, ${this.height.toFixed(fractionDigits)})`;
|
||||
}
|
||||
|
||||
toString() {
|
||||
return `Rect(${this.x}, ${this.y}, ${this.width}, ${this.height})`;
|
||||
}
|
||||
}
|
||||
94
frontend/text-editor/src/playground/shape.js
Normal file
94
frontend/text-editor/src/playground/shape.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import { UUID } from "./uuid.js";
|
||||
import { Rect } from "./geom.js";
|
||||
import { TextContent } from "./text.js";
|
||||
|
||||
export class Shape {
|
||||
static Type = {
|
||||
Frame: 0,
|
||||
Group: 1,
|
||||
Bool: 2,
|
||||
Rect: 3,
|
||||
Path: 4,
|
||||
Text: 5,
|
||||
Circle: 6,
|
||||
SVGRaw: 7,
|
||||
isType(type) {
|
||||
return Object.values(this).includes(type);
|
||||
},
|
||||
};
|
||||
|
||||
static RootId = UUID.ZERO;
|
||||
|
||||
#id;
|
||||
#type;
|
||||
#parentId;
|
||||
#selrect;
|
||||
#childrenIds = [];
|
||||
#textContent = null;
|
||||
#rotation = 0;
|
||||
|
||||
constructor(init) {
|
||||
this.#id = init?.id ?? new UUID();
|
||||
if (this.#id && !(this.#id instanceof UUID)) {
|
||||
throw new TypeError("Invalid shape id");
|
||||
}
|
||||
this.#type = init?.type ?? Shape.Type.Rect;
|
||||
if (!Shape.Type.isType(this.#type)) {
|
||||
throw new TypeError("Invalid shape type");
|
||||
}
|
||||
this.#parentId = init?.parentId ?? Shape.RootId;
|
||||
if (this.#parentId && !(this.#parentId instanceof UUID)) {
|
||||
throw new TypeError("Invalid shape parent id");
|
||||
}
|
||||
this.#selrect = init?.selrect ?? new Rect();
|
||||
if (this.#selrect && !(this.#selrect instanceof Rect)) {
|
||||
throw new TypeError("Invalid shape selrect");
|
||||
}
|
||||
this.#childrenIds = init?.childrenIds ?? [];
|
||||
if (
|
||||
!Array.isArray(this.#childrenIds) ||
|
||||
!this.#childrenIds.every((id) => id instanceof UUID)
|
||||
) {
|
||||
throw new TypeError("Invalid shape children ids");
|
||||
}
|
||||
this.#textContent = init?.textContent ?? null;
|
||||
if (this.#textContent && !(this.#textContent instanceof TextContent)) {
|
||||
throw new TypeError("Invalid shape text content");
|
||||
}
|
||||
}
|
||||
|
||||
get id() {
|
||||
return this.#id;
|
||||
}
|
||||
|
||||
get type() {
|
||||
return this.#type;
|
||||
}
|
||||
|
||||
get parentId() {
|
||||
return this.#parentId;
|
||||
}
|
||||
|
||||
get selrect() {
|
||||
return this.#selrect;
|
||||
}
|
||||
|
||||
get childrenIds() {
|
||||
return this.#childrenIds;
|
||||
}
|
||||
|
||||
get textContent() {
|
||||
return this.#textContent;
|
||||
}
|
||||
|
||||
get rotation() {
|
||||
return this.#rotation
|
||||
}
|
||||
|
||||
set rotation(newRotation) {
|
||||
if (!Number.isFinite(newRotation)) {
|
||||
throw new TypeError('Invalid rotation')
|
||||
}
|
||||
this.#rotation = newRotation
|
||||
}
|
||||
}
|
||||
14
frontend/text-editor/src/playground/style.js
Normal file
14
frontend/text-editor/src/playground/style.js
Normal file
@@ -0,0 +1,14 @@
|
||||
export function fromStyleValue(styleValue) {
|
||||
return styleValue.replaceAll("-', '_").toUpperCase();
|
||||
}
|
||||
|
||||
export function fromStyle(style) {
|
||||
const entry = Object.entries(this).find(([name, value]) =>
|
||||
name === fromStyleValue(style) ? value : 0,
|
||||
);
|
||||
if (!entry)
|
||||
return;
|
||||
|
||||
const [name] = entry;
|
||||
return name;
|
||||
}
|
||||
250
frontend/text-editor/src/playground/text.js
Normal file
250
frontend/text-editor/src/playground/text.js
Normal file
@@ -0,0 +1,250 @@
|
||||
import { UUID } from "./uuid.js";
|
||||
import { fromStyle } from "./style.js";
|
||||
import { FontStyle } from "./font.js";
|
||||
import { Fill } from "./fill.js";
|
||||
|
||||
export const TextAlign = {
|
||||
LEFT: 0,
|
||||
CENTER: 1,
|
||||
RIGHT: 2,
|
||||
JUSTIFY: 3,
|
||||
|
||||
fromStyle,
|
||||
};
|
||||
|
||||
export const TextDirection = {
|
||||
LTR: 0,
|
||||
RTL: 1,
|
||||
|
||||
fromStyle,
|
||||
};
|
||||
|
||||
export const TextDecoration = {
|
||||
NONE: 0,
|
||||
UNDERLINE: 1,
|
||||
LINE_THROUGH: 2,
|
||||
OVERLINE: 3,
|
||||
|
||||
fromStyle,
|
||||
};
|
||||
|
||||
export const TextTransform = {
|
||||
NONE: 0,
|
||||
UPPERCASE: 1,
|
||||
LOWERCASE: 2,
|
||||
CAPITALIZE: 3,
|
||||
|
||||
fromStyle,
|
||||
};
|
||||
|
||||
export class TextLeaf {
|
||||
static BYTE_LENGTH = 60;
|
||||
|
||||
static fromDOM(leafElement, fontManager) {
|
||||
const elementStyle = leafElement.style; //window.getComputedStyle(leafElement);
|
||||
const fontSize = parseFloat(
|
||||
elementStyle.getPropertyValue("font-size"),
|
||||
);
|
||||
const fontStyle =
|
||||
FontStyle.fromStyle(elementStyle.getPropertyValue("font-style")) ??
|
||||
FontStyle.NORMAL;
|
||||
const fontWeight = parseInt(
|
||||
elementStyle.getPropertyValue("font-weight"),
|
||||
);
|
||||
const letterSpacing = parseFloat(
|
||||
elementStyle.getPropertyValue("letter-spacing"),
|
||||
);
|
||||
const fontFamily = elementStyle.getPropertyValue("font-family");
|
||||
console.log("fontFamily", fontFamily);
|
||||
const fontStyles = fontManager.fonts.get(
|
||||
fontFamily,
|
||||
);
|
||||
const textDecoration = TextDecoration.fromStyle(
|
||||
elementStyle.getPropertyValue("text-decoration"),
|
||||
);
|
||||
const textTransform = TextTransform.fromStyle(
|
||||
elementStyle.getPropertyValue("text-transform"),
|
||||
);
|
||||
const textDirection = TextDirection.fromStyle(
|
||||
elementStyle.getPropertyValue("text-direction"),
|
||||
);
|
||||
console.log(fontWeight, fontStyle);
|
||||
const font = fontStyles.find(
|
||||
(currentFontStyle) =>
|
||||
currentFontStyle.weightAsNumber === fontWeight &&
|
||||
currentFontStyle.styleAsNumber === fontStyle,
|
||||
);
|
||||
if (!font) {
|
||||
throw new Error(`Invalid font "${fontFamily}"`)
|
||||
}
|
||||
return new TextLeaf({
|
||||
fontId: font.id, // leafElement.style.getPropertyValue("--font-id"),
|
||||
fontFamilyHash: 0,
|
||||
fontVariantId: UUID.ZERO, // leafElement.style.getPropertyValue("--font-variant-id"),
|
||||
fontStyle,
|
||||
fontSize,
|
||||
fontWeight,
|
||||
letterSpacing,
|
||||
textDecoration,
|
||||
textTransform,
|
||||
textDirection,
|
||||
text: leafElement.textContent,
|
||||
});
|
||||
}
|
||||
|
||||
fontId = UUID.ZERO;
|
||||
fontFamilyHash = 0;
|
||||
fontVariantId = UUID.ZERO;
|
||||
fontStyle = 0;
|
||||
fontSize = 16;
|
||||
fontWeight = 400;
|
||||
letterSpacing = 0.0;
|
||||
textDecoration = 0;
|
||||
textTransform = 0;
|
||||
textDirection = 0;
|
||||
#text = "";
|
||||
fills = [new Fill()];
|
||||
|
||||
constructor(init) {
|
||||
this.fontId = init?.fontId ?? UUID.ZERO;
|
||||
this.fontStyle = init?.fontStyle ?? 0;
|
||||
this.fontSize = init?.fontSize ?? 16;
|
||||
this.fontWeight = init?.fontWeight ?? 400;
|
||||
this.letterSpacing = init?.letterSpacing ?? 0;
|
||||
this.textDecoration = init?.textDecoration ?? 0;
|
||||
this.textTransform = init?.textTransform ?? 0;
|
||||
this.textDirection = init?.textDirection ?? 0;
|
||||
this.#text = init?.text ?? "";
|
||||
this.fills = init?.fills ?? [new Fill()];
|
||||
}
|
||||
|
||||
get text() {
|
||||
return this.#text;
|
||||
}
|
||||
|
||||
set text(newText) {
|
||||
this.#text = newText;
|
||||
}
|
||||
|
||||
get textByteLength() {
|
||||
const text = this.text;
|
||||
const textEncoder = new TextEncoder();
|
||||
const textBuffer = textEncoder.encode(text);
|
||||
return textBuffer.byteLength;
|
||||
}
|
||||
|
||||
get leafByteLength() {
|
||||
return this.fills.length * Fill.BYTE_LENGTH + TextLeaf.BYTE_LENGTH;
|
||||
}
|
||||
}
|
||||
|
||||
export class TextParagraph {
|
||||
static BYTE_LENGTH = 48;
|
||||
|
||||
static fromDOM(paragraphElement, fontManager) {
|
||||
return new TextParagraph({
|
||||
textAlign: TextAlign.fromStyle(
|
||||
paragraphElement.style.getPropertyValue("text-align"),
|
||||
),
|
||||
textDecoration: TextDecoration.fromStyle(
|
||||
paragraphElement.style.getPropertyValue("text-decoration"),
|
||||
),
|
||||
textTransform: TextTransform.fromStyle(
|
||||
paragraphElement.style.getPropertyValue("text-transform"),
|
||||
),
|
||||
textDirection: TextDirection.fromStyle(
|
||||
paragraphElement.style.getPropertyValue("text-direction"),
|
||||
),
|
||||
lineHeight: parseFloat(
|
||||
paragraphElement.style.getPropertyValue("line-height"),
|
||||
),
|
||||
letterSpacing: parseFloat(
|
||||
paragraphElement.style.getPropertyValue("letter-spacing"),
|
||||
),
|
||||
leaves: Array.from(paragraphElement.children, (leafElement) =>
|
||||
TextLeaf.fromDOM(leafElement, fontManager),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
#leaves = [];
|
||||
textAlign = 0;
|
||||
textDecoration = 0;
|
||||
textTransform = 0;
|
||||
textDirection = 0;
|
||||
lineHeight = 1.2;
|
||||
letterSpacing = 0;
|
||||
typographyRefFile = UUID.ZERO;
|
||||
typographyRefId = UUID.ZERO;
|
||||
|
||||
constructor(init) {
|
||||
this.textAlign = init?.textAlign ?? TextAlign.LEFT;
|
||||
this.textDecoration = init?.textDecoration ?? TextDecoration.NONE;
|
||||
this.textTransform = init?.textTransform ?? TextTransform.NONE;
|
||||
this.textDirection = init?.textDirection ?? TextDirection.LTR;
|
||||
this.lineHeight = init?.lineHeight ?? 1.2;
|
||||
this.letterSpacing = init?.letterSpacing ?? 0.0;
|
||||
this.typographyRefFile = init?.typographyRefFile ?? UUID.ZERO;
|
||||
this.typographyRefId = init?.typographyRefId ?? UUID.ZERO;
|
||||
this.#leaves = init?.leaves ?? [];
|
||||
if (
|
||||
!Array.isArray(this.#leaves) ||
|
||||
!this.#leaves.every((leaf) => leaf instanceof TextLeaf)
|
||||
) {
|
||||
throw new TypeError("Invalid text leaves");
|
||||
}
|
||||
}
|
||||
|
||||
get leaves() {
|
||||
return this.#leaves;
|
||||
}
|
||||
|
||||
get text() {
|
||||
return this.#leaves.reduce((acc, leaf) => acc + leaf.text, "");
|
||||
}
|
||||
|
||||
get textBuffer() {
|
||||
const textEncoder = new TextEncoder();
|
||||
const textBuffer = textEncoder.encode(this.text);
|
||||
return textBuffer;
|
||||
}
|
||||
|
||||
get byteLength() {
|
||||
return (
|
||||
this.#leaves.reduce((acc, leaf) => acc + leaf.leafByteLength, 0) +
|
||||
this.textBuffer.byteLength +
|
||||
TextParagraph.BYTE_LENGTH
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class TextContent {
|
||||
static fromDOM(rootElement, fontManager) {
|
||||
const paragraphs = Array.from(rootElement.children, (paragraph) =>
|
||||
TextParagraph.fromDOM(paragraph, fontManager),
|
||||
);
|
||||
return new TextContent({ paragraphs });
|
||||
}
|
||||
|
||||
#paragraphs = [];
|
||||
|
||||
constructor(init) {
|
||||
this.#paragraphs = init?.paragraphs ?? [];
|
||||
if (
|
||||
!Array.isArray(this.#paragraphs) ||
|
||||
!this.#paragraphs.every((paragraph) => paragraph instanceof TextParagraph)
|
||||
) {
|
||||
throw new TypeError("Invalid text paragraphs");
|
||||
}
|
||||
}
|
||||
|
||||
get paragraphs() {
|
||||
return this.#paragraphs;
|
||||
}
|
||||
|
||||
updateFromDOM(rootElement, fontManager) {
|
||||
this.#paragraphs = Array.from(rootElement.children, (paragraph) =>
|
||||
TextParagraph.fromDOM(paragraph, fontManager),
|
||||
);
|
||||
}
|
||||
}
|
||||
47
frontend/text-editor/src/playground/uuid.js
Normal file
47
frontend/text-editor/src/playground/uuid.js
Normal file
@@ -0,0 +1,47 @@
|
||||
export class UUID extends Uint32Array {
|
||||
static BYTE_LENGTH = this.BYTES_PER_ELEMENT * 4;
|
||||
|
||||
/**
|
||||
* @type {UUID}
|
||||
*/
|
||||
static ZERO = new UUID("00000000-0000-0000-0000-000000000000");
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param {string} [id]
|
||||
*/
|
||||
constructor(id = crypto.randomUUID()) {
|
||||
super(4);
|
||||
const hex = id.replace(/-/g, "");
|
||||
for (let i = 0; i < this.length; i++) {
|
||||
this[i] = parseInt(hex.slice(i * 8, (i + 1) * 8), 16);
|
||||
}
|
||||
}
|
||||
|
||||
[Symbol.toPrimitive]() {
|
||||
return this.toString();
|
||||
}
|
||||
|
||||
valueOf() {
|
||||
return this.toString();
|
||||
}
|
||||
|
||||
toString() {
|
||||
let str = "";
|
||||
for (let i = 0; i < this.length; i++) {
|
||||
str += this[i].toString(16).padStart(8, "0");
|
||||
}
|
||||
return (
|
||||
str.slice(0, 8) +
|
||||
"-" +
|
||||
str.slice(8, 12) +
|
||||
"-" +
|
||||
str.slice(12, 16) +
|
||||
"-" +
|
||||
str.slice(16, 20) +
|
||||
"-" +
|
||||
str.slice(20)
|
||||
);
|
||||
}
|
||||
}
|
||||
44
frontend/text-editor/src/playground/viewport.js
Normal file
44
frontend/text-editor/src/playground/viewport.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Point } from './geom';
|
||||
|
||||
export class Viewport {
|
||||
#zoom;
|
||||
#position = new Point();
|
||||
|
||||
constructor(init) {
|
||||
this.#zoom = init?.zoom || 1;
|
||||
this.#position.x = init?.x || 0;
|
||||
this.#position.y = init?.y || 0;
|
||||
}
|
||||
|
||||
get zoom() {
|
||||
return this.#zoom;
|
||||
}
|
||||
|
||||
get x() {
|
||||
return this.#position.x;
|
||||
}
|
||||
|
||||
get y() {
|
||||
return this.#position.y;
|
||||
}
|
||||
|
||||
set zoom(newZoom) {
|
||||
if (!Number.isFinite(newZoom)) throw new TypeError("Invalid new zoom");
|
||||
this.#zoom = newZoom;
|
||||
}
|
||||
|
||||
set x(newX) {
|
||||
if (!Number.isFinite(newX)) throw new TypeError("Invalid new x");
|
||||
this.#position.x = newX;
|
||||
}
|
||||
|
||||
set y(newY) {
|
||||
if (!Number.isFinite(newY)) throw new TypeError("Invalid new y");
|
||||
this.#position.y = newY;
|
||||
}
|
||||
|
||||
pan(dx, dy) {
|
||||
this.#position.x += dx / this.#zoom
|
||||
this.#position.y += dy / this.#zoom
|
||||
}
|
||||
}
|
||||
52
frontend/text-editor/src/playground/wasm.js
Normal file
52
frontend/text-editor/src/playground/wasm.js
Normal file
@@ -0,0 +1,52 @@
|
||||
export class WASMModuleWrapper {
|
||||
#module;
|
||||
|
||||
constructor(module) {
|
||||
this.#module = module;
|
||||
}
|
||||
|
||||
get module() {
|
||||
return this.#module;
|
||||
}
|
||||
|
||||
get gl() {
|
||||
return this.#module.GL;
|
||||
}
|
||||
|
||||
get heapu8() {
|
||||
return this.#module.HEAPU8;
|
||||
}
|
||||
|
||||
get heapu32() {
|
||||
return this.#module.HEAPU32;
|
||||
}
|
||||
|
||||
set(ptr, size, data, byteSize = data instanceof Uint32Array ? 4 : 1) {
|
||||
const heap = byteSize === 4 ? this.#module.HEAPU32 : this.#module.HEAPU8;
|
||||
const typedArray = byteSize === 4 ? Uint32Array : Uint8Array;
|
||||
const length = size / byteSize;
|
||||
const mem = new typedArray(heap.buffer, ptr, length);
|
||||
mem.set(data);
|
||||
}
|
||||
|
||||
viewOf(ptr, size) {
|
||||
return new DataView(this.#module.HEAPU8.buffer, ptr, size);
|
||||
}
|
||||
|
||||
registerContext(canvas, contextId, contextAttributes) {
|
||||
const gl = this.gl;
|
||||
const context = canvas.getContext(contextId, contextAttributes);
|
||||
const handle = gl.registerContext(context, { majorVersion: 2 });
|
||||
gl.makeContextCurrent(handle);
|
||||
return context;
|
||||
}
|
||||
|
||||
call(name, ...args) {
|
||||
const _name = `_${name}`;
|
||||
if (!(_name in this.#module)) {
|
||||
throw new Error(`${name} not found in WASM module`);
|
||||
}
|
||||
console.log("Calling", name, ...args);
|
||||
return this.#module[_name](...args);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,49 @@
|
||||
:root {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user