Editable text outlines
Full-page experience · Open playground
Type text, generate font outlines, and drag their control points.
Try it
Section titled “Try it”Type a word, generate its outline, then drag gold or white points.
What to learn
Section titled “What to learn”- Shape text with HarfBuzz into editable curves.
- Manipulate glyph outlines without reshaping during dragging.
Integration boundaries
Section titled “Integration boundaries”- Font loading is asynchronous; conversion and point editing are synchronous.
- The font handle is disposed when the example is unmounted.
This is a Pibbl Canvas example. Its mount owns its runtime resources; call its controller’s dispose method when removing it. The preview starts automatically unless reduced motion is enabled; Stop disposes the mount. Preserve the source’s logical canvas size and aspect ratio. Native HTML is not captured by Canvas filters.
Related APIs
Section titled “Related APIs”Complete source
Section titled “Complete source”Host setup for this source: use a canvas with width 900 and height 480, compile with jsx: "react-jsx" and jsxImportSource: "@pibbl/core", import its default mount, and call const controller = mount(canvas) after attaching the canvas. Call controller.dispose() before removing it.
text-geometry.example.tsx
Section titled “text-geometry.example.tsx”import { pibbl, Path, Circle, Text, Rectangle, signal, type PibblController, type PathGeometry, type PathSegment, type PibblPointerEvent } from '@pibbl/core';import { loadOutlineFont, createTextGeometry, type OutlineFont } from '@pibbl/text';
export default function mount(canvas: HTMLCanvasElement): PibblController { const abort = new AbortController(); const form = document.createElement('form'); const input = document.createElement('input'); const button = document.createElement('button'); const reset = document.createElement('button'); const status = document.createElement('span'); form.style.cssText = 'display:flex;gap:10px;align-items:center;flex-wrap:wrap;padding:14px;background:#16232d;color:#dbe7ea;font:14px sans-serif'; input.setAttribute('aria-label', 'Text to turn into an outline'); input.value = 'Hello Pibbl'; input.maxLength = 36; input.style.cssText = 'font:inherit;padding:10px;border:1px solid #8da1aa;border-radius:6px;min-width:220px'; button.type = 'button'; button.textContent = 'Generate outline'; button.disabled = true; reset.textContent = 'Reset points'; reset.type = 'button'; reset.disabled = true; for (const el of [button, reset]) el.style.cssText = 'font:inherit;padding:10px;border-radius:6px;cursor:pointer'; status.setAttribute('role', 'status'); status.textContent = 'Loading font…'; form.append(input, button, reset, status); canvas.before(form); const previousTouch = canvas.style.touchAction; canvas.style.touchAction = 'none'; const generation = signal(0); let geometry: PathGeometry | undefined, original: PathGeometry | undefined; let font: OutlineFont | undefined, disposed = false; let drag: { pointer: number; segment: number; xKey: string; yKey: string } | undefined; const stop = (event: PibblPointerEvent) => { if (drag?.pointer !== event.pointerId) return; drag = undefined; if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); }; function Scene() { generation.get(); const handles = []; let index = 0; for (const segment of geometry ?? []) { const segmentIndex = index++; const keys = segment.type === 'quadratic' ? [['cpx', 'cpy'], ['x', 'y']] : segment.type === 'cubic' ? [['cp1x', 'cp1y'], ['cp2x', 'cp2y'], ['x', 'y']] : segment.type === 'move' || segment.type === 'line' ? [['x', 'y']] : []; for (const [xKey, yKey] of keys) { const values = segment as unknown as Record<string, number>; handles.push(<Circle key={`${segmentIndex}:${xKey}`} style={{ cx: values[xKey], cy: values[yKey], radius: xKey === 'x' ? 3 : 4, fill: xKey === 'x' ? '#e8f5f1' : '#ffc46a', stroke: '#243c46', strokeWidth: 1, cursor: 'grab' }} onPointerDown={event => { event.currentTarget.setPointerCapture(event.pointerId); drag = { pointer: event.pointerId, segment: segmentIndex, xKey, yKey }; }} onPointerMove={event => { if (!geometry || drag?.pointer !== event.pointerId || drag.segment !== segmentIndex || drag.xKey !== xKey) return; const current = geometry.getSegment(segmentIndex); geometry.setSegment(segmentIndex, { ...current, [xKey]: event.x, [yKey]: event.y } as PathSegment); generation.update(value => value + 1); status.textContent = `Point moved · revision ${geometry.revision}`; }} onPointerUp={stop} onPointerCancel={stop} onLostPointerCapture={() => { drag = undefined; }} />); } } return <> <Rectangle style={{ width: 900, height: 480, fill: '#102029' }} pointerEvents="none" /> <Text style={{ left: 28, top: 28, font: '700 25px sans-serif', fill: '#f0f7f5' }} pointerEvents="none">TYPE IT. SHAPE IT.</Text> <Text style={{ left: 28, top: 65, font: '15px sans-serif', fill: '#a4bdc4' }} pointerEvents="none">Generate an outline, then drag the gold control points or white endpoints.</Text> {geometry && <Path style={{ d: geometry, fill: '#59bca8', stroke: '#a0f0d6', strokeWidth: 1 }} pointerEvents="none" />} {handles} <Text style={{ left: 28, top: 435, font: '14px monospace', fill: '#a4bdc4' }} pointerEvents="none">{`${geometry?.segmentCount ?? 0} editable segments · no font shaping during dragging`}</Text> </>; } const generate = () => { if (!font || disposed) return; try { const start = performance.now(); const result = createTextGeometry(font, input.value, { fontSize: 130 }); const scale = Math.min(1, 820 / Math.max(1, result.advance.x)); result.geometry.transform({ a: scale, d: scale, e: 38, f: 290 }); geometry = result.geometry; original = geometry.clone(); drag = undefined; generation.update(value => value + 1); reset.disabled = false; status.setAttribute('role', 'status'); status.style.cssText = ''; status.textContent = `${result.glyphs.length} glyphs · advance ${result.advance.x.toFixed(1)} · ink ${result.inkBounds ? result.inkBounds.width.toFixed(1) + " × " + result.inkBounds.height.toFixed(1) : "empty"} · ${(performance.now() - start).toFixed(1)} ms`; } catch (error) { geometry = original = undefined; drag = undefined; reset.disabled = true; generation.update(value => value + 1); status.setAttribute('role', 'alert'); status.style.cssText = 'flex-basis:100%;padding:10px;border:1px solid #ffb4a9;border-radius:6px;background:#482b2b;color:#ffe1db'; status.textContent = `Cannot generate outline: ${error instanceof Error ? error.message : String(error)} Try text supported by this demo font; most emoji are not supported.`; } }; button.onclick = generate; input.onkeydown = event => { if (event.key === "Enter") { event.preventDefault(); generate(); } }; form.onsubmit = event => { event.preventDefault(); }; reset.onclick = () => { if (original) { geometry = original.clone(); drag = undefined; generation.update(value => value + 1); } }; let controller: PibblController; const dispose = () => { if (disposed) return; disposed = true; abort.abort(); font?.dispose(); font = undefined; controller?.dispose(); form.remove(); canvas.style.touchAction = previousTouch; geometry = original = undefined; form.onsubmit = null; reset.onclick = null; button.onclick = null; input.onkeydown = null; }; try { controller = pibbl(canvas, <Scene />); } catch (error) { dispose(); throw error; } void loadOutlineFont(new URL('/pibbl-text/ABeeZee-Regular.ttf', document.baseURI), { signal: abort.signal }).then(value => { if (disposed) { value.dispose(); return; } font = value; button.disabled = false; generate(); }).catch(error => { if (!disposed) status.textContent = `Font loading failed: ${String(error)}`; }); return { dispose };}Documentation version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision 2dccb19. ALPHA — NOT FOR PRODUCTION USE.