From 6316fd21b9c26e17ee70c0fe36999be7601ba761 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Apr 2026 20:06:43 +0000 Subject: [PATCH] =?UTF-8?q?Add=20Mouse=20&=20Cheese=20demo=20=E2=80=94=20o?= =?UTF-8?q?bstacle-aware=20text=20layout=20with=20cute=20illustration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new demo page (/demos/mouse-cheese) showing Pretext's layoutNextLine() used for per-line variable-width layout: each story line gets a narrower available width while it overlaps the cheese block, so the text naturally wraps around the illustration. A slider resizes the cheese in real time and the text reflows without any DOM measurements in the resize path. The cheese and mouse are inline SVGs (cute cartoon style). The demo is added to the demos index grid alongside the existing nine demos. https://claude.ai/code/session_01P3CMU8eN1riSUg4EzCaMcA --- pages/demos/index.html | 5 + pages/demos/mouse-cheese.html | 215 ++++++++++++++++++++++++++++++++++ pages/demos/mouse-cheese.ts | 181 ++++++++++++++++++++++++++++ 3 files changed, 401 insertions(+) create mode 100644 pages/demos/mouse-cheese.html create mode 100644 pages/demos/mouse-cheese.ts diff --git a/pages/demos/index.html b/pages/demos/index.html index c03d4a80..76e54794 100644 --- a/pages/demos/index.html +++ b/pages/demos/index.html @@ -141,6 +141,11 @@

Markdown Chat

Masonry

A text-card occlusion demo where height prediction comes from Pretext instead of DOM reads.

+ + +

Mouse & Cheese

+

A story that wraps around a cheese obstacle in real time — resize the cheese, watch the lines reflow.

+
diff --git a/pages/demos/mouse-cheese.html b/pages/demos/mouse-cheese.html new file mode 100644 index 00000000..99d5d8bc --- /dev/null +++ b/pages/demos/mouse-cheese.html @@ -0,0 +1,215 @@ + + + + + +Mouse & Cheese — Pretext Demo + + + +
+

Demo

+

Mouse & Cheese

+

+ The story text wraps around the cheese in real time — no DOM measurements + in the layout path. Drag the slider to resize the cheese and watch the + lines reflow. +

+ +
+ Cheese size: + + 140px +
+ +
+ +
+ + +
+ +
+ + + +
+
+ + + + diff --git a/pages/demos/mouse-cheese.ts b/pages/demos/mouse-cheese.ts new file mode 100644 index 00000000..57249a82 --- /dev/null +++ b/pages/demos/mouse-cheese.ts @@ -0,0 +1,181 @@ +import { prepareWithSegments, layoutNextLine, type LayoutCursor, type PreparedTextWithSegments } from '../../src/layout.ts' + +const STORY = + 'Once upon a time, a very hungry mouse crept through a quiet kitchen. ' + + 'His tiny nose twitched with the unmistakable scent of aged Gruyère. ' + + 'There, on the wooden counter, sat the most magnificent wedge of cheese ' + + 'he had ever seen — golden, dotted with perfect round holes, glowing like ' + + 'a small sun in the afternoon light. He froze. His whiskers quivered. His ' + + 'heart thumped like a tiny drum. Could this be real? He had dreamed of such ' + + 'a cheese every night, curled up in his little nest behind the baseboard. ' + + 'Round. Warm. Salty. Perfect. He took one careful step forward, then another. ' + + 'The cheese did not move. He took one deep breath and made a decision: ' + + 'today, at long last, the cheese would be his.' + +// Gap between the right edge of the text and the left edge of the cheese +const CHEESE_GAP = 14 +// Padding inside the panel (must match the CSS padding on .panel) +const PANEL_PADDING = 28 +// Line height — must match the CSS on .text-layer (16px/26px) +const LINE_H = 26 + +type State = { + scheduledRaf: number | null + prepared: PreparedTextWithSegments | null + preparedFont: string +} + +const st: State = { + scheduledRaf: null, + prepared: null, + preparedFont: '', +} + +function getRequiredElement(id: string): HTMLElement { + const el = document.getElementById(id) + if (!(el instanceof HTMLElement)) throw new Error(`#${id} not found`) + return el +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot, { once: true }) +} else { + boot() +} + +function boot(): void { + const slider = getRequiredElement('cheese-slider') + const scene = getRequiredElement('scene') + const cheeseWrap = getRequiredElement('cheese-wrap') + const textLayer = getRequiredElement('text-layer') + const sizeLabel = getRequiredElement('cheese-size-label') + + if (!(slider instanceof HTMLInputElement)) return + + function scheduleRender(): void { + if (st.scheduledRaf !== null) return + st.scheduledRaf = requestAnimationFrame(() => { + st.scheduledRaf = null + render(slider, scene, cheeseWrap, textLayer, sizeLabel) + }) + } + + slider.addEventListener('input', () => { + sizeLabel.textContent = `${slider.value}px` + scheduleRender() + }) + + window.addEventListener('resize', scheduleRender) + + document.fonts.ready.then(() => { + scheduleRender() + }) + + scheduleRender() +} + +function getFontString(el: HTMLElement): string { + const styles = getComputedStyle(el) + if (styles.font.length > 0) return styles.font + return ( + `${styles.fontStyle} ${styles.fontVariant} ${styles.fontWeight} ` + + `${styles.fontSize} / ${styles.lineHeight} ${styles.fontFamily}` + ) +} + +function getPrepared(font: string): PreparedTextWithSegments { + if (st.prepared !== null && st.preparedFont === font) return st.prepared + st.preparedFont = font + st.prepared = prepareWithSegments(STORY, font) + return st.prepared +} + +function availableWidth( + lineY: number, + containerW: number, + cheeseTop: number, + cheeseBottom: number, + cheeseLeft: number, +): number { + const lineBottom = lineY + LINE_H + if (lineY < cheeseBottom && lineBottom > cheeseTop) { + // This line overlaps the cheese vertically — narrow it so text stays left of cheese + return Math.max(60, cheeseLeft - CHEESE_GAP) + } + return containerW +} + +function render( + slider: HTMLInputElement, + scene: HTMLElement, + cheeseWrap: HTMLElement, + textLayer: HTMLElement, + sizeLabel: HTMLElement, +): void { + const sceneRect = scene.getBoundingClientRect() + const containerW = Math.floor(sceneRect.width) - PANEL_PADDING * 2 + if (containerW < 60) return + + const cheeseSize = Number(slider.value) + sizeLabel.textContent = `${cheeseSize}px` + + // Cheese position relative to the text layer (top-right of the panel content area) + const CHEESE_TOP = 0 // cheese starts at the top of the text area + const cheeseBottom = CHEESE_TOP + cheeseSize + const cheeseLeft = containerW - cheeseSize // right-flush within the content area + + // Update the cheese element size (CSS custom property on the panel) + scene.style.setProperty('--cheese-size', `${cheeseSize}px`) + + // Get the font from the text layer so Pretext uses the same face/size the browser renders + const font = getFontString(textLayer) + const prepared = getPrepared(font) + + // Walk lines with per-line widths using layoutNextLine() + let cursor: LayoutCursor = { segmentIndex: 0, graphemeIndex: 0 } + let y = 0 + const lines: Array<{ text: string; y: number; w: number }> = [] + + while (true) { + const w = availableWidth(y, containerW, CHEESE_TOP, cheeseBottom, cheeseLeft) + const line = layoutNextLine(prepared, cursor, w) + if (line === null) break + lines.push({ text: line.text, y, w }) + cursor = line.end + y += LINE_H + if (y > 1200) break // safety cap + } + + // Flush lines into DOM — reuse existing span elements where possible + const children = textLayer.children + const childArray = Array.from(children) as HTMLElement[] + + for (let i = 0; i < lines.length; i++) { + const lineData = lines[i]! + let el = childArray[i] + if (!(el instanceof HTMLSpanElement)) { + el = document.createElement('span') + el.style.cssText = + 'display:block;position:absolute;left:0;white-space:nowrap;overflow:visible;' + textLayer.appendChild(el) + childArray.push(el) + } + el.textContent = lineData.text + el.style.top = `${lineData.y}px` + el.hidden = false + } + + // Hide any extra spans from a previous render with more lines + for (let i = lines.length; i < childArray.length; i++) { + const el = childArray[i] + if (el instanceof HTMLElement) el.hidden = true + } + + // Size the text layer so the panel is tall enough + const textHeight = y + textLayer.style.height = `${textHeight}px` + + // Ensure the panel is tall enough to show text + mouse illustration + const minPanelHeight = textHeight + PANEL_PADDING * 2 + 20 + scene.style.minHeight = `${Math.max(380, minPanelHeight)}px` +}