mirror of
https://github.com/penpot/penpot.git
synced 2025-12-11 22:14:05 +01:00
🎉 Add Text Editor WASM Playground
This commit is contained in:
committed by
Elena Torro
parent
b12d44150b
commit
15eee0d8d8
2
frontend/text-editor/.gitignore
vendored
2
frontend/text-editor/.gitignore
vendored
@@ -340,3 +340,5 @@ $RECYCLE.BIN/
|
||||
/playwright/.cache/
|
||||
|
||||
vite.config.js.timestamp*
|
||||
|
||||
render_wasm.*
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"coverage": "vitest run --coverage",
|
||||
"wasm:update": "cp ../resources/public/js/render_wasm.wasm ./src/wasm/render_wasm.wasm && cp ../resources/public/js/render_wasm.js ./src/wasm/render_wasm.js",
|
||||
"test": "vitest --run",
|
||||
"test:watch": "vitest",
|
||||
"test:watch:ui": "vitest --ui",
|
||||
|
||||
502
frontend/text-editor/src/wasm.html
Normal file
502
frontend/text-editor/src/wasm.html
Normal file
@@ -0,0 +1,502 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300..800;1,300..800&family=Playwrite+ES:wght@100..400&family=Playwrite+NZ:wght@100..400&family=Playwrite+US+Trad:wght@100..400&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap" rel="stylesheet">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Penpot - Text Editor Playground</title>
|
||||
<style>
|
||||
#output {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
<!--
|
||||
|
||||
Text output
|
||||
|
||||
-->
|
||||
<div id="output"></div>
|
||||
<canvas id="canvas"></canvas>
|
||||
<script type="module">
|
||||
import "./style.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
|
||||
} from './wasm/lib.js';
|
||||
|
||||
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": "sourcesanspro",
|
||||
"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": '["~#\'","sourcesanspro"]',
|
||||
"--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");
|
||||
|
||||
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);
|
||||
}
|
||||
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 = 120;
|
||||
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) => {
|
||||
console.log("needs layout");
|
||||
useShape(uuid);
|
||||
console.log("useShape", uuid);
|
||||
updateTextShape(fontSize, textEditor.root);
|
||||
console.log("updateTextShape", fontSize);
|
||||
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);
|
||||
|
||||
|
||||
useShape(uuid);
|
||||
Module._set_parent(0, 0, 0, 0);
|
||||
Module._set_shape_type(5);
|
||||
|
||||
const x1 = 0;
|
||||
const y1 = 0;
|
||||
const width = 1000;
|
||||
const height = 500;
|
||||
Module._set_shape_selrect(x1, y1, x1 + width, y1 + height);
|
||||
|
||||
/*
|
||||
if (Math.random() < 0.3) {
|
||||
const numStrokes = getRandomInt(1, 3);
|
||||
for (let j = 0; j < numStrokes; j++) {
|
||||
const strokeWidth = getRandomInt(1, 10);
|
||||
Module._add_shape_center_stroke(strokeWidth, 0, 0, 0);
|
||||
const color = getRandomColor();
|
||||
const argb2 = hexToU32ARGB(color, getRandomFloat(0.1, 1.0));
|
||||
addShapeSolidStrokeFill(argb2);
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
addTextShape(fontSize, "Hello, World!");
|
||||
|
||||
useShape("00000000-0000-0000-0000-000000000000");
|
||||
setShapeChildren(children);
|
||||
render()
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
541
frontend/text-editor/src/wasm/lib.js
Normal file
541
frontend/text-editor/src/wasm/lib.js
Normal file
@@ -0,0 +1,541 @@
|
||||
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() {
|
||||
console.log('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 'oblique':
|
||||
case 'italic':
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function updateTextShape(fontSize, root) {
|
||||
const paragraphAttrSize = 48;
|
||||
const leafAttrSize = 56;
|
||||
const fillSize = 160;
|
||||
|
||||
// Calculate fills
|
||||
const fills = [
|
||||
{
|
||||
type: "solid",
|
||||
color: "#ff00ff",
|
||||
opacity: 1.0,
|
||||
},
|
||||
];
|
||||
|
||||
const totalFills = fills.length;
|
||||
const totalFillsSize = totalFills * fillSize;
|
||||
|
||||
const paragraphs = root.children;
|
||||
console.log("paragraphs", paragraphs.length);
|
||||
|
||||
Module._clear_shape_text();
|
||||
for (const paragraph of paragraphs) {
|
||||
let totalSize = paragraphAttrSize;
|
||||
|
||||
const leaves = paragraph.children;
|
||||
const numLeaves = leaves.length;
|
||||
console.log("leaves", numLeaves);
|
||||
|
||||
for (const leaf of leaves) {
|
||||
const text = leaf.textContent;
|
||||
const textBuffer = new TextEncoder().encode(text);
|
||||
const textSize = textBuffer.byteLength;
|
||||
console.log("text", text, textSize);
|
||||
totalSize += leafAttrSize + totalFillsSize;
|
||||
}
|
||||
|
||||
totalSize += paragraph.textContent.length;
|
||||
|
||||
console.log("Total Size", totalSize);
|
||||
// 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"),
|
||||
);
|
||||
console.log("text-align", textAlign);
|
||||
const textDirection = getTextDirection(
|
||||
paragraph.style.getPropertyValue("text-direction"),
|
||||
);
|
||||
console.log("text-direction", textDirection);
|
||||
const textDecoration = getTextDecoration(
|
||||
paragraph.style.getPropertyValue("text-decoration"),
|
||||
);
|
||||
console.log("text-decoration", textDecoration);
|
||||
const textTransform = getTextTransform(
|
||||
paragraph.style.getPropertyValue("text-transform"),
|
||||
);
|
||||
console.log("text-transform", textTransform);
|
||||
const lineHeight = parseFloat(
|
||||
paragraph.style.getPropertyValue("line-height"),
|
||||
);
|
||||
console.log("line-height", lineHeight);
|
||||
const letterSpacing = parseFloat(
|
||||
paragraph.style.getPropertyValue("letter-spacing"),
|
||||
);
|
||||
console.log("letter-spacing", letterSpacing);
|
||||
|
||||
/*
|
||||
num_leaves: u32,
|
||||
text_align: u8,
|
||||
text_direction: u8,
|
||||
text_decoration: u8,
|
||||
text_transform: u8,
|
||||
line_height: f32,
|
||||
letter_spacing: f32,
|
||||
typography_ref_file: [u32; 4],
|
||||
typography_ref_id: [u32; 4],
|
||||
*/
|
||||
|
||||
// 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)
|
||||
|
||||
let leafOffset = paragraphAttrSize;
|
||||
for (const leaf of leaves) {
|
||||
console.log(
|
||||
"leafOffset",
|
||||
leafOffset,
|
||||
paragraphAttrSize,
|
||||
leafAttrSize,
|
||||
fillSize,
|
||||
totalFills,
|
||||
totalFillsSize,
|
||||
);
|
||||
const fontStyle = getFontStyle(leaf.style.getPropertyValue("font-style"));
|
||||
const fontSize = parseFloat(leaf.style.getPropertyValue("font-size"));
|
||||
console.log("font-size", fontSize);
|
||||
const fontWeight = parseInt(
|
||||
leaf.style.getPropertyValue("font-weight"),
|
||||
10,
|
||||
);
|
||||
console.log("font-weight", fontWeight);
|
||||
|
||||
const text = leaf.textContent;
|
||||
const textBuffer = new TextEncoder().encode(text);
|
||||
const textSize = textBuffer.byteLength;
|
||||
|
||||
// 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.setFloat32(leafOffset + 4, fontSize, true); // font-size
|
||||
dview.setInt32(leafOffset + 8, fontWeight, true); // font-weight: normal
|
||||
dview.setUint32(leafOffset + 12, 0, true); // font-id (UUID part 1)
|
||||
dview.setUint32(leafOffset + 16, 0, true); // font-id (UUID part 2)
|
||||
dview.setUint32(leafOffset + 20, 0, true); // font-id (UUID part 3)
|
||||
dview.setUint32(leafOffset + 24, 0, true); // font-id (UUID part 4)
|
||||
dview.setUint32(leafOffset + 28, 0, true); // font-family hash
|
||||
dview.setUint32(leafOffset + 32, 0, true); // font-variant-id (UUID part 1)
|
||||
dview.setUint32(leafOffset + 36, 0, true); // font-variant-id (UUID part 2)
|
||||
dview.setUint32(leafOffset + 40, 0, true); // font-variant-id (UUID part 3)
|
||||
dview.setUint32(leafOffset + 44, 0, true); // font-variant-id (UUID part 4)
|
||||
dview.setUint32(leafOffset + 48, textSize, true); // text-length
|
||||
dview.setUint32(leafOffset + 52, totalFills, true); // total fills count
|
||||
|
||||
// Serialize fills
|
||||
let fillOffset = leafOffset + leafAttrSize;
|
||||
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 += fillSize; // Move to the next fill
|
||||
}
|
||||
});
|
||||
leafOffset += leafAttrSize + totalFillsSize;
|
||||
}
|
||||
|
||||
const text = paragraph.textContent;
|
||||
const textBuffer = new TextEncoder().encode(text);
|
||||
|
||||
// Add text content
|
||||
const textOffset = leafOffset;
|
||||
console.log('textOffset', textOffset);
|
||||
heap.set(textBuffer, textOffset);
|
||||
|
||||
Module._set_shape_text_content();
|
||||
}
|
||||
}
|
||||
|
||||
export function addTextShape(fontSize, text) {
|
||||
const numLeaves = 1; // Single text leaf for simplicity
|
||||
const paragraphAttrSize = 48;
|
||||
const leafAttrSize = 56;
|
||||
const fillSize = 160;
|
||||
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 * fillSize;
|
||||
|
||||
// Calculate metadata and total buffer size
|
||||
const metadataSize = paragraphAttrSize + leafAttrSize + 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);
|
||||
|
||||
// Set number of leaves
|
||||
dview.setUint32(0, numLeaves, true);
|
||||
|
||||
// Serialize paragraph attributes
|
||||
dview.setUint8(4, 1); // text-align: left
|
||||
dview.setUint8(5, 0); // text-direction: LTR
|
||||
dview.setUint8(6, 0); // text-decoration: none
|
||||
dview.setUint8(7, 0); // text-transform: none
|
||||
dview.setFloat32(8, 1.2, true); // line-height
|
||||
dview.setFloat32(12, 0, 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.setInt32(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.setInt32(44, 0, true); // typography-ref-id (UUID part 4)
|
||||
|
||||
// Serialize leaf attributes
|
||||
const leafOffset = paragraphAttrSize;
|
||||
dview.setUint8(leafOffset, 0); // font-style: normal
|
||||
dview.setFloat32(leafOffset + 4, fontSize, true); // font-size
|
||||
dview.setUint32(leafOffset + 8, 400, true); // font-weight: normal
|
||||
dview.setUint32(leafOffset + 12, 0, true); // font-id (UUID part 1)
|
||||
dview.setUint32(leafOffset + 16, 0, true); // font-id (UUID part 2)
|
||||
dview.setUint32(leafOffset + 20, 0, true); // font-id (UUID part 3)
|
||||
dview.setInt32(leafOffset + 24, 0, true); // font-id (UUID part 4)
|
||||
dview.setInt32(leafOffset + 28, 0, true); // font-family hash
|
||||
dview.setUint32(leafOffset + 32, 0, true); // font-variant-id (UUID part 1)
|
||||
dview.setUint32(leafOffset + 36, 0, true); // font-variant-id (UUID part 2)
|
||||
dview.setUint32(leafOffset + 40, 0, true); // font-variant-id (UUID part 3)
|
||||
dview.setInt32(leafOffset + 44, 0, true); // font-variant-id (UUID part 4)
|
||||
dview.setInt32(leafOffset + 48, textSize, true); // text-length
|
||||
dview.setInt32(leafOffset + 52, totalFills, true); // total fills count
|
||||
|
||||
// Serialize fills
|
||||
let fillOffset = leafOffset + leafAttrSize;
|
||||
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 += fillSize; // Move to the next fill
|
||||
}
|
||||
});
|
||||
|
||||
// Add text content
|
||||
const textOffset = metadataSize;
|
||||
heap.set(textBuffer, textOffset);
|
||||
|
||||
// Call the WebAssembly function
|
||||
Module._set_shape_text_content();
|
||||
}
|
||||
Reference in New Issue
Block a user