diff --git a/frontend/text-editor/.gitignore b/frontend/text-editor/.gitignore index db3424bb9f..ad94b410e1 100644 --- a/frontend/text-editor/.gitignore +++ b/frontend/text-editor/.gitignore @@ -340,3 +340,5 @@ $RECYCLE.BIN/ /playwright/.cache/ vite.config.js.timestamp* + +render_wasm.* diff --git a/frontend/text-editor/package.json b/frontend/text-editor/package.json index 865bc5d5a1..b31fc91b7f 100644 --- a/frontend/text-editor/package.json +++ b/frontend/text-editor/package.json @@ -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", diff --git a/frontend/text-editor/src/wasm.html b/frontend/text-editor/src/wasm.html new file mode 100644 index 0000000000..700d38a363 --- /dev/null +++ b/frontend/text-editor/src/wasm.html @@ -0,0 +1,502 @@ + + + + + + + + + + Penpot - Text Editor Playground + + + +
+
+ Styles + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ Debug +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+
+
+ +
+ + + + + diff --git a/frontend/text-editor/src/wasm/lib.js b/frontend/text-editor/src/wasm/lib.js new file mode 100644 index 0000000000..022a163bf6 --- /dev/null +++ b/frontend/text-editor/src/wasm/lib.js @@ -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(); +} diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index c232e2a34b..8310fe0b3b 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -529,11 +529,13 @@ impl TryFrom<&[u8]> for RawTextLeaf { } #[allow(dead_code)] +#[repr(C)] #[derive(Debug, Clone)] pub struct RawTextLeafData { font_style: u8, text_decoration: u8, text_transform: u8, + byte_padding: u8, font_size: f32, font_weight: i32, font_id: [u32; 4], @@ -563,6 +565,7 @@ impl From<&[u8]> for RawTextLeafData { font_style: text_leaf.font_style, text_decoration: text_leaf.text_decoration, text_transform: text_leaf.text_transform, + byte_padding: 0, font_size: text_leaf.font_size, font_weight: text_leaf.font_weight, font_id: text_leaf.font_id, diff --git a/render-wasm/src/wasm/text.rs b/render-wasm/src/wasm/text.rs index fdfec34f05..f581d06933 100644 --- a/render-wasm/src/wasm/text.rs +++ b/render-wasm/src/wasm/text.rs @@ -20,7 +20,6 @@ pub extern "C" fn set_shape_text_content() { .add_paragraph(raw_text_data.paragraph) .expect("Failed to add paragraph"); }); - mem::free_bytes(); }