Files
penpot/frontend/text-editor/src/playground.js
2025-10-16 12:59:24 +02:00

579 lines
20 KiB
JavaScript

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