{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"editorial-engine","type":"registry:ui","title":"EditorialEngine","description":"Live magazine layout: multi-column text flowing around draggable orbs with zero DOM reads, powered by pretext","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["text"],"dependencies":["@chenglou/pretext@^0.0.5"],"registryDependencies":["https://fancy-ui.rama.app/r/utils.json"],"docs":"Installs @chenglou/pretext as a dependency.","files":[{"path":"react/src/components/editorial-engine/EditorialEngine.tsx","content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { cn } from \"../../utils\";\nimport { createEditorialEngine } from \"./engine\";\nimport \"./editorial-engine.css\";\n\n/**\n * Props for EditorialEngine\n *\n * Live magazine layout: multi-column text flow with cursor handoff,\n * auto-fitted headline, drop cap, pullquotes, and body text reflowing in\n * real time around draggable animated orbs. All line breaking is computed\n * from cached font metrics — the render loop performs zero DOM reads.\n */\nexport interface EditorialEngineProps {\n\t/** Headline, auto-sized to the widest font that breaks no word */\n\theadline?: string;\n\t/** Body copy (plain text, paragraphs separated by blank lines) */\n\tbody?: string;\n\t/** Up to two pullquotes embedded in the columns */\n\tpullquotes?: string[];\n\t/** Font family stack used for all text */\n\tfontFamily?: string;\n\t/** Additional CSS classes for the stage */\n\tclassName?: string;\n}\n\nconst DEFAULT_HEADLINE = \"TEXT THAT FLOWS AROUND ANYTHING\";\n\nconst DEFAULT_BODY = `Print designers have flowed text around images, illustrations, and odd-shaped figures for more than a century. The web never caught up. CSS gives us float, and more recently shape-outside, but both only push text away from one side of one static box. Flowing text through multiple columns, around several moving obstacles at once, filling the gaps on both sides — the browser has no answer for that.\n\nThe reason is not a lack of imagination. It is the cost of asking. Every question about text — how wide is this word, where does this line break, how tall is this paragraph — must go through the browser's layout engine, and the layout engine answers by reflowing the document. Ask too often and the interface stutters. So we learned not to ask, and our text stayed in rigid rectangles.\n\nThis page asks hundreds of times per frame. Every line of text you are reading was positioned by arithmetic, not by the browser. The pretext library measured every word once, up front, against a canvas font engine. After that, layout is pure math: given a width, it returns line breaks, line widths, and a cursor pointing at exactly where the text stopped.\n\nThat cursor is what makes columns work. The first column consumes text until it runs out of vertical space, then hands its cursor to the second column, which continues mid-sentence without duplication or gaps. Newspapers have done this on paper forever; on the web it has always required hidden elements and fragile content splitting.\n\nThe orbs drifting across the page are obstacles, not decoration. Each frame, the engine intersects every text line's vertical band with every orb, subtracts the blocked intervals from the column width, and fills every remaining slot — text flows on both sides of an orb at once, something shape-outside cannot express at all.\n\nDrag an orb and the paragraphs part around it in real time. Click one and it pauses. Resize the window and the headline re-fits itself, searching for the largest font size that breaks no word across lines — a binary search that costs microseconds because each probe is arithmetic, not reflow.\n\nThe drop cap at the top of this article is another obstacle: a rectangle carved out of the first column's first three lines. The pullquotes are the same trick at a larger scale. Everything you see is one mechanism — carve the blocked intervals out of each line's band, lay out text in whatever space remains.\n\nWhen the engine finishes a frame, it writes positions and text content to a pool of absolutely positioned elements, and nothing else. The browser never computes layout for this page's body text, because every coordinate is already known. A full reflow of every line around five moving obstacles typically costs a fraction of a millisecond.\n\nCheap text measurement changes what interfaces can be. Virtualized lists can know every item's exact height before rendering it. Chat bubbles can shrink-wrap to their tightest width. Labels can dodge chart elements as you zoom. Documents can flow around widgets that other people are dragging in real time.\n\nNone of this needs a new browser API or a standards process. It needs cached font metrics, a line breaker, and the willingness to stop asking the DOM questions it answers slowly. The text on this page stopped asking. And so it flows.`;\n\nconst DEFAULT_PULLQUOTES = [\n\t\"“Text flows on both sides of a moving obstacle at once — something shape-outside cannot express at all.”\",\n\t\"“Every coordinate is already known. The browser never computes layout for this text.”\",\n];\n\nconst DEFAULT_FONT_FAMILY =\n\t'\"Iowan Old Style\", \"Palatino Linotype\", \"Book Antiqua\", Palatino, serif';\n\nexport function EditorialEngine({\n\theadline = DEFAULT_HEADLINE,\n\tbody = DEFAULT_BODY,\n\tpullquotes = DEFAULT_PULLQUOTES,\n\tfontFamily = DEFAULT_FONT_FAMILY,\n\tclassName,\n}: EditorialEngineProps) {\n\t// The engine owns this layer exclusively, which is why the accessible\n\t// fallback is a sibling of it and not a child of the element the engine\n\t// writes into.\n\tconst layerRef = useRef<HTMLDivElement | null>(null);\n\tconst [ready, setReady] = useState(false);\n\n\t// The pullquote array is compared by CONTENT, not identity: a caller who\n\t// writes `pullquotes={[\"…\"]}` inline hands a new array on every render, and\n\t// an identity-keyed effect would tear the engine down and re-measure the\n\t// whole article each time. The Svelte source re-runs its `$effect` on a\n\t// genuine prop reassignment only, so keying on content is what preserves\n\t// its observable behaviour here.\n\tconst pullquotesKey = pullquotes.join(\"\\u0000\");\n\n\t// Re-created whenever the text props change, so the prepared measurements\n\t// always match the rendered content. Effects never run during SSR.\n\tuseEffect(() => {\n\t\tconst layer = layerRef.current;\n\t\tif (layer === null) return;\n\t\tconst options = { headline, body, pullquotes, fontFamily };\n\t\tlet destroy: (() => void) | null = null;\n\t\tlet cancelled = false;\n\n\t\t// Text is measured against the canvas font engine — fonts must be\n\t\t// loaded first or widths come from the fallback font.\n\t\tdocument.fonts.ready.then(() => {\n\t\t\tif (cancelled) return;\n\t\t\tdestroy = createEditorialEngine(layer, options);\n\t\t\tsetReady(true);\n\t\t});\n\n\t\treturn () => {\n\t\t\tcancelled = true;\n\t\t\tdestroy?.();\n\t\t};\n\t\t// `pullquotes` is read through `pullquotesKey`; see the note above.\n\t}, [headline, body, pullquotesKey, fontFamily]);\n\n\treturn (\n\t\t<div className={cn(\"ee-stage\", className, ready && \"ready\")} style={{ fontFamily }}>\n\t\t\t{/*\n\t\t\t * The accessible document, and the SSR / pre-engine fallback: visible\n\t\t\t * until the engine is ready, then visually hidden (clipped, not\n\t\t\t * `display: none`) but never unmounted. The positioned-line layer\n\t\t\t * below is one span per visual line, with no heading and no paragraph\n\t\t\t * boundaries, so this stays the only accessible copy.\n\t\t\t */}\n\t\t\t<div className={cn(\"ee-fallback\", ready && \"ee-sr-only\")}>\n\t\t\t\t<h2>{headline}</h2>\n\t\t\t\t<p>{body}</p>\n\t\t\t</div>\n\t\t\t<div ref={layerRef} className=\"ee-layer\" aria-hidden=\"true\" />\n\t\t</div>\n\t);\n}\n","type":"registry:ui","target":"@ui/fancy/components/editorial-engine/EditorialEngine.tsx"},{"path":"react/src/components/editorial-engine/editorial-engine.css","content":"/*\n * Every rule is anchored on `.ee-stage`, the root class the component already\n * carries. Svelte scoped these rules at compile time; plain CSS is global, so\n * the descendants (`.ee-line`, `.ee-orb`, …) are namespaced under the root\n * rather than shipped as bare class selectors.\n */\n\n.ee-stage {\n\tposition: relative;\n\twidth: 100%;\n\theight: 100%;\n\toverflow: hidden;\n\tbackground: radial-gradient(ellipse at 50% 40%, #0f0f14 0%, #0a0a0c 100%);\n\tcolor: #e8e4dc;\n\ttouch-action: pan-y pinch-zoom;\n}\n\n.ee-stage .ee-layer {\n\tposition: absolute;\n\tinset: 0;\n\ttouch-action: pan-y pinch-zoom;\n}\n\n.ee-stage .ee-fallback {\n\tposition: absolute;\n\tinset: 0;\n\tpadding: 48px;\n\toverflow: hidden;\n\topacity: 0.4;\n\tfont-size: 14px;\n}\n\n/*\n * Visually hidden, still exposed to assistive tech. Scoped rather than the\n * Tailwind `sr-only` utility because `.ee-stage .ee-fallback` above outranks\n * it on specificity and would re-inflate the box.\n */\n.ee-stage .ee-fallback.ee-sr-only {\n\tinset: auto;\n\twidth: 1px;\n\theight: 1px;\n\tmargin: -1px;\n\tpadding: 0;\n\tborder: 0;\n\tclip-path: inset(50%);\n\twhite-space: nowrap;\n}\n\n.ee-stage .ee-line {\n\tposition: absolute;\n\twhite-space: pre;\n\tz-index: 1;\n\tcolor: #e8e4dc;\n\tuser-select: text;\n\t-webkit-user-select: text;\n}\n\n.ee-stage .ee-headline-line {\n\tposition: absolute;\n\twhite-space: pre;\n\tfont-weight: 700;\n\tcolor: #fff;\n\tletter-spacing: -0.5px;\n\tz-index: 2;\n\tuser-select: text;\n\t-webkit-user-select: text;\n}\n\n.ee-stage .ee-drop-cap {\n\tposition: absolute;\n\tpointer-events: none;\n\tz-index: 2;\n\tfont-weight: 700;\n\tcolor: #c4a35a;\n}\n\n.ee-stage .ee-orb {\n\tposition: absolute;\n\tborder-radius: 50%;\n\tpointer-events: none;\n\tz-index: 10;\n\twill-change: transform;\n}\n\n.ee-stage .ee-pullquote-box {\n\tposition: absolute;\n\tpointer-events: none;\n\tz-index: 3;\n\tborder-left: 3px solid #6b5a3d;\n\tpadding-left: 14px;\n}\n\n.ee-stage .ee-pullquote-line {\n\tposition: absolute;\n\twhite-space: pre;\n\tfont-style: italic;\n\tcolor: #b8a070;\n\tuser-select: text;\n\t-webkit-user-select: text;\n}\n","type":"registry:ui","target":"@ui/fancy/components/editorial-engine/editorial-engine.css"},{"path":"react/src/components/editorial-engine/engine.ts","content":"\"use client\";\n\n// Editorial layout engine powered by @chenglou/pretext.\n// Inspired by the pretext \"editorial engine\" demo\n// (https://chenglou.me/pretext/editorial-engine/).\n//\n// Every frame: orb physics → obstacle intersection per text line → slot\n// carving → pretext line layout with cursor handoff across columns → DOM\n// writes only (left/top/textContent). No DOM measurement anywhere in the\n// render loop.\n\nimport {\n\tprepareWithSegments,\n\tlayoutWithLines,\n\tlayoutNextLine,\n\twalkLineRanges,\n\ttype LayoutCursor,\n\ttype PreparedTextWithSegments,\n} from \"@chenglou/pretext\";\n\n// ============================================================================\n// Geometry\n// ============================================================================\n\ninterface Interval {\n\tleft: number;\n\tright: number;\n}\n\ninterface CircleObstacle {\n\tcx: number;\n\tcy: number;\n\tr: number;\n\thPad: number;\n\tvPad: number;\n}\n\ninterface RectObstacle {\n\tx: number;\n\ty: number;\n\tw: number;\n\th: number;\n}\n\ninterface PositionedLine {\n\tx: number;\n\ty: number;\n\ttext: string;\n\twidth: number;\n}\n\nconst MIN_SLOT_WIDTH = 50;\n\n/** Subtract blocked intervals from a base interval, keeping viable slots. */\nfunction carveTextLineSlots(base: Interval, blocked: Interval[]): Interval[] {\n\tlet slots: Interval[] = [base];\n\tfor (const interval of blocked) {\n\t\tconst next: Interval[] = [];\n\t\tfor (const slot of slots) {\n\t\t\tif (interval.right <= slot.left || interval.left >= slot.right) {\n\t\t\t\tnext.push(slot);\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif (interval.left > slot.left) next.push({ left: slot.left, right: interval.left });\n\t\t\tif (interval.right < slot.right) next.push({ left: interval.right, right: slot.right });\n\t\t}\n\t\tslots = next;\n\t}\n\treturn slots.filter((slot) => slot.right - slot.left >= MIN_SLOT_WIDTH);\n}\n\n/** Horizontal interval blocked by a circle within a vertical band, or null. */\nfunction circleIntervalForBand(\n\tcx: number,\n\tcy: number,\n\tr: number,\n\tbandTop: number,\n\tbandBottom: number,\n\thPad: number,\n\tvPad: number\n): Interval | null {\n\tconst top = bandTop - vPad;\n\tconst bottom = bandBottom + vPad;\n\tif (top >= cy + r || bottom <= cy - r) return null;\n\tconst minDy = cy >= top && cy <= bottom ? 0 : cy < top ? top - cy : cy - bottom;\n\tif (minDy >= r) return null;\n\tconst maxDx = Math.sqrt(r * r - minDy * minDy);\n\treturn { left: cx - maxDx - hPad, right: cx + maxDx + hPad };\n}\n\n// ============================================================================\n// Text layout\n// ============================================================================\n\n/**\n * Flow text into a rectangular region, line by line, skipping circle and rect\n * obstacles. Returns positioned lines plus the cursor where the text stopped,\n * so the next column can pick up exactly where this one ended.\n */\nfunction layoutColumn(\n\tprepared: PreparedTextWithSegments,\n\tstartCursor: LayoutCursor,\n\tregionX: number,\n\tregionY: number,\n\tregionW: number,\n\tregionH: number,\n\tlineHeight: number,\n\tcircleObstacles: CircleObstacle[],\n\trectObstacles: RectObstacle[],\n\tsingleSlotOnly: boolean\n): { lines: PositionedLine[]; cursor: LayoutCursor } {\n\tlet cursor = startCursor;\n\tlet lineTop = regionY;\n\tconst lines: PositionedLine[] = [];\n\tlet textExhausted = false;\n\n\twhile (lineTop + lineHeight <= regionY + regionH && !textExhausted) {\n\t\tconst bandTop = lineTop;\n\t\tconst bandBottom = lineTop + lineHeight;\n\t\tconst blocked: Interval[] = [];\n\n\t\tfor (const obstacle of circleObstacles) {\n\t\t\tconst interval = circleIntervalForBand(\n\t\t\t\tobstacle.cx,\n\t\t\t\tobstacle.cy,\n\t\t\t\tobstacle.r,\n\t\t\t\tbandTop,\n\t\t\t\tbandBottom,\n\t\t\t\tobstacle.hPad,\n\t\t\t\tobstacle.vPad\n\t\t\t);\n\t\t\tif (interval !== null) blocked.push(interval);\n\t\t}\n\t\tfor (const rect of rectObstacles) {\n\t\t\tif (bandBottom <= rect.y || bandTop >= rect.y + rect.h) continue;\n\t\t\tblocked.push({ left: rect.x, right: rect.x + rect.w });\n\t\t}\n\n\t\tconst slots = carveTextLineSlots({ left: regionX, right: regionX + regionW }, blocked);\n\t\tif (slots.length === 0) {\n\t\t\tlineTop += lineHeight;\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Narrow screens: fill only the widest slot per band to stay readable.\n\t\tconst orderedSlots = singleSlotOnly\n\t\t\t? [\n\t\t\t\t\tslots.reduce((best, slot) =>\n\t\t\t\t\t\tslot.right - slot.left > best.right - best.left ? slot : best\n\t\t\t\t\t),\n\t\t\t\t]\n\t\t\t: [...slots].sort((a, b) => a.left - b.left);\n\n\t\tfor (const slot of orderedSlots) {\n\t\t\tconst line = layoutNextLine(prepared, cursor, slot.right - slot.left);\n\t\t\tif (line === null) {\n\t\t\t\ttextExhausted = true;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tlines.push({\n\t\t\t\tx: Math.round(slot.left),\n\t\t\t\ty: Math.round(lineTop),\n\t\t\t\ttext: line.text,\n\t\t\t\twidth: line.width,\n\t\t\t});\n\t\t\tcursor = line.end;\n\t\t}\n\t\tlineTop += lineHeight;\n\t}\n\treturn { lines, cursor };\n}\n\n// ============================================================================\n// Engine\n// ============================================================================\n\nexport interface EditorialEngineOptions {\n\theadline: string;\n\tbody: string;\n\tpullquotes: string[];\n\tfontFamily: string;\n}\n\ninterface OrbDef {\n\tfx: number;\n\tfy: number;\n\tr: number;\n\tvx: number;\n\tvy: number;\n\tcolor: [number, number, number];\n}\n\nconst ORB_DEFS: OrbDef[] = [\n\t{ fx: 0.52, fy: 0.22, r: 110, vx: 24, vy: 16, color: [196, 163, 90] },\n\t{ fx: 0.18, fy: 0.48, r: 85, vx: -19, vy: 26, color: [100, 140, 255] },\n\t{ fx: 0.74, fy: 0.58, r: 95, vx: 16, vy: -21, color: [232, 100, 130] },\n\t{ fx: 0.38, fy: 0.72, r: 75, vx: -26, vy: -14, color: [80, 200, 140] },\n\t{ fx: 0.86, fy: 0.18, r: 65, vx: -13, vy: 19, color: [150, 100, 220] },\n];\n\nconst BODY_FONT_SIZE = 18;\nconst BODY_LINE_HEIGHT = 30;\nconst GUTTER = 48;\nconst COL_GAP = 40;\nconst BOTTOM_GAP = 20;\nconst DROP_CAP_LINES = 3;\nconst PQ_LINE_HEIGHT = 27;\nconst NARROW_BREAKPOINT = 760;\nconst NARROW_GUTTER = 20;\nconst NARROW_COL_GAP = 20;\nconst NARROW_BOTTOM_GAP = 16;\nconst NARROW_ORB_SCALE = 0.58;\nconst NARROW_ACTIVE_ORBS = 3;\n\ninterface Orb {\n\tx: number;\n\ty: number;\n\tr: number;\n\tvx: number;\n\tvy: number;\n\tpaused: boolean;\n}\n\ninterface Drag {\n\torbIndex: number;\n\tstartPointerX: number;\n\tstartPointerY: number;\n\tstartOrbX: number;\n\tstartOrbY: number;\n}\n\ninterface PullquoteRect extends RectObstacle {\n\tlines: PositionedLine[];\n\tcolIdx: number;\n}\n\n/**\n * A pullquote slot that survived the \"was a pullquote actually supplied?\"\n * filter. Declared so the filter can carry a type predicate: this package\n * compiles under `noUncheckedIndexedAccess`, where `pullquotes[1]` is\n * `PreparedTextWithSegments | undefined` and a plain `.filter()` does not\n * narrow it away.\n */\ninterface PullquoteSpec {\n\tprepared: PreparedTextWithSegments;\n\tcolIdx: number;\n\tyFrac: number;\n\twFrac: number;\n\tside: string;\n}\n\nfunction hitTestOrbs(\n\torbs: Orb[],\n\tpx: number,\n\tpy: number,\n\tactiveCount: number,\n\tradiusScale: number\n): number {\n\tfor (let index = activeCount - 1; index >= 0; index--) {\n\t\tconst orb = orbs[index]!;\n\t\tconst radius = orb.r * radiusScale;\n\t\tconst dx = px - orb.x;\n\t\tconst dy = py - orb.y;\n\t\tif (dx * dx + dy * dy <= radius * radius) return index;\n\t}\n\treturn -1;\n}\n\nfunction positionedLinesEqual(a: PositionedLine[], b: PositionedLine[]): boolean {\n\tif (a.length !== b.length) return false;\n\tfor (let i = 0; i < a.length; i++) {\n\t\tif (a[i]!.x !== b[i]!.x || a[i]!.y !== b[i]!.y || a[i]!.text !== b[i]!.text) return false;\n\t}\n\treturn true;\n}\n\nexport function createEditorialEngine(\n\tstage: HTMLDivElement,\n\toptions: EditorialEngineOptions\n): () => void {\n\tconst { headline, body, pullquotes, fontFamily } = options;\n\tconst bodyFont = `${BODY_FONT_SIZE}px ${fontFamily}`;\n\tconst pullquoteFont = `italic 19px ${fontFamily}`;\n\n\tlet destroyed = false;\n\tlet stageW = stage.clientWidth;\n\tlet stageH = stage.clientHeight;\n\n\t// --- DOM pools (write-only after creation) ---\n\tconst bodyLinePool: HTMLSpanElement[] = [];\n\tconst headlinePool: HTMLSpanElement[] = [];\n\tconst pullquoteLinePool: HTMLSpanElement[] = [];\n\tconst pullquoteBoxPool: HTMLDivElement[] = [];\n\n\tfunction syncPool<T extends HTMLElement>(pool: T[], count: number, create: () => T): void {\n\t\twhile (pool.length < count) {\n\t\t\tconst element = create();\n\t\t\tstage.appendChild(element);\n\t\t\tpool.push(element);\n\t\t}\n\t\tfor (let index = 0; index < pool.length; index++) {\n\t\t\tpool[index]!.style.display = index < count ? \"\" : \"none\";\n\t\t}\n\t}\n\n\tfunction makeLineEl(className: string): HTMLSpanElement {\n\t\tconst element = document.createElement(\"span\");\n\t\telement.className = className;\n\t\treturn element;\n\t}\n\n\tconst orbEls = ORB_DEFS.map((def) => {\n\t\tconst element = document.createElement(\"div\");\n\t\telement.className = \"ee-orb\";\n\t\tconst [r, g, b] = def.color;\n\t\telement.style.background = `radial-gradient(circle at 35% 35%, rgba(${r},${g},${b},0.35), rgba(${r},${g},${b},0.12) 55%, transparent 72%)`;\n\t\telement.style.boxShadow = `0 0 60px 15px rgba(${r},${g},${b},0.18), 0 0 120px 40px rgba(${r},${g},${b},0.07)`;\n\t\tstage.appendChild(element);\n\t\treturn element;\n\t});\n\n\tconst dropCapEl = document.createElement(\"div\");\n\tdropCapEl.className = \"ee-drop-cap\";\n\tstage.appendChild(dropCapEl);\n\n\t// --- Prepared text (one-time measurement, cached) ---\n\t// pre-wrap keeps the blank-line paragraph breaks the `body` prop advertises.\n\tconst preparedBody = prepareWithSegments(body, bodyFont, { whiteSpace: \"pre-wrap\" });\n\tconst preparedPullquotes = pullquotes.map((text) => prepareWithSegments(text, pullquoteFont));\n\tconst pullquoteSpecs = [\n\t\t{ prepared: preparedPullquotes[0], colIdx: 0, yFrac: 0.48, wFrac: 0.52, side: \"right\" },\n\t\t{ prepared: preparedPullquotes[1], colIdx: 1, yFrac: 0.32, wFrac: 0.5, side: \"left\" },\n\t].filter((spec): spec is PullquoteSpec => spec.prepared !== undefined);\n\n\tconst DROP_CAP_SIZE = BODY_LINE_HEIGHT * DROP_CAP_LINES - 4;\n\tconst dropCapFont = `700 ${DROP_CAP_SIZE}px ${fontFamily}`;\n\t// Empty body: no drop cap, render (nothing) from the very first grapheme.\n\tconst dropCapText = body.length > 0 ? body[0]! : \"\";\n\tlet dropCapTotalW = 0;\n\tif (dropCapText !== \"\") {\n\t\tconst preparedDropCap = prepareWithSegments(dropCapText, dropCapFont);\n\t\tlet dropCapWidth = 0;\n\t\twalkLineRanges(preparedDropCap, 9999, (line) => {\n\t\t\tdropCapWidth = line.width;\n\t\t});\n\t\tdropCapTotalW = Math.ceil(dropCapWidth) + 10;\n\t\tdropCapEl.textContent = dropCapText;\n\t\tdropCapEl.style.font = dropCapFont;\n\t\tdropCapEl.style.lineHeight = `${DROP_CAP_SIZE}px`;\n\t} else {\n\t\tdropCapEl.style.display = \"none\";\n\t}\n\n\t// --- Headline auto-fit: binary search over font sizes, rejecting any size\n\t// that breaks a word mid-line. Each probe is pure arithmetic.\n\tlet cachedKey = \"\";\n\tlet cachedFit = { fontSize: 24, lines: [] as PositionedLine[] };\n\tfunction fitHeadline(maxWidth: number, maxHeight: number, maxSize: number) {\n\t\tconst key = `${maxWidth}|${maxHeight}|${maxSize}`;\n\t\tif (key === cachedKey) return cachedFit;\n\t\tlet lo = 20;\n\t\tlet hi = maxSize;\n\t\tlet best = lo;\n\t\tlet bestLines: PositionedLine[] = [];\n\t\twhile (lo <= hi) {\n\t\t\tconst size = Math.floor((lo + hi) / 2);\n\t\t\tconst lineHeight = Math.round(size * 0.93);\n\t\t\tconst prepared = prepareWithSegments(headline, `700 ${size}px ${fontFamily}`);\n\t\t\tlet breaksWord = false;\n\t\t\tlet lineCount = 0;\n\t\t\twalkLineRanges(prepared, maxWidth, (line) => {\n\t\t\t\tlineCount++;\n\t\t\t\tif (line.end.graphemeIndex !== 0) breaksWord = true;\n\t\t\t});\n\t\t\tif (!breaksWord && lineCount * lineHeight <= maxHeight) {\n\t\t\t\tbest = size;\n\t\t\t\tbestLines = layoutWithLines(prepared, maxWidth, lineHeight).lines.map((line, i) => ({\n\t\t\t\t\tx: 0,\n\t\t\t\t\ty: i * lineHeight,\n\t\t\t\t\ttext: line.text,\n\t\t\t\t\twidth: line.width,\n\t\t\t\t}));\n\t\t\t\tlo = size + 1;\n\t\t\t} else {\n\t\t\t\thi = size - 1;\n\t\t\t}\n\t\t}\n\t\t// No size fit (overlong word or tiny stage): fall back to the minimum\n\t\t// size with word breaks allowed rather than dropping the headline.\n\t\tif (bestLines.length === 0 && headline.length > 0) {\n\t\t\tbest = 20;\n\t\t\tconst lineHeight = Math.round(best * 0.93);\n\t\t\tconst prepared = prepareWithSegments(headline, `700 ${best}px ${fontFamily}`);\n\t\t\tbestLines = layoutWithLines(prepared, maxWidth, lineHeight).lines.map((line, i) => ({\n\t\t\t\tx: 0,\n\t\t\t\ty: i * lineHeight,\n\t\t\t\ttext: line.text,\n\t\t\t\twidth: line.width,\n\t\t\t}));\n\t\t}\n\t\tcachedKey = key;\n\t\tcachedFit = { fontSize: best, lines: bestLines };\n\t\treturn cachedFit;\n\t}\n\n\t// --- Interaction state ---\n\tconst reducedMotion = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\tconst orbs: Orb[] = ORB_DEFS.map((def) => ({\n\t\tx: def.fx * stageW,\n\t\ty: def.fy * stageH,\n\t\tr: def.r,\n\t\tvx: def.vx,\n\t\tvy: def.vy,\n\t\tpaused: reducedMotion,\n\t}));\n\tlet pointer = { x: -9999, y: -9999 };\n\tlet drag: Drag | null = null;\n\tlet selectionActive = false;\n\tlet lastFrameTime: number | null = null;\n\tlet committedLines: {\n\t\theadline: PositionedLine[];\n\t\tbody: PositionedLine[];\n\t\tpullquote: PositionedLine[];\n\t\theadlineFont: string;\n\t} | null = null;\n\n\tfunction stagePoint(event: { clientX: number; clientY: number }): { x: number; y: number } {\n\t\tconst rect = stage.getBoundingClientRect();\n\t\treturn { x: event.clientX - rect.left, y: event.clientY - rect.top };\n\t}\n\n\t// --- Render loop ---\n\tlet scheduledRaf: number | null = null;\n\tfunction scheduleRender(): void {\n\t\tif (scheduledRaf !== null || destroyed) return;\n\t\tscheduledRaf = requestAnimationFrame((now) => {\n\t\t\tscheduledRaf = null;\n\t\t\tif (render(now)) scheduleRender();\n\t\t});\n\t}\n\n\tfunction render(now: number): boolean {\n\t\t// Re-laying lines out would destroy an active text selection.\n\t\tif (selectionActive && drag === null) return false;\n\n\t\tconst isNarrow = stageW < NARROW_BREAKPOINT;\n\t\tconst gutter = isNarrow ? NARROW_GUTTER : GUTTER;\n\t\tconst colGap = isNarrow ? NARROW_COL_GAP : COL_GAP;\n\t\tconst bottomGap = isNarrow ? NARROW_BOTTOM_GAP : BOTTOM_GAP;\n\t\tconst orbRadiusScale = isNarrow ? NARROW_ORB_SCALE : 1;\n\t\tconst activeOrbCount = isNarrow ? Math.min(NARROW_ACTIVE_ORBS, orbs.length) : orbs.length;\n\n\t\t// Orb physics: integrate velocity, bounce on walls, soft repulsion.\n\t\tconst dt = Math.min((now - (lastFrameTime ?? now)) / 1000, 0.05);\n\t\tconst draggedOrbIndex = drag?.orbIndex ?? -1;\n\t\tlet stillAnimating = false;\n\t\tfor (let index = 0; index < activeOrbCount; index++) {\n\t\t\tconst orb = orbs[index]!;\n\t\t\tconst radius = orb.r * orbRadiusScale;\n\t\t\tif (orb.paused || index === draggedOrbIndex) continue;\n\t\t\tstillAnimating = true;\n\t\t\torb.x += orb.vx * dt;\n\t\t\torb.y += orb.vy * dt;\n\t\t\tif (orb.x - radius < 0) {\n\t\t\t\torb.x = radius;\n\t\t\t\torb.vx = Math.abs(orb.vx);\n\t\t\t}\n\t\t\tif (orb.x + radius > stageW) {\n\t\t\t\torb.x = stageW - radius;\n\t\t\t\torb.vx = -Math.abs(orb.vx);\n\t\t\t}\n\t\t\tif (orb.y - radius < gutter * 0.5) {\n\t\t\t\torb.y = radius + gutter * 0.5;\n\t\t\t\torb.vy = Math.abs(orb.vy);\n\t\t\t}\n\t\t\tif (orb.y + radius > stageH - bottomGap) {\n\t\t\t\torb.y = stageH - bottomGap - radius;\n\t\t\t\torb.vy = -Math.abs(orb.vy);\n\t\t\t}\n\t\t}\n\t\tfor (let i = 0; i < activeOrbCount; i++) {\n\t\t\tconst a = orbs[i]!;\n\t\t\tfor (let j = i + 1; j < activeOrbCount; j++) {\n\t\t\t\tconst b = orbs[j]!;\n\t\t\t\tconst dx = b.x - a.x;\n\t\t\t\tconst dy = b.y - a.y;\n\t\t\t\tconst dist = Math.sqrt(dx * dx + dy * dy);\n\t\t\t\tconst minDist = a.r * orbRadiusScale + b.r * orbRadiusScale + (isNarrow ? 12 : 20);\n\t\t\t\tif (dist >= minDist || dist <= 0.1) continue;\n\t\t\t\tconst force = (minDist - dist) * 0.8;\n\t\t\t\tconst nx = dx / dist;\n\t\t\t\tconst ny = dy / dist;\n\t\t\t\tif (!a.paused && i !== draggedOrbIndex) {\n\t\t\t\t\ta.vx -= nx * force * dt;\n\t\t\t\t\ta.vy -= ny * force * dt;\n\t\t\t\t}\n\t\t\t\tif (!b.paused && j !== draggedOrbIndex) {\n\t\t\t\t\tb.vx += nx * force * dt;\n\t\t\t\t\tb.vy += ny * force * dt;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tconst circleObstacles: CircleObstacle[] = [];\n\t\tfor (let index = 0; index < activeOrbCount; index++) {\n\t\t\tconst orb = orbs[index]!;\n\t\t\tcircleObstacles.push({\n\t\t\t\tcx: orb.x,\n\t\t\t\tcy: orb.y,\n\t\t\t\tr: orb.r * orbRadiusScale,\n\t\t\t\thPad: isNarrow ? 10 : 14,\n\t\t\t\tvPad: isNarrow ? 2 : 4,\n\t\t\t});\n\t\t}\n\n\t\t// Headline\n\t\tconst headlineWidth = Math.min(stageW - gutter * 2 - (isNarrow ? 12 : 0), 1000);\n\t\tconst maxHeadlineHeight = Math.floor(stageH * (isNarrow ? 0.2 : 0.24));\n\t\tconst { fontSize: headlineSize, lines: headlineLines } = fitHeadline(\n\t\t\theadlineWidth,\n\t\t\tmaxHeadlineHeight,\n\t\t\tisNarrow ? 38 : 92\n\t\t);\n\t\tconst headlineLineHeight = Math.round(headlineSize * 0.93);\n\t\tconst headlineFont = `700 ${headlineSize}px ${fontFamily}`;\n\n\t\t// Columns\n\t\tconst bodyTop = gutter + headlineLines.length * headlineLineHeight + (isNarrow ? 14 : 20);\n\t\tconst bodyHeight = stageH - bodyTop - bottomGap;\n\t\tconst columnCount = stageW > 1000 ? 3 : stageW > 640 ? 2 : 1;\n\t\tconst totalGutter = gutter * 2 + colGap * (columnCount - 1);\n\t\tconst maxContentWidth = Math.min(stageW, 1500);\n\t\tconst columnWidth = Math.floor((maxContentWidth - totalGutter) / columnCount);\n\t\tconst contentLeft = Math.round(\n\t\t\t(stageW - (columnCount * columnWidth + (columnCount - 1) * colGap)) / 2\n\t\t);\n\n\t\tconst dropCapRect: RectObstacle = {\n\t\t\tx: contentLeft - 2,\n\t\t\ty: bodyTop - 2,\n\t\t\tw: dropCapTotalW,\n\t\t\th: DROP_CAP_LINES * BODY_LINE_HEIGHT + 2,\n\t\t};\n\n\t\t// Pullquotes occupy fixed rects the body text must flow around.\n\t\tconst pullquoteRects: PullquoteRect[] = [];\n\t\tif (!isNarrow) {\n\t\t\tfor (const spec of pullquoteSpecs) {\n\t\t\t\tif (spec.colIdx >= columnCount) continue;\n\t\t\t\tconst pqWidth = Math.round(columnWidth * spec.wFrac);\n\t\t\t\tconst pqLines = layoutWithLines(spec.prepared, pqWidth - 20, PQ_LINE_HEIGHT).lines;\n\t\t\t\tconst pqHeight = pqLines.length * PQ_LINE_HEIGHT + 16;\n\t\t\t\tconst columnX = contentLeft + spec.colIdx * (columnWidth + colGap);\n\t\t\t\tconst pqX = spec.side === \"right\" ? columnX + columnWidth - pqWidth : columnX;\n\t\t\t\tconst pqY = Math.round(bodyTop + bodyHeight * spec.yFrac);\n\t\t\t\tpullquoteRects.push({\n\t\t\t\t\tx: pqX,\n\t\t\t\t\ty: pqY,\n\t\t\t\t\tw: pqWidth,\n\t\t\t\t\th: pqHeight,\n\t\t\t\t\tcolIdx: spec.colIdx,\n\t\t\t\t\tlines: pqLines.map((line, lineIndex) => ({\n\t\t\t\t\t\tx: pqX + 20,\n\t\t\t\t\t\ty: pqY + 8 + lineIndex * PQ_LINE_HEIGHT,\n\t\t\t\t\t\ttext: line.text,\n\t\t\t\t\t\twidth: line.width,\n\t\t\t\t\t})),\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\n\t\t// Body flow with cursor handoff between columns. Cursor starts past the\n\t\t// first grapheme — the drop cap renders it.\n\t\tconst allBodyLines: PositionedLine[] = [];\n\t\tlet cursor: LayoutCursor = { segmentIndex: 0, graphemeIndex: dropCapText === \"\" ? 0 : 1 };\n\t\tfor (let columnIndex = 0; columnIndex < columnCount; columnIndex++) {\n\t\t\tconst columnX = contentLeft + columnIndex * (columnWidth + colGap);\n\t\t\tconst rects: RectObstacle[] = [];\n\t\t\tif (columnIndex === 0 && dropCapTotalW > 0) rects.push(dropCapRect);\n\t\t\tfor (const pq of pullquoteRects) {\n\t\t\t\tif (pq.colIdx === columnIndex) rects.push(pq);\n\t\t\t}\n\t\t\tconst result = layoutColumn(\n\t\t\t\tpreparedBody,\n\t\t\t\tcursor,\n\t\t\t\tcolumnX,\n\t\t\t\tbodyTop,\n\t\t\t\tcolumnWidth,\n\t\t\t\tbodyHeight,\n\t\t\t\tBODY_LINE_HEIGHT,\n\t\t\t\tcircleObstacles,\n\t\t\t\trects,\n\t\t\t\tisNarrow\n\t\t\t);\n\t\t\tallBodyLines.push(...result.lines);\n\t\t\tcursor = result.cursor;\n\t\t}\n\n\t\tconst pullquoteLines = pullquoteRects.flatMap((pq) => pq.lines);\n\n\t\t// --- DOM writes (only when the projection changed) ---\n\t\tconst changed =\n\t\t\tcommittedLines === null ||\n\t\t\tcommittedLines.headlineFont !== headlineFont ||\n\t\t\t!positionedLinesEqual(committedLines.headline, headlineLines) ||\n\t\t\t!positionedLinesEqual(committedLines.body, allBodyLines) ||\n\t\t\t!positionedLinesEqual(committedLines.pullquote, pullquoteLines);\n\n\t\tif (changed) {\n\t\t\tsyncPool(headlinePool, headlineLines.length, () => makeLineEl(\"ee-headline-line\"));\n\t\t\tfor (let i = 0; i < headlineLines.length; i++) {\n\t\t\t\tconst el = headlinePool[i]!;\n\t\t\t\tconst line = headlineLines[i]!;\n\t\t\t\tel.textContent = line.text;\n\t\t\t\tel.style.left = `${gutter + line.x}px`;\n\t\t\t\tel.style.top = `${gutter + line.y}px`;\n\t\t\t\tel.style.font = headlineFont;\n\t\t\t\tel.style.lineHeight = `${headlineLineHeight}px`;\n\t\t\t}\n\t\t\tsyncPool(bodyLinePool, allBodyLines.length, () => makeLineEl(\"ee-line\"));\n\t\t\tfor (let i = 0; i < allBodyLines.length; i++) {\n\t\t\t\tconst el = bodyLinePool[i]!;\n\t\t\t\tconst line = allBodyLines[i]!;\n\t\t\t\tel.textContent = line.text;\n\t\t\t\tel.style.left = `${line.x}px`;\n\t\t\t\tel.style.top = `${line.y}px`;\n\t\t\t\tel.style.font = bodyFont;\n\t\t\t\tel.style.lineHeight = `${BODY_LINE_HEIGHT}px`;\n\t\t\t}\n\t\t\tsyncPool(pullquoteLinePool, pullquoteLines.length, () => makeLineEl(\"ee-pullquote-line\"));\n\t\t\tfor (let i = 0; i < pullquoteLines.length; i++) {\n\t\t\t\tconst el = pullquoteLinePool[i]!;\n\t\t\t\tconst line = pullquoteLines[i]!;\n\t\t\t\tel.textContent = line.text;\n\t\t\t\tel.style.left = `${line.x}px`;\n\t\t\t\tel.style.top = `${line.y}px`;\n\t\t\t\tel.style.font = pullquoteFont;\n\t\t\t\tel.style.lineHeight = `${PQ_LINE_HEIGHT}px`;\n\t\t\t}\n\t\t\tcommittedLines = {\n\t\t\t\theadline: headlineLines,\n\t\t\t\tbody: allBodyLines,\n\t\t\t\tpullquote: pullquoteLines,\n\t\t\t\theadlineFont,\n\t\t\t};\n\t\t}\n\n\t\tdropCapEl.style.left = `${contentLeft}px`;\n\t\tdropCapEl.style.top = `${bodyTop}px`;\n\n\t\tsyncPool(pullquoteBoxPool, pullquoteRects.length, () => {\n\t\t\tconst element = document.createElement(\"div\");\n\t\t\telement.className = \"ee-pullquote-box\";\n\t\t\treturn element;\n\t\t});\n\t\tfor (let i = 0; i < pullquoteRects.length; i++) {\n\t\t\tconst pq = pullquoteRects[i]!;\n\t\t\tconst el = pullquoteBoxPool[i]!;\n\t\t\tel.style.left = `${pq.x}px`;\n\t\t\tel.style.top = `${pq.y}px`;\n\t\t\tel.style.width = `${pq.w}px`;\n\t\t\tel.style.height = `${pq.h}px`;\n\t\t}\n\n\t\tfor (let index = 0; index < orbs.length; index++) {\n\t\t\tconst orb = orbs[index]!;\n\t\t\tconst element = orbEls[index]!;\n\t\t\tif (index >= activeOrbCount) {\n\t\t\t\telement.style.display = \"none\";\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tconst radius = orb.r * orbRadiusScale;\n\t\t\telement.style.display = \"\";\n\t\t\telement.style.left = `${orb.x - radius}px`;\n\t\t\telement.style.top = `${orb.y - radius}px`;\n\t\t\telement.style.width = `${radius * 2}px`;\n\t\t\telement.style.height = `${radius * 2}px`;\n\t\t\telement.style.opacity = orb.paused ? \"0.45\" : \"1\";\n\t\t}\n\n\t\tconst hoveredOrbIndex = hitTestOrbs(orbs, pointer.x, pointer.y, activeOrbCount, orbRadiusScale);\n\t\tstage.style.cursor = drag !== null ? \"grabbing\" : hoveredOrbIndex !== -1 ? \"grab\" : \"\";\n\t\tlastFrameTime = stillAnimating ? now : null;\n\t\treturn stillAnimating;\n\t}\n\n\t// --- Events ---\n\tfunction onPointerDown(event: PointerEvent): void {\n\t\tconst point = stagePoint(event);\n\t\tconst isNarrow = stageW < NARROW_BREAKPOINT;\n\t\tconst activeOrbCount = isNarrow ? NARROW_ACTIVE_ORBS : orbs.length;\n\t\tconst radiusScale = isNarrow ? NARROW_ORB_SCALE : 1;\n\t\tconst orbIndex = hitTestOrbs(orbs, point.x, point.y, activeOrbCount, radiusScale);\n\t\tif (orbIndex !== -1) {\n\t\t\tevent.preventDefault();\n\t\t\tconst orb = orbs[orbIndex]!;\n\t\t\tdrag = {\n\t\t\t\torbIndex,\n\t\t\t\tstartPointerX: point.x,\n\t\t\t\tstartPointerY: point.y,\n\t\t\t\tstartOrbX: orb.x,\n\t\t\t\tstartOrbY: orb.y,\n\t\t\t};\n\t\t}\n\t\tpointer = point;\n\t\tscheduleRender();\n\t}\n\n\t// Under `touch-action: pan-y` the browser would take over a touch drag with a\n\t// vertical component and cancel the pointer stream. Claim the gesture only\n\t// when it starts on an orb, so page scrolling still works everywhere else.\n\tfunction onTouchStart(event: TouchEvent): void {\n\t\tconst touch = event.changedTouches[0];\n\t\tif (touch === undefined || !event.cancelable) return;\n\t\tconst point = stagePoint(touch);\n\t\tconst isNarrow = stageW < NARROW_BREAKPOINT;\n\t\tconst activeOrbCount = isNarrow ? NARROW_ACTIVE_ORBS : orbs.length;\n\t\tconst radiusScale = isNarrow ? NARROW_ORB_SCALE : 1;\n\t\tif (hitTestOrbs(orbs, point.x, point.y, activeOrbCount, radiusScale) !== -1) {\n\t\t\tevent.preventDefault();\n\t\t}\n\t}\n\n\tfunction onPointerMove(event: PointerEvent): void {\n\t\tconst point = stagePoint(event);\n\t\tpointer = point;\n\t\tif (drag !== null) {\n\t\t\tconst orb = orbs[drag.orbIndex]!;\n\t\t\torb.x = drag.startOrbX + (point.x - drag.startPointerX);\n\t\t\torb.y = drag.startOrbY + (point.y - drag.startPointerY);\n\t\t}\n\t\tscheduleRender();\n\t}\n\n\tfunction onPointerUp(event: PointerEvent): void {\n\t\tconst point = stagePoint(event);\n\t\tpointer = point;\n\t\tif (drag !== null) {\n\t\t\tconst dx = point.x - drag.startPointerX;\n\t\t\tconst dy = point.y - drag.startPointerY;\n\t\t\t// A motionless press toggles pause instead of dragging.\n\t\t\tif (dx * dx + dy * dy < 16) {\n\t\t\t\torbs[drag.orbIndex]!.paused = !orbs[drag.orbIndex]!.paused;\n\t\t\t}\n\t\t\tdrag = null;\n\t\t}\n\t\tscheduleRender();\n\t}\n\n\tfunction onSelectionChange(): void {\n\t\tconst selection = window.getSelection();\n\t\tselectionActive = selection !== null && !selection.isCollapsed && selection.rangeCount > 0;\n\t\tscheduleRender();\n\t}\n\n\tstage.addEventListener(\"pointerdown\", onPointerDown);\n\tstage.addEventListener(\"touchstart\", onTouchStart, { passive: false });\n\twindow.addEventListener(\"pointermove\", onPointerMove);\n\twindow.addEventListener(\"pointerup\", onPointerUp);\n\twindow.addEventListener(\"pointercancel\", onPointerUp);\n\tdocument.addEventListener(\"selectionchange\", onSelectionChange);\n\n\tconst resizeObserver = new ResizeObserver((entries) => {\n\t\tfor (const entry of entries) {\n\t\t\tstageW = entry.contentRect.width;\n\t\t\tstageH = entry.contentRect.height;\n\t\t}\n\t\tscheduleRender();\n\t});\n\tresizeObserver.observe(stage);\n\n\tscheduleRender();\n\n\treturn () => {\n\t\tdestroyed = true;\n\t\tif (scheduledRaf !== null) cancelAnimationFrame(scheduledRaf);\n\t\tresizeObserver.disconnect();\n\t\tstage.removeEventListener(\"pointerdown\", onPointerDown);\n\t\tstage.removeEventListener(\"touchstart\", onTouchStart);\n\t\twindow.removeEventListener(\"pointermove\", onPointerMove);\n\t\twindow.removeEventListener(\"pointerup\", onPointerUp);\n\t\twindow.removeEventListener(\"pointercancel\", onPointerUp);\n\t\tdocument.removeEventListener(\"selectionchange\", onSelectionChange);\n\t\tstage.replaceChildren();\n\t};\n}\n","type":"registry:ui","target":"@ui/fancy/components/editorial-engine/engine.ts"},{"path":"react/src/components/editorial-engine/index.ts","content":"\"use client\";\n\nexport { EditorialEngine } from \"./EditorialEngine\";\nexport type { EditorialEngineProps } from \"./EditorialEngine\";\n","type":"registry:ui","target":"@ui/fancy/components/editorial-engine/index.ts"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/editorial-engine?framework=react"}}