From 8ba2f8b6bab7c9b715aeb37efb3b50f607c30a13 Mon Sep 17 00:00:00 2001 From: frouaix Date: Tue, 21 Jul 2026 12:21:15 -0700 Subject: [PATCH 1/4] Add layout pagination package and demos Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 1 + packages/demo/component-demo.html | 2 +- packages/demo/demos/layout-pagination-flow.ts | 33 ++ .../demos/layout-pagination-presentation.ts | 47 ++ packages/demo/gallery.html | 2 +- packages/demo/gallery.ts | 5 + packages/demo/layout-pagination-flow-setup.js | 19 + packages/demo/layout-pagination-flow.html | 94 ++++ .../layout-pagination-presentation-setup.js | 36 ++ .../demo/layout-pagination-presentation.html | 94 ++++ packages/demo/layout-pagination-preview.ts | 485 ++++++++++++++++ packages/demo/layout-pagination-scenarios.ts | 266 +++++++++ packages/demo/package.json | 3 +- packages/demo/text-selection-debug-texta.html | 4 +- packages/demo/text-selection-debug.html | 63 +-- packages/demo/text-selection.html | 4 +- packages/demo/vite.config.ts | 3 + packages/layout-pagination/package.json | 29 + packages/layout-pagination/src/engine.ts | 529 ++++++++++++++++++ packages/layout-pagination/src/index.ts | 57 ++ packages/layout-pagination/src/measure.ts | 33 ++ packages/layout-pagination/src/model.ts | 226 ++++++++ .../layout-pagination/test/page-spec.test.ts | 281 ++++++++++ packages/layout-pagination/tsconfig.json | 11 + pnpm-lock.yaml | 9 + 25 files changed, 2291 insertions(+), 45 deletions(-) create mode 100644 packages/demo/demos/layout-pagination-flow.ts create mode 100644 packages/demo/demos/layout-pagination-presentation.ts create mode 100644 packages/demo/layout-pagination-flow-setup.js create mode 100644 packages/demo/layout-pagination-flow.html create mode 100644 packages/demo/layout-pagination-presentation-setup.js create mode 100644 packages/demo/layout-pagination-presentation.html create mode 100644 packages/demo/layout-pagination-preview.ts create mode 100644 packages/demo/layout-pagination-scenarios.ts create mode 100644 packages/layout-pagination/package.json create mode 100644 packages/layout-pagination/src/engine.ts create mode 100644 packages/layout-pagination/src/index.ts create mode 100644 packages/layout-pagination/src/measure.ts create mode 100644 packages/layout-pagination/src/model.ts create mode 100644 packages/layout-pagination/test/page-spec.test.ts create mode 100644 packages/layout-pagination/tsconfig.json diff --git a/README.md b/README.md index 7cd2361..6048bc4 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Vitrine is organized as a pnpm workspace monorepo with several publishable packa |---------|-----------|----------|-------------| | Core | `packages/core` | `vitrine` | Block tree, rendering, events, hit-testing, frustum culling — zero GUI dependencies | | GUI | `packages/gui` | `vitrine-gui` | High-level GUI controls, theming, layout, and `VitrineComponent` | +| Layout & Pagination | `packages/layout-pagination` | `vitrine-layout-pagination` | Renderer-agnostic document layout and pagination contracts | | Tables | `packages/tables` | `vitrine-tables` | Table layout model and extension contracts | | Tables Adapter | `packages/tables-vitrine` | `vitrine-tables-adapter` | Vitrine adapter for table rendering | | Texta | `packages/texta` | `texta` | Attributed text engine for semantic and render transformations | diff --git a/packages/demo/component-demo.html b/packages/demo/component-demo.html index f500c0e..0cf474f 100644 --- a/packages/demo/component-demo.html +++ b/packages/demo/component-demo.html @@ -181,7 +181,7 @@

Raw Blocks

diff --git a/packages/demo/demos/layout-pagination-flow.ts b/packages/demo/demos/layout-pagination-flow.ts new file mode 100644 index 0000000..fbc32c4 --- /dev/null +++ b/packages/demo/demos/layout-pagination-flow.ts @@ -0,0 +1,33 @@ +import { group } from 'vitrine'; +import { buildFlowDocumentPreview } from '../layout-pagination-preview.ts'; +import { documentFlow, flowResult } from '../layout-pagination-scenarios.ts'; + +export const demo = { + id: 'layout-pagination-flow', + name: 'Layout Pagination — Flow Document', + description: 'Markdown-style flow content with semantic page breaks', + category: 'documents', + size: { width: 1280, height: 900 }, + enableCulling: false, + + init: () => ({ + pageCount: flowResult.pages.length + }), + + render: () => group({}, [ + buildFlowDocumentPreview(documentFlow, flowResult, { + columns: 2 + }) + ]), + + code: `const doc: LayoutDocument = { + kind: 'flow', + page: { width: 612, height: 792, unit: 'pt' }, + header, + footer, + body: markdownAsLayoutNodes +}; + +const result = createPaginatedLayoutEngine().layout(doc); +const preview = buildFlowDocumentPreview(doc, result);` +}; diff --git a/packages/demo/demos/layout-pagination-presentation.ts b/packages/demo/demos/layout-pagination-presentation.ts new file mode 100644 index 0000000..e7b3fd0 --- /dev/null +++ b/packages/demo/demos/layout-pagination-presentation.ts @@ -0,0 +1,47 @@ +import { group } from 'vitrine'; +import { buildPresentationPreview } from '../layout-pagination-preview.ts'; +import { documentPresentation, presentationResult } from '../layout-pagination-scenarios.ts'; + +interface PresentationDemoState { + currentSlideIndex: number; + time: number; +} + +export const demo = { + id: 'layout-pagination-presentation', + name: 'Layout Pagination — Presentation', + description: 'Fixed-size slide pages for browser presentations', + category: 'documents', + size: { width: 1280, height: 860 }, + enableCulling: false, + + init: (): PresentationDemoState => ({ + currentSlideIndex: 0, + time: 0 + }), + + update: (state: PresentationDemoState, dt: number): void => { + state.time += dt; + if (state.time >= 3.5) { + state.time = 0; + state.currentSlideIndex = (state.currentSlideIndex + 1) % presentationResult.pages.length; + } + }, + + render: (state: PresentationDemoState) => group({}, [ + buildPresentationPreview(documentPresentation, presentationResult, { + currentPageIndex: state.currentSlideIndex + }) + ]), + + code: `const deck: LayoutDocument = { + kind: 'presentation', + page: { width: 1280, height: 720, unit: 'px' }, + slides: [titleSlide, agendaSlide, closingSlide] +}; + +const result = createPaginatedLayoutEngine().layout(deck); +const preview = buildPresentationPreview(deck, result, { + currentPageIndex +});` +}; diff --git a/packages/demo/gallery.html b/packages/demo/gallery.html index 33c9439..6054108 100644 --- a/packages/demo/gallery.html +++ b/packages/demo/gallery.html @@ -523,7 +523,7 @@

Performance

diff --git a/packages/demo/gallery.ts b/packages/demo/gallery.ts index c3bdf33..e6a6136 100644 --- a/packages/demo/gallery.ts +++ b/packages/demo/gallery.ts @@ -49,6 +49,8 @@ import { demo as imageFiltersDemo } from './demos/image-filters.ts'; import { demo as textWrappingDemo } from './demos/text-wrapping.ts'; import { demo as linksDemo } from './demos/links.ts'; import { demo as basicBlocksDemo } from './demos/basic-blocks.ts'; +import { demo as layoutPaginationPresentationDemo } from './demos/layout-pagination-presentation.ts'; +import { demo as layoutPaginationFlowDemo } from './demos/layout-pagination-flow.ts'; // Demo registry const demos: GalleryDemo[] = [ @@ -72,6 +74,8 @@ const demos: GalleryDemo[] = [ { ...guiFormDemo, category: 'ui' }, { ...guiDashboardDemo, category: 'ui' }, { ...guiGalleryDemo, category: 'ui' }, + { ...layoutPaginationPresentationDemo, category: 'documents' }, + { ...layoutPaginationFlowDemo, category: 'documents' }, { ...snakeDemo, category: 'games' } ] as GalleryDemo[]; @@ -137,6 +141,7 @@ function renderDemoList() { 'productivity': { name: 'Productivity', icon: '📋' }, 'creative': { name: 'Creative', icon: '🎨' }, 'ui': { name: 'UI Components', icon: '🎛️' }, + 'documents': { name: 'Documents & Layout', icon: '📄' }, 'games': { name: 'Interactive', icon: '🎮' } }; diff --git a/packages/demo/layout-pagination-flow-setup.js b/packages/demo/layout-pagination-flow-setup.js new file mode 100644 index 0000000..477f5c6 --- /dev/null +++ b/packages/demo/layout-pagination-flow-setup.js @@ -0,0 +1,19 @@ +import { VitrineComponent } from 'vitrine-gui'; +import { buildFlowDocumentPreview } from './layout-pagination-preview.ts'; +import { documentFlow, flowResult } from './layout-pagination-scenarios.ts'; + +const component = VitrineComponent.block(() => ( + buildFlowDocumentPreview(documentFlow, flowResult, { + x: 70, + y: 86, + scale: 1.1, + pageGap: 42, + columns: 2 + }) +), { + width: 1600, + height: 1120, + renderMode: 'continuous' +}); + +component.mount(document.getElementById('canvas')); diff --git a/packages/demo/layout-pagination-flow.html b/packages/demo/layout-pagination-flow.html new file mode 100644 index 0000000..08ae9bd --- /dev/null +++ b/packages/demo/layout-pagination-flow.html @@ -0,0 +1,94 @@ + + + + + + + Vitrine - Layout Pagination Flow Document + + + +
+

Layout Pagination — Flow Document

+

+ Standalone paginated document preview with realistic page proportions, repeated header/footer regions, + and explicit semantic page breaks for markdown-like content. +

+
+
+

Flow content is paginated semantically into pages; it is not merely sliced by viewport.

+
+
+ + + + + + diff --git a/packages/demo/layout-pagination-presentation-setup.js b/packages/demo/layout-pagination-presentation-setup.js new file mode 100644 index 0000000..4b4a6f0 --- /dev/null +++ b/packages/demo/layout-pagination-presentation-setup.js @@ -0,0 +1,36 @@ +import { VitrineComponent } from 'vitrine-gui'; +import { buildPresentationPreview } from './layout-pagination-preview.ts'; +import { documentPresentation, presentationResult } from './layout-pagination-scenarios.ts'; + +let currentSlideIndex = 0; +let time = 0; + +const component = VitrineComponent.block(() => ( + buildPresentationPreview(documentPresentation, presentationResult, { + currentPageIndex: currentSlideIndex, + x: 72, + y: 92, + mainScale: 0.86, + thumbnailScale: 0.17, + thumbnailGap: 20 + }) +), { + width: 1600, + height: 980, + renderMode: 'continuous' +}); + +component.mount(document.getElementById('canvas')); + +let lastTime = performance.now(); +function animate(now) { + const dt = (now - lastTime) / 1000; + lastTime = now; + time += dt; + if (time >= 4) { + time = 0; + currentSlideIndex = (currentSlideIndex + 1) % presentationResult.pages.length; + } + requestAnimationFrame(animate); +} +requestAnimationFrame(animate); diff --git a/packages/demo/layout-pagination-presentation.html b/packages/demo/layout-pagination-presentation.html new file mode 100644 index 0000000..fe397fa --- /dev/null +++ b/packages/demo/layout-pagination-presentation.html @@ -0,0 +1,94 @@ + + + + + + + Vitrine - Layout Pagination Presentation + + + +
+

Layout Pagination — Presentation

+

+ Standalone presentation-page preview with explicit 16:9 slide geometry, a large active slide, + and a realistic thumbnail rail for browser-authored slide decks. +

+
+
+

Slides are explicit pages. The page size remains fixed; only the browser preview scales.

+
+
+ + + + + + diff --git a/packages/demo/layout-pagination-preview.ts b/packages/demo/layout-pagination-preview.ts new file mode 100644 index 0000000..e659fe7 --- /dev/null +++ b/packages/demo/layout-pagination-preview.ts @@ -0,0 +1,485 @@ +import { group, rectangle, text } from 'vitrine'; +import type { Block } from 'vitrine'; +import { + normalizeLayoutInsets, + type BoxLayoutNode, + type FixedLayoutNode, + type LayoutDocument, + type LayoutFragment, + type LayoutInsets, + type LayoutNode, + type LayoutRect, + type PaginatedLayoutResult, + type PaginatedPage, + type ResolvedPageSpec, + type StackLayoutNode, + type TextLayoutNode +} from 'vitrine-layout-pagination'; + +interface RenderTheme { + canvasFill: string; + pageFill: string; + pageStroke: string; + shadowFill: string; + textFill: string; + mutedTextFill: string; + accentFill: string; + artifactFills: Record<'background' | 'header' | 'footer' | 'foreground', string>; +} + +interface FlowPreviewOptions { + x?: number; + y?: number; + scale?: number; + pageGap?: number; + columns?: number; + showPageLabels?: boolean; +} + +interface PresentationPreviewOptions { + x?: number; + y?: number; + mainScale?: number; + thumbnailScale?: number; + thumbnailGap?: number; + currentPageIndex?: number; +} + +function scaleLength(value: number, scale: number): number { + return value * scale; +} + +function scaleInsets(insets: LayoutInsets, scale: number): LayoutInsets { + return { + top: insets.top * scale, + right: insets.right * scale, + bottom: insets.bottom * scale, + left: insets.left * scale + }; +} + +function scaleFontString(font: string | undefined, scale: number): string | undefined { + if (!font) { + return undefined; + } + return font.replace(/(\d+(?:\.\d+)?)px/g, (_match, size) => `${Math.max(1, Number(size) * scale)}px`); +} + +const themeDefault: RenderTheme = { + canvasFill: '#0f172a', + pageFill: '#ffffff', + pageStroke: '#cbd5e1', + shadowFill: 'rgba(15, 23, 42, 0.14)', + textFill: '#0f172a', + mutedTextFill: '#475569', + accentFill: '#2563eb', + artifactFills: { + background: 'rgba(59, 130, 246, 0.06)', + header: 'rgba(59, 130, 246, 0.08)', + footer: 'rgba(148, 163, 184, 0.10)', + foreground: 'rgba(14, 165, 233, 0.05)' + } +}; + +function scaleRect(rect: LayoutRect, pageX: number, pageY: number, scale: number): LayoutRect { + return { + x: pageX + rect.x * scale, + y: pageY + rect.y * scale, + width: rect.width * scale, + height: rect.height * scale + }; +} + +function estimateTextHeight(node: TextLayoutNode, width: number): number { + const fontSize = typeof node.textStyle?.fontSize === 'number' ? node.textStyle.fontSize : 16; + const lineHeight = typeof node.textStyle?.lineHeight === 'number' ? node.textStyle.lineHeight : fontSize * 1.4; + const cCharsPerLine = Math.max(1, Math.floor(Math.max(width, fontSize) / Math.max(1, fontSize * 0.58))); + return node.text + .split('\n') + .reduce((count, line) => count + Math.max(1, Math.ceil(line.length / cCharsPerLine)), 0) * lineHeight; +} + +function estimateNodeHeight(node: LayoutNode, width: number): number { + switch (node.kind) { + case 'text': + return estimateTextHeight(node, width); + case 'spacer': + return node.size; + case 'box': { + const insets = normalizeLayoutInsets(node.padding); + const childHeight = node.child ? estimateNodeHeight(node.child, Math.max(0, width - insets.left - insets.right)) : 0; + const naturalHeight = childHeight + insets.top + insets.bottom; + let nextHeight = node.height ?? naturalHeight; + if (node.minHeight !== undefined) { + nextHeight = Math.max(nextHeight, node.minHeight); + } + if (node.maxHeight !== undefined) { + nextHeight = Math.min(nextHeight, node.maxHeight); + } + return nextHeight; + } + case 'stack': { + const insets = normalizeLayoutInsets(node.padding); + const innerWidth = Math.max(0, width - insets.left - insets.right); + let height = insets.top + insets.bottom; + node.children.forEach((child, index) => { + height += estimateNodeHeight(child, innerWidth); + if (index < node.children.length - 1) { + height += node.gap ?? 0; + } + }); + return height; + } + case 'measured': + case 'table': + return 96; + case 'pageBreak': + return 0; + case 'fixed': + return estimateNodeHeight(node.child, width); + } +} + +function findNodeById(node: LayoutNode | undefined, id: string): LayoutNode | null { + if (!node) { + return null; + } + if (node.id === id) { + return node; + } + switch (node.kind) { + case 'stack': + for (const child of node.children) { + const found = findNodeById(child, id); + if (found) { + return found; + } + } + return null; + case 'box': + return node.child ? findNodeById(node.child, id) : null; + case 'fixed': + return findNodeById(node.child, id); + default: + return null; + } +} + +function lookupNode(document: LayoutDocument, fragment: LayoutFragment, pageIndex: number): LayoutNode | null { + if (fragment.artifactKind) { + switch (fragment.artifactKind) { + case 'background': + return document.background ?? null; + case 'foreground': + return document.foreground ?? null; + case 'header': + return document.kind === 'flow' ? document.header ?? null : null; + case 'footer': + return document.kind === 'flow' ? document.footer ?? null : null; + } + } + + if (document.kind === 'presentation') { + return document.slides[pageIndex] ?? null; + } + + if (!fragment.nodeId) { + return null; + } + return findNodeById(document.body, fragment.nodeId) + ?? findNodeById(document.header, fragment.nodeId) + ?? findNodeById(document.footer, fragment.nodeId) + ?? findNodeById(document.background, fragment.nodeId) + ?? findNodeById(document.foreground, fragment.nodeId); +} + +function buildTextBlock(node: TextLayoutNode, rect: LayoutRect, theme: RenderTheme, scale: number): Block { + const fontSize = (typeof node.textStyle?.fontSize === 'number' ? node.textStyle.fontSize : 16) * scale; + const dyLineHeight = (typeof node.textStyle?.lineHeight === 'number' + ? node.textStyle.lineHeight + : (typeof node.textStyle?.fontSize === 'number' ? node.textStyle.fontSize : 16) * 1.4) * scale; + const fill = typeof node.textStyle?.fill === 'string' ? node.textStyle.fill : theme.textFill; + const font = typeof node.textStyle?.font === 'string' + ? scaleFontString(node.textStyle.font, scale) + : undefined; + return text({ + x: rect.x, + y: rect.y, + text: node.text, + dx: rect.width, + fontSize, + dyLineHeight, + font, + fill, + baseline: 'top' + }); +} + +function buildBoxBlocks(node: BoxLayoutNode, rect: LayoutRect, theme: RenderTheme, scale: number): Block[] { + const insets = scaleInsets(normalizeLayoutInsets(node.padding), scale); + const fill = rect.height <= 64 ? '#dbeafe' : '#eff6ff'; + const blocks: Block[] = [ + rectangle({ + x: rect.x, + y: rect.y, + dx: rect.width, + dy: rect.height, + fill, + stroke: '#93c5fd', + strokeWidth: 1, + cornerRadius: 12 * scale + }) + ]; + + if (node.child) { + blocks.push(...renderNodeIntoRect(node.child, { + x: rect.x + insets.left, + y: rect.y + insets.top, + width: Math.max(0, rect.width - insets.left - insets.right), + height: Math.max(0, rect.height - insets.top - insets.bottom) + }, theme, scale)); + } + return blocks; +} + +function buildMeasuredPlaceholder(node: LayoutNode, rect: LayoutRect, label: string, theme: RenderTheme, scale: number): Block[] { + return [ + rectangle({ + x: rect.x, + y: rect.y, + dx: rect.width, + dy: rect.height, + fill: '#f8fafc', + stroke: '#cbd5e1', + strokeWidth: 1, + cornerRadius: 10 * scale + }), + text({ + x: rect.x + 16 * scale, + y: rect.y + 16 * scale, + text: label, + fontSize: 14 * scale, + fill: theme.mutedTextFill, + baseline: 'top' + }) + ]; +} + +function renderStackIntoRect(node: StackLayoutNode, rect: LayoutRect, theme: RenderTheme, scale: number): Block[] { + const insets = scaleInsets(normalizeLayoutInsets(node.padding), scale); + const innerRect: LayoutRect = { + x: rect.x + insets.left, + y: rect.y + insets.top, + width: Math.max(0, rect.width - insets.left - insets.right), + height: Math.max(0, rect.height - insets.top - insets.bottom) + }; + let yCursor = innerRect.y; + const blocks: Block[] = []; + + node.children.forEach((child, index) => { + const childHeight = estimateNodeHeight(child, innerRect.width / Math.max(scale, 0.0001)) * scale; + const childRect: LayoutRect = { + x: innerRect.x, + y: yCursor, + width: innerRect.width, + height: childHeight + }; + blocks.push(...renderNodeIntoRect(child, childRect, theme, scale)); + yCursor += childHeight; + if (index < node.children.length - 1) { + yCursor += (node.gap ?? 0) * scale; + } + }); + + return blocks; +} + +function renderNodeIntoRect(node: LayoutNode, rect: LayoutRect, theme: RenderTheme, scale: number): Block[] { + switch (node.kind) { + case 'text': + return [buildTextBlock(node, rect, theme, scale)]; + case 'spacer': + return []; + case 'stack': + return renderStackIntoRect(node, rect, theme, scale); + case 'box': + return buildBoxBlocks(node, rect, theme, scale); + case 'measured': + return buildMeasuredPlaceholder(node, rect, `Measured: ${node.measureKey}`, theme, scale); + case 'table': + return buildMeasuredPlaceholder(node, rect, `Table: ${node.tableId}`, theme, scale); + case 'fixed': + return renderNodeIntoRect((node as FixedLayoutNode).child, rect, theme, scale); + case 'pageBreak': + return []; + } +} + +function renderPageFragments( + document: LayoutDocument, + page: PaginatedPage, + pageX: number, + pageY: number, + scale: number, + theme: RenderTheme +): Block[] { + const blocks: Block[] = [ + rectangle({ + x: pageX + 8, + y: pageY + 10, + dx: page.page.width * scale, + dy: page.page.height * scale, + fill: theme.shadowFill, + cornerRadius: 18 + }), + rectangle({ + x: pageX, + y: pageY, + dx: page.page.width * scale, + dy: page.page.height * scale, + fill: theme.pageFill, + stroke: theme.pageStroke, + strokeWidth: 1, + cornerRadius: 18 + }) + ]; + + for (const fragment of page.fragments) { + const rectScaled = scaleRect(fragment.rect, pageX, pageY, scale); + if (fragment.artifactKind) { + blocks.push(rectangle({ + x: rectScaled.x, + y: rectScaled.y, + dx: rectScaled.width, + dy: rectScaled.height, + fill: theme.artifactFills[fragment.artifactKind] + })); + } + const node = lookupNode(document, fragment, page.index); + if (!node) { + continue; + } + blocks.push(...renderNodeIntoRect(node, rectScaled, theme, scale)); + } + + return blocks; +} + +export function buildFlowDocumentPreview( + document: LayoutDocument, + result: PaginatedLayoutResult, + options: FlowPreviewOptions = {} +): Block { + const xStart = options.x ?? 40; + const yStart = options.y ?? 60; + const scale = options.scale ?? 0.34; + const pageGap = options.pageGap ?? 28; + const cColumns = Math.max(1, options.columns ?? 2); + const pageWidthScaled = result.page.width * scale; + const pageHeightScaled = result.page.height * scale; + const pageStrideX = pageWidthScaled + pageGap; + const pageStrideY = pageHeightScaled + pageGap; + const children: Block[] = [ + rectangle({ + dx: 1280, + dy: 900, + fill: themeDefault.canvasFill + }), + text({ + x: 44, + y: 26, + text: `Flow document preview · ${result.pages.length} pages`, + fontSize: 22, + fill: '#e2e8f0', + baseline: 'top' + }), + text({ + x: 44, + y: 58, + text: 'Semantic pagination with repeated page furniture and page breaks.', + fontSize: 13, + fill: '#94a3b8', + baseline: 'top' + }) + ]; + + result.pages.forEach((page, index) => { + const col = index % cColumns; + const row = Math.floor(index / cColumns); + const pageX = xStart + col * pageStrideX; + const pageY = yStart + row * pageStrideY; + children.push(...renderPageFragments(document, page, pageX, pageY, scale, themeDefault)); + if (options.showPageLabels !== false) { + children.push(text({ + x: pageX + 14, + y: pageY + 14, + text: `Page ${index + 1}`, + fontSize: 11, + fill: themeDefault.mutedTextFill, + baseline: 'top' + })); + } + }); + + return group({}, children); +} + +export function buildPresentationPreview( + document: LayoutDocument, + result: PaginatedLayoutResult, + options: PresentationPreviewOptions = {} +): Block { + const iCurrent = Math.max(0, Math.min(options.currentPageIndex ?? 0, result.pages.length - 1)); + const mainScale = options.mainScale ?? 0.5; + const thumbScale = options.thumbnailScale ?? 0.14; + const thumbGap = options.thumbnailGap ?? 18; + const xStart = options.x ?? 40; + const yStart = options.y ?? 64; + const pageMain = result.pages[iCurrent]!; + const dxMain = result.page.width * mainScale; + const dyMain = result.page.height * mainScale; + const children: Block[] = [ + rectangle({ + dx: 1280, + dy: 860, + fill: '#020617' + }), + text({ + x: 44, + y: 26, + text: `Presentation preview · slide ${iCurrent + 1} of ${result.pages.length}`, + fontSize: 22, + fill: '#e2e8f0', + baseline: 'top' + }), + text({ + x: 44, + y: 58, + text: 'Explicit slide pages with fixed 16:9 geometry for browser presentations.', + fontSize: 13, + fill: '#94a3b8', + baseline: 'top' + }), + ...renderPageFragments(document, pageMain, xStart, yStart, mainScale, themeDefault) + ]; + + result.pages.forEach((page, index) => { + const thumbX = xStart + index * (result.page.width * thumbScale + thumbGap); + const thumbY = yStart + dyMain + 34; + const scale = thumbScale; + children.push(...renderPageFragments(document, page, thumbX, thumbY, scale, { + ...themeDefault, + pageStroke: index === iCurrent ? themeDefault.accentFill : themeDefault.pageStroke + })); + children.push(text({ + x: thumbX + 8, + y: thumbY + 8, + text: String(index + 1), + fontSize: 10, + fill: index === iCurrent ? themeDefault.accentFill : themeDefault.mutedTextFill, + baseline: 'top' + })); + }); + + return group({}, children); +} diff --git a/packages/demo/layout-pagination-scenarios.ts b/packages/demo/layout-pagination-scenarios.ts new file mode 100644 index 0000000..8055705 --- /dev/null +++ b/packages/demo/layout-pagination-scenarios.ts @@ -0,0 +1,266 @@ +import type { LayoutDocument } from 'vitrine-layout-pagination'; +import { createPaginatedLayoutEngine } from 'vitrine-layout-pagination'; + +function paragraph(id: string, text: string): LayoutDocument['body'] { + return { + kind: 'text', + id, + text, + textStyle: { + fontSize: 16, + lineHeight: 24, + fill: '#1e293b' + } + }; +} + +export const documentPresentation: LayoutDocument = { + kind: 'presentation', + id: 'browser-deck', + page: { + width: 1280, + height: 720, + unit: 'px', + margins: { + top: 72, + right: 92, + bottom: 72, + left: 92 + } + }, + slides: [ + { + kind: 'stack', + id: 'slide-title', + gap: 28, + children: [ + { + kind: 'text', + id: 'slide-title-heading', + text: 'Q3 Product Narrative', + textStyle: { fontSize: 42, fill: '#0f172a', lineHeight: 52 } + }, + { + kind: 'text', + id: 'slide-title-subheading', + text: 'A browser-authored slide deck with fixed 16:9 page geometry and reusable page semantics.', + textStyle: { fontSize: 22, fill: '#334155', lineHeight: 32 } + }, + { kind: 'spacer', size: 32 }, + { + kind: 'box', + id: 'slide-title-hero', + height: 220, + padding: { top: 24, right: 24, bottom: 24, left: 24 }, + child: { + kind: 'text', + id: 'slide-title-hero-text', + text: 'Every slide is an explicit page, not a flow fragment. That means browser preview, PDF export, and page-level transitions can all share the same authored geometry.', + textStyle: { fontSize: 24, fill: '#1e293b', lineHeight: 34 } + } + } + ] + }, + { + kind: 'stack', + id: 'slide-agenda', + gap: 20, + children: [ + { + kind: 'text', + id: 'slide-agenda-heading', + text: 'Why explicit presentation pages?', + textStyle: { fontSize: 36, fill: '#0f172a', lineHeight: 44 } + }, + { + kind: 'box', + id: 'slide-agenda-card-1', + height: 132, + padding: { top: 18, right: 20, bottom: 18, left: 20 }, + child: { + kind: 'text', + id: 'slide-agenda-card-1-text', + text: '1. Page size and aspect ratio stay stable across browsers and projectors.', + textStyle: { fontSize: 20, fill: '#1e293b', lineHeight: 30 } + } + }, + { + kind: 'box', + id: 'slide-agenda-card-2', + height: 132, + padding: { top: 18, right: 20, bottom: 18, left: 20 }, + child: { + kind: 'text', + id: 'slide-agenda-card-2-text', + text: '2. Slide-level semantics make PDF export and page thumbnails straightforward.', + textStyle: { fontSize: 20, fill: '#1e293b', lineHeight: 30 } + } + }, + { + kind: 'box', + id: 'slide-agenda-card-3', + height: 132, + padding: { top: 18, right: 20, bottom: 18, left: 20 }, + child: { + kind: 'text', + id: 'slide-agenda-card-3-text', + text: '3. A later adapter can turn the same page model into browser previews, canvases, or exported files.', + textStyle: { fontSize: 20, fill: '#1e293b', lineHeight: 30 } + } + } + ] + }, + { + kind: 'stack', + id: 'slide-closing', + gap: 26, + children: [ + { + kind: 'text', + id: 'slide-closing-heading', + text: 'Next step', + textStyle: { fontSize: 38, fill: '#0f172a', lineHeight: 48 } + }, + { + kind: 'text', + id: 'slide-closing-body', + text: 'Keep the document model renderer-agnostic, then add richer slide composition and a Vitrine preview adapter without confusing slide pages with paginated flow content.', + textStyle: { fontSize: 24, fill: '#334155', lineHeight: 34 } + }, + { kind: 'spacer', size: 30 }, + { + kind: 'box', + id: 'slide-closing-banner', + height: 150, + padding: { top: 28, right: 28, bottom: 28, left: 28 }, + child: { + kind: 'text', + id: 'slide-closing-banner-text', + text: 'Presentation pages: explicit, stable, and ready for browser preview.', + textStyle: { fontSize: 28, fill: '#1d4ed8', lineHeight: 38 } + } + } + ] + } + ] +}; + +export const presentationResult = createPaginatedLayoutEngine().layout(documentPresentation); + +export const documentFlow: LayoutDocument = { + kind: 'flow', + id: 'markdown-doc', + page: { + width: 612, + height: 792, + unit: 'pt', + margins: { + top: 54, + right: 54, + bottom: 54, + left: 54 + } + }, + background: { + kind: 'box', + id: 'doc-bg', + child: undefined + }, + header: { + kind: 'text', + id: 'doc-header', + text: 'Vitrine Layout & Pagination — Draft Notes', + textStyle: { + fontSize: 14, + lineHeight: 18, + fill: '#1d4ed8' + } + }, + footer: { + kind: 'text', + id: 'doc-footer', + text: 'Semantic flow document · repeated footer region', + textStyle: { + fontSize: 12, + lineHeight: 16, + fill: '#64748b' + } + }, + body: { + kind: 'stack', + id: 'doc-body', + gap: 16, + children: [ + { + kind: 'text', + id: 'doc-title', + text: 'Paginated Markdown-like Documentation', + textStyle: { + fontSize: 28, + lineHeight: 36, + fill: '#0f172a' + } + }, + paragraph( + 'doc-intro', + 'This scenario represents markdown or extended-markdown content rendered through text and texta primitives. The authored content should paginate semantically, with repeated headers and footers, instead of arbitrary page slicing.' + ), + paragraph( + 'doc-section-1', + 'The flow engine should own page geometry, content boxes, and break rules. Rich text, tables, images, and custom blocks can participate through measurement contracts so the pagination layer stays renderer-agnostic.' + ), + paragraph( + 'doc-section-2', + 'That same paginated structure should later feed browser previews and PDF export. The goal is for document flow to decide where page boundaries belong, while renderers decide how the resulting pages are drawn.' + ), + { + kind: 'box', + id: 'doc-callout', + height: 150, + padding: { top: 18, right: 20, bottom: 18, left: 20 }, + child: { + kind: 'text', + id: 'doc-callout-text', + text: 'Callout: a semantic document paginator should prefer stable section and paragraph breaks over viewport slicing, especially for exported PDFs.', + textStyle: { + fontSize: 18, + lineHeight: 28, + fill: '#1e293b' + } + } + }, + paragraph( + 'doc-section-3', + 'Tables and other measured blocks should integrate through delegates so pagination can see their intrinsic sizes and make better break decisions. This is especially important for documentation with callouts, examples, and embedded diagrams.' + ), + { + kind: 'pageBreak', + id: 'doc-break-1' + }, + { + kind: 'text', + id: 'doc-section-4-title', + text: 'Second chapter', + textStyle: { + fontSize: 22, + lineHeight: 30, + fill: '#0f172a' + } + }, + paragraph( + 'doc-section-4', + 'An explicit page break is still useful in documentation workflows. It lets authors control chapter starts or front matter while still relying on automatic pagination within each chapter.' + ), + paragraph( + 'doc-section-5', + 'The preview shown here is intentionally document-like: multiple pages at once, repeated furniture, and consistent body boxes. It is not a presentation deck or a freeform canvas scene.' + ), + paragraph( + 'doc-section-6', + 'Later phases can add richer fragmentation for complex boxes, better typography-aware text handling, and adapters that convert these paginated results into Vitrine block trees or PDF pages with higher fidelity.' + ) + ] + } +}; + +export const flowResult = createPaginatedLayoutEngine().layout(documentFlow); diff --git a/packages/demo/package.json b/packages/demo/package.json index 56fb5ac..cbb1125 100644 --- a/packages/demo/package.json +++ b/packages/demo/package.json @@ -13,7 +13,8 @@ "dependencies": { "texta": "workspace:*", "vitrine": "workspace:*", - "vitrine-gui": "workspace:*" + "vitrine-gui": "workspace:*", + "vitrine-layout-pagination": "workspace:*" }, "devDependencies": { "@types/node": "^22.10.5", diff --git a/packages/demo/text-selection-debug-texta.html b/packages/demo/text-selection-debug-texta.html index 725c191..feb9633 100644 --- a/packages/demo/text-selection-debug-texta.html +++ b/packages/demo/text-selection-debug-texta.html @@ -88,8 +88,8 @@

Performance and Cache Counters

diff --git a/packages/demo/text-selection-debug.html b/packages/demo/text-selection-debug.html index 86c845b..837bb5b 100644 --- a/packages/demo/text-selection-debug.html +++ b/packages/demo/text-selection-debug.html @@ -4,45 +4,32 @@ - Vitrine - Text Selection Debug - + + Redirecting to Text Selection… + -
-

Vitrine Text Selection Debug

-

- Testing text selection with various text block configurations: different fonts, sizes, line spacing, and multi-line wrapping. -

-
-
-

Canvas with various text blocks for debugging. Check debug output below when clicking.

-
- -
-

Text Cache Counters

-
-
- Fonts (G/P/M) - 0 / 0 / 0 -
-
- Entries (G/P/M) - 0 / 0 / 0 -
-
-
- -
- Click on text blocks and check debug output here. - Coordinates will show canvas size, click position, and hit test results. -
-
- - - - +

Redirecting to Text Selection…

+ diff --git a/packages/demo/text-selection.html b/packages/demo/text-selection.html index 148d617..1610648 100644 --- a/packages/demo/text-selection.html +++ b/packages/demo/text-selection.html @@ -49,8 +49,8 @@

Vitrine Text Selection

diff --git a/packages/demo/vite.config.ts b/packages/demo/vite.config.ts index 29f4a1e..a994b59 100644 --- a/packages/demo/vite.config.ts +++ b/packages/demo/vite.config.ts @@ -23,6 +23,8 @@ export default defineConfig({ componentDemo: resolve(__dirname, 'component-demo.html'), texta: resolve(__dirname, 'texta.html'), markdown: resolve(__dirname, 'markdown.html'), + layoutPaginationPresentation: resolve(__dirname, 'layout-pagination-presentation.html'), + layoutPaginationFlow: resolve(__dirname, 'layout-pagination-flow.html'), tables: resolve(__dirname, 'tables.html'), textSelection: resolve(__dirname, 'text-selection.html'), textSelectionDebug: resolve(__dirname, 'text-selection-debug.html'), @@ -37,6 +39,7 @@ export default defineConfig({ 'texta/markdown': resolve(__dirname, '../texta/src/markdown.ts'), 'vitrine-tables': resolve(__dirname, '../tables/src/index.ts'), 'vitrine-tables-adapter': resolve(__dirname, '../tables-vitrine/src/index.ts'), + 'vitrine-layout-pagination': resolve(__dirname, '../layout-pagination/src/index.ts'), 'vitrine': resolve(__dirname, '../core/src/index.ts'), 'vitrine-gui': resolve(__dirname, '../gui/src/index.ts') } diff --git a/packages/layout-pagination/package.json b/packages/layout-pagination/package.json new file mode 100644 index 0000000..44fff65 --- /dev/null +++ b/packages/layout-pagination/package.json @@ -0,0 +1,29 @@ +{ + "name": "vitrine-layout-pagination", + "version": "0.1.0", + "description": "Renderer-agnostic layout and pagination contracts for Vitrine", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "lint": "tsc --noEmit", + "test": "vitest run", + "clean": "rm -rf dist" + }, + "license": "MIT", + "devDependencies": { + "typescript": "^5.7.3", + "vitest": "^3.0.0" + } +} diff --git a/packages/layout-pagination/src/engine.ts b/packages/layout-pagination/src/engine.ts new file mode 100644 index 0000000..b10aaae --- /dev/null +++ b/packages/layout-pagination/src/engine.ts @@ -0,0 +1,529 @@ +import type { + FlowLayoutDocument, + LayoutDocument, + LayoutFragment, + LayoutNode, + LayoutPaginationOptions, + LayoutRect, + PaginatedLayoutResult, + PaginationDiagnostic, + PageArtifactKind, + PresentationLayoutDocument, + ResolvedPageSpec +} from './model.ts'; +import { normalizeLayoutInsets, resolvePageSpec } from './model.ts'; +import type { LayoutMeasureDelegate, LayoutMeasuredContent } from './measure.ts'; +import { createNullMeasureDelegate } from './measure.ts'; + +export interface PreparedFlowLayoutDocument { + kind: 'flow'; + documentId: string; + page: ResolvedPageSpec; + body: LayoutNode; + header?: LayoutNode; + footer?: LayoutNode; + background?: LayoutNode; + foreground?: LayoutNode; +} + +export interface PreparedPresentationLayoutDocument { + kind: 'presentation'; + documentId: string; + page: ResolvedPageSpec; + slides: LayoutNode[]; + previewScale: 'contain' | 'cover' | 'stretch'; + background?: LayoutNode; + foreground?: LayoutNode; +} + +export type PreparedLayoutDocument = + | PreparedFlowLayoutDocument + | PreparedPresentationLayoutDocument; + +interface FlowLayoutRuntime { + page: ResolvedPageSpec; + delegate: LayoutMeasureDelegate; + diagnostics: PaginationDiagnostic[]; + pages: PaginatedLayoutResult['pages']; + bodyBox: LayoutRect; + maxPages: number; + currentPageIndex: number; + cursorY: number; +} + +function assertRootNodeAllowed(node: LayoutNode, role: 'body' | 'header' | 'footer' | 'background' | 'foreground'): void { + if (node.kind === 'fixed' && role !== 'body') { + throw new Error(`Nested fixed nodes are not allowed in ${role}.`); + } +} + +function validateFlowDocument(document: FlowLayoutDocument): void { + assertRootNodeAllowed(document.body, 'body'); + if (document.header) { + assertRootNodeAllowed(document.header, 'header'); + } + if (document.footer) { + assertRootNodeAllowed(document.footer, 'footer'); + } + if (document.background) { + assertRootNodeAllowed(document.background, 'background'); + } + if (document.foreground) { + assertRootNodeAllowed(document.foreground, 'foreground'); + } +} + +function validatePresentationDocument(document: PresentationLayoutDocument): void { + if (document.slides.length === 0) { + throw new Error('Presentation documents must define at least one slide.'); + } + + for (const slide of document.slides) { + assertRootNodeAllowed(slide, 'body'); + } + if (document.background) { + assertRootNodeAllowed(document.background, 'background'); + } + if (document.foreground) { + assertRootNodeAllowed(document.foreground, 'foreground'); + } +} + +export function validateLayoutDocument(document: LayoutDocument): void { + if (document.id.trim().length === 0) { + throw new Error('Layout document id must be non-empty.'); + } + + if (document.kind === 'presentation') { + validatePresentationDocument(document); + return; + } + + validateFlowDocument(document); +} + +export function prepareLayoutDocument(document: LayoutDocument): PreparedLayoutDocument { + validateLayoutDocument(document); + + const page = resolvePageSpec(document.page); + if (document.kind === 'presentation') { + return { + kind: 'presentation', + documentId: document.id, + page, + slides: document.slides, + previewScale: document.previewScale ?? 'contain', + background: document.background, + foreground: document.foreground + }; + } + + return { + kind: 'flow', + documentId: document.id, + page, + body: document.body, + header: document.header, + footer: document.footer, + background: document.background, + foreground: document.foreground + }; +} + +function cloneRect(rect: LayoutRect): LayoutRect { + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; +} + +function clampDimension(value: number, min?: number, max?: number): number { + let next = value; + if (min !== undefined) { + next = Math.max(min, next); + } + if (max !== undefined) { + next = Math.min(max, next); + } + return next; +} + +function addDiagnostic( + diagnostics: PaginationDiagnostic[], + diagnostic: PaginationDiagnostic +): void { + diagnostics.push(diagnostic); +} + +function estimateTextHeight(text: string, availableWidth: number, textStyle?: Record): number { + const fontSize = typeof textStyle?.fontSize === 'number' ? textStyle.fontSize : 16; + const lineHeight = typeof textStyle?.lineHeight === 'number' ? textStyle.lineHeight : fontSize * 1.4; + const charsPerLine = Math.max(1, Math.floor(Math.max(availableWidth, fontSize) / Math.max(1, fontSize * 0.6))); + return text + .split('\n') + .reduce((count, line) => count + Math.max(1, Math.ceil(line.length / charsPerLine)), 0) * lineHeight; +} + +function measureLeafNode( + node: LayoutNode, + availableWidth: number, + page: ResolvedPageSpec, + delegate: LayoutMeasureDelegate, + diagnostics: PaginationDiagnostic[] +): { width: number; height: number; renderData?: unknown } { + const measured = delegate.measure({ + node, + availableSpace: { + width: availableWidth, + height: page.contentBox.height + }, + page + }); + if (measured) { + return { + width: measured.preferredRect?.width ?? availableWidth, + height: measured.preferredRect?.height ?? measured.intrinsicSize.minHeight, + renderData: measured.renderData + }; + } + + switch (node.kind) { + case 'spacer': + return { width: availableWidth, height: node.size }; + case 'text': + return { + width: availableWidth, + height: estimateTextHeight(node.text, availableWidth, node.textStyle) + }; + case 'measured': + case 'table': + addDiagnostic(diagnostics, { + code: 'missing-measure-delegate', + severity: 'warning', + message: `No measure delegate provided for ${node.kind} node.`, + nodeId: node.id + }); + return { width: availableWidth, height: 0 }; + default: + return { width: availableWidth, height: 0 }; + } +} + +function estimateNodeHeight( + node: LayoutNode, + availableWidth: number, + page: ResolvedPageSpec, + delegate: LayoutMeasureDelegate, + diagnostics: PaginationDiagnostic[] +): number { + switch (node.kind) { + case 'stack': { + const padding = normalizeLayoutInsets(node.padding); + let height = padding.top + padding.bottom; + node.children.forEach((child, index) => { + height += estimateNodeHeight(child, Math.max(0, availableWidth - padding.left - padding.right), page, delegate, diagnostics); + if (index < node.children.length - 1) { + height += node.gap ?? 0; + } + }); + return height; + } + case 'box': { + const padding = normalizeLayoutInsets(node.padding); + const childHeight = node.child + ? estimateNodeHeight(node.child, Math.max(0, availableWidth - padding.left - padding.right), page, delegate, diagnostics) + : 0; + const naturalHeight = childHeight + padding.top + padding.bottom; + return clampDimension(node.height ?? naturalHeight, node.minHeight, node.maxHeight); + } + case 'pageBreak': + return 0; + default: + return measureLeafNode(node, availableWidth, page, delegate, diagnostics).height; + } +} + +function createArtifactFragment( + node: LayoutNode, + pageIndex: number, + rect: LayoutRect, + artifactKind: PageArtifactKind +): LayoutFragment { + return { + nodeId: node.id, + nodeKind: node.kind, + pageIndex, + rect, + continuation: 'none', + artifactKind + }; +} + +function ensurePage(runtime: FlowLayoutRuntime): void { + if (runtime.pages[runtime.currentPageIndex]) { + return; + } + if (runtime.currentPageIndex >= runtime.maxPages) { + throw new Error(`Pagination exceeded maxPages=${runtime.maxPages}.`); + } + runtime.pages.push({ + index: runtime.currentPageIndex, + page: runtime.page, + bodyBox: cloneRect(runtime.bodyBox), + fragments: [] + }); +} + +function currentPage(runtime: FlowLayoutRuntime): PaginatedLayoutResult['pages'][number] { + ensurePage(runtime); + return runtime.pages[runtime.currentPageIndex]!; +} + +function pushPageArtifacts( + pageFragments: LayoutFragment[], + prepared: PreparedFlowLayoutDocument, + pageIndex: number, + bodyBox: LayoutRect, + headerHeight: number, + footerHeight: number, + phase: 'start' | 'end' +): void { + if (phase === 'start' && prepared.background) { + pageFragments.push(createArtifactFragment(prepared.background, pageIndex, cloneRect(prepared.page.contentBox), 'background')); + } + if (phase === 'start' && prepared.header && headerHeight > 0) { + pageFragments.push(createArtifactFragment(prepared.header, pageIndex, { + x: prepared.page.contentBox.x, + y: prepared.page.contentBox.y, + width: prepared.page.contentBox.width, + height: headerHeight + }, 'header')); + } + if (phase === 'end' && prepared.footer && footerHeight > 0) { + pageFragments.push(createArtifactFragment(prepared.footer, pageIndex, { + x: prepared.page.contentBox.x, + y: bodyBox.y + bodyBox.height, + width: prepared.page.contentBox.width, + height: footerHeight + }, 'footer')); + } + if (phase === 'end' && prepared.foreground) { + pageFragments.push(createArtifactFragment(prepared.foreground, pageIndex, cloneRect(prepared.page.contentBox), 'foreground')); + } +} + +function startNewFlowPage( + runtime: FlowLayoutRuntime, + prepared: PreparedFlowLayoutDocument, + headerHeight: number, + footerHeight: number +): void { + ensurePage(runtime); + const page = currentPage(runtime); + if (page.fragments.length === 0) { + pushPageArtifacts(page.fragments, prepared, runtime.currentPageIndex, runtime.bodyBox, headerHeight, footerHeight, 'start'); + } + runtime.cursorY = runtime.bodyBox.y; +} + +function advancePage( + runtime: FlowLayoutRuntime, + prepared: PreparedFlowLayoutDocument, + headerHeight: number, + footerHeight: number +): void { + runtime.currentPageIndex += 1; + ensurePage(runtime); + const page = currentPage(runtime); + if (page.fragments.length === 0) { + pushPageArtifacts(page.fragments, prepared, runtime.currentPageIndex, runtime.bodyBox, headerHeight, footerHeight, 'start'); + } + runtime.cursorY = runtime.bodyBox.y; +} + +function emitLeafFragment( + runtime: FlowLayoutRuntime, + node: LayoutNode, + width: number, + height: number, + renderData?: unknown +): void { + currentPage(runtime).fragments.push({ + nodeId: node.id, + nodeKind: node.kind, + pageIndex: runtime.currentPageIndex, + rect: { + x: runtime.bodyBox.x, + y: runtime.cursorY, + width, + height + }, + continuation: 'none', + renderData + }); + runtime.cursorY += height; +} + +function layoutNodeIntoPages( + node: LayoutNode, + runtime: FlowLayoutRuntime, + prepared: PreparedFlowLayoutDocument, + availableWidth: number, + headerHeight: number, + footerHeight: number +): void { + if (node.breakBefore === 'page' || node.kind === 'pageBreak') { + advancePage(runtime, prepared, headerHeight, footerHeight); + if (node.kind === 'pageBreak') { + return; + } + } + + if (node.kind === 'stack') { + const padding = normalizeLayoutInsets(node.padding); + const innerWidth = Math.max(0, availableWidth - padding.left - padding.right); + runtime.cursorY += padding.top; + node.children.forEach((child, index) => { + if (index > 0) { + runtime.cursorY += node.gap ?? 0; + } + const totalHeight = estimateNodeHeight(child, innerWidth, runtime.page, runtime.delegate, runtime.diagnostics); + const remainingHeight = runtime.bodyBox.y + runtime.bodyBox.height - runtime.cursorY; + if (remainingHeight < totalHeight && totalHeight <= runtime.bodyBox.height) { + advancePage(runtime, prepared, headerHeight, footerHeight); + } + layoutNodeIntoPages(child, runtime, prepared, innerWidth, headerHeight, footerHeight); + }); + runtime.cursorY += padding.bottom; + } else if (node.kind === 'box') { + const totalHeight = estimateNodeHeight(node, availableWidth, runtime.page, runtime.delegate, runtime.diagnostics); + const remainingHeight = runtime.bodyBox.y + runtime.bodyBox.height - runtime.cursorY; + if (remainingHeight < totalHeight && totalHeight <= runtime.bodyBox.height) { + advancePage(runtime, prepared, headerHeight, footerHeight); + } + if (totalHeight > runtime.bodyBox.height) { + addDiagnostic(runtime.diagnostics, { + code: 'box-overflow-page', + severity: 'warning', + message: 'Box node is taller than the available page body area.', + nodeId: node.id, + pageIndex: runtime.currentPageIndex + }); + } + emitLeafFragment(runtime, node, availableWidth, totalHeight); + } else { + const measured = measureLeafNode(node, availableWidth, runtime.page, runtime.delegate, runtime.diagnostics); + const remainingHeight = runtime.bodyBox.y + runtime.bodyBox.height - runtime.cursorY; + if (remainingHeight < measured.height && measured.height <= runtime.bodyBox.height) { + advancePage(runtime, prepared, headerHeight, footerHeight); + } + if (measured.height > runtime.bodyBox.height) { + addDiagnostic(runtime.diagnostics, { + code: 'node-overflow-page', + severity: 'warning', + message: `${node.kind} node is taller than the available page body area.`, + nodeId: node.id, + pageIndex: runtime.currentPageIndex + }); + } + emitLeafFragment(runtime, node, measured.width, measured.height, measured.renderData); + } + + if (node.breakAfter === 'page') { + advancePage(runtime, prepared, headerHeight, footerHeight); + } +} + +function layoutFlowDocument( + prepared: PreparedFlowLayoutDocument, + delegate: LayoutMeasureDelegate, + options?: LayoutPaginationOptions +): PaginatedLayoutResult { + const diagnostics: PaginationDiagnostic[] = []; + const headerHeight = prepared.header + ? estimateNodeHeight(prepared.header, prepared.page.contentBox.width, prepared.page, delegate, diagnostics) + : 0; + const footerHeight = prepared.footer + ? estimateNodeHeight(prepared.footer, prepared.page.contentBox.width, prepared.page, delegate, diagnostics) + : 0; + const bodyBox: LayoutRect = { + x: prepared.page.contentBox.x, + y: prepared.page.contentBox.y + headerHeight, + width: prepared.page.contentBox.width, + height: Math.max(0, prepared.page.contentBox.height - headerHeight - footerHeight) + }; + const runtime: FlowLayoutRuntime = { + page: prepared.page, + delegate, + diagnostics, + pages: [], + bodyBox, + maxPages: options?.maxPages ?? 1000, + currentPageIndex: 0, + cursorY: bodyBox.y + }; + + startNewFlowPage(runtime, prepared, headerHeight, footerHeight); + layoutNodeIntoPages(prepared.body, runtime, prepared, bodyBox.width, headerHeight, footerHeight); + runtime.pages.forEach((page, index) => { + pushPageArtifacts(page.fragments, prepared, index, bodyBox, headerHeight, footerHeight, 'end'); + }); + + return { + documentId: prepared.documentId, + page: prepared.page, + pages: runtime.pages, + diagnostics + }; +} + +function layoutPresentationDocument(prepared: PreparedPresentationLayoutDocument): PaginatedLayoutResult { + return { + documentId: prepared.documentId, + page: prepared.page, + diagnostics: [], + pages: prepared.slides.map((slide, index) => ({ + index, + page: prepared.page, + bodyBox: cloneRect(prepared.page.contentBox), + fragments: [ + ...(prepared.background ? [createArtifactFragment(prepared.background, index, cloneRect(prepared.page.contentBox), 'background')] : []), + { + nodeId: slide.id, + nodeKind: slide.kind, + pageIndex: index, + rect: cloneRect(prepared.page.contentBox), + continuation: 'none' + }, + ...(prepared.foreground ? [createArtifactFragment(prepared.foreground, index, cloneRect(prepared.page.contentBox), 'foreground')] : []) + ] + })) + }; +} + +export class PaginatedLayoutEngine { + private delegate: LayoutMeasureDelegate; + + constructor(delegate: LayoutMeasureDelegate = createNullMeasureDelegate()) { + this.delegate = delegate; + } + + getMeasureDelegate(): LayoutMeasureDelegate { + return this.delegate; + } + + prepare(document: LayoutDocument): PreparedLayoutDocument { + return prepareLayoutDocument(document); + } + + layout(document: LayoutDocument, options?: LayoutPaginationOptions): PaginatedLayoutResult { + const prepared = this.prepare(document); + if (prepared.kind === 'presentation') { + return layoutPresentationDocument(prepared); + } + return layoutFlowDocument(prepared, this.delegate, options); + } +} + +export function createPaginatedLayoutEngine( + delegate: LayoutMeasureDelegate = createNullMeasureDelegate() +): PaginatedLayoutEngine { + return new PaginatedLayoutEngine(delegate); +} diff --git a/packages/layout-pagination/src/index.ts b/packages/layout-pagination/src/index.ts new file mode 100644 index 0000000..e17dce0 --- /dev/null +++ b/packages/layout-pagination/src/index.ts @@ -0,0 +1,57 @@ +export type { + BaseLayoutNode, + BoxLayoutNode, + BreakAfterPolicy, + BreakBeforePolicy, + BreakInsidePolicy, + FixedLayoutNode, + FlowLayoutDocument, + FragmentContinuation, + LayoutDocument, + LayoutDocumentKind, + LayoutFragment, + LayoutInsets, + LayoutNode, + LayoutPaginationOptions, + LayoutRect, + LayoutSize, + MeasuredLayoutNode, + PageArtifactKind, + PageBreakLayoutNode, + PageSpec, + PageUnit, + PaginatedLayoutResult, + PaginatedPage, + PaginationDiagnostic, + PaginationDiagnosticSeverity, + PresentationLayoutDocument, + PresentationPreviewScale, + ResolvedPageSpec, + SpacerLayoutNode, + StackLayoutNode, + TableLayoutNode, + TextLayoutNode +} from './model.ts'; +export { + normalizeLayoutInsets, + resolvePageSpec +} from './model.ts'; +export type { + LayoutAvailableSpace, + LayoutIntrinsicSize, + LayoutMeasureDelegate, + LayoutMeasuredContent, + LayoutMeasureRequest +} from './measure.ts'; +export { createNullMeasureDelegate } from './measure.ts'; +export type { + PreparedFlowLayoutDocument, + PreparedLayoutDocument, + PreparedPresentationLayoutDocument +} from './engine.ts'; +export { + createPaginatedLayoutEngine, + PaginatedLayoutEngine, + prepareLayoutDocument, + validateLayoutDocument +} from './engine.ts'; diff --git a/packages/layout-pagination/src/measure.ts b/packages/layout-pagination/src/measure.ts new file mode 100644 index 0000000..d0b19e5 --- /dev/null +++ b/packages/layout-pagination/src/measure.ts @@ -0,0 +1,33 @@ +import type { LayoutNode, LayoutRect, LayoutSize, ResolvedPageSpec } from './model.ts'; + +export interface LayoutAvailableSpace extends LayoutSize {} + +export interface LayoutIntrinsicSize { + minWidth: number; + maxWidth: number; + minHeight: number; +} + +export interface LayoutMeasureRequest { + node: LayoutNode; + availableSpace: LayoutAvailableSpace; + page: ResolvedPageSpec; +} + +export interface LayoutMeasuredContent { + intrinsicSize: LayoutIntrinsicSize; + preferredRect?: LayoutRect; + renderData?: unknown; +} + +export interface LayoutMeasureDelegate { + measure(request: LayoutMeasureRequest): LayoutMeasuredContent | null; +} + +export function createNullMeasureDelegate(): LayoutMeasureDelegate { + return { + measure(): LayoutMeasuredContent | null { + return null; + } + }; +} diff --git a/packages/layout-pagination/src/model.ts b/packages/layout-pagination/src/model.ts new file mode 100644 index 0000000..082adbb --- /dev/null +++ b/packages/layout-pagination/src/model.ts @@ -0,0 +1,226 @@ +export type PageUnit = 'pt' | 'px' | 'mm' | 'in'; + +export interface LayoutInsets { + top: number; + right: number; + bottom: number; + left: number; +} + +export interface LayoutSize { + width: number; + height: number; +} + +export interface LayoutRect extends LayoutSize { + x: number; + y: number; +} + +export interface PageSpec extends LayoutSize { + id?: string; + unit?: PageUnit; + margins?: Partial; +} + +export interface ResolvedPageSpec extends LayoutSize { + id?: string; + unit: PageUnit; + margins: LayoutInsets; + contentBox: LayoutRect; +} + +export type LayoutDocumentKind = 'flow' | 'presentation'; +export type PresentationPreviewScale = 'contain' | 'cover' | 'stretch'; + +export type BreakBeforePolicy = 'auto' | 'page'; +export type BreakAfterPolicy = 'auto' | 'page'; +export type BreakInsidePolicy = 'auto' | 'avoid'; +export type PageArtifactKind = 'header' | 'footer' | 'background' | 'foreground'; + +export interface BaseLayoutNode { + id?: string; + breakBefore?: BreakBeforePolicy; + breakAfter?: BreakAfterPolicy; + breakInside?: BreakInsidePolicy; + keepWithNext?: boolean; +} + +export interface StackLayoutNode extends BaseLayoutNode { + kind: 'stack'; + gap?: number; + padding?: Partial; + children: LayoutNode[]; +} + +export interface BoxLayoutNode extends BaseLayoutNode { + kind: 'box'; + padding?: Partial; + minWidth?: number; + maxWidth?: number; + minHeight?: number; + maxHeight?: number; + width?: number; + height?: number; + child?: LayoutNode; +} + +export interface SpacerLayoutNode extends BaseLayoutNode { + kind: 'spacer'; + size: number; +} + +export interface PageBreakLayoutNode extends BaseLayoutNode { + kind: 'pageBreak'; + reason?: string; +} + +export interface TextLayoutNode extends BaseLayoutNode { + kind: 'text'; + text: string; + textStyle?: Record; +} + +export interface TableLayoutNode extends BaseLayoutNode { + kind: 'table'; + tableId: string; + content: unknown; +} + +export interface MeasuredLayoutNode extends BaseLayoutNode { + kind: 'measured'; + measureKey: string; + content: unknown; +} + +export interface FixedLayoutNode extends BaseLayoutNode { + kind: 'fixed'; + artifactKind: PageArtifactKind; + child: LayoutNode; +} + +export type LayoutNode = + | StackLayoutNode + | BoxLayoutNode + | SpacerLayoutNode + | PageBreakLayoutNode + | TextLayoutNode + | TableLayoutNode + | MeasuredLayoutNode + | FixedLayoutNode; + +export interface FlowLayoutDocument { + kind: 'flow'; + id: string; + page: PageSpec; + body: LayoutNode; + header?: LayoutNode; + footer?: LayoutNode; + background?: LayoutNode; + foreground?: LayoutNode; +} + +export interface PresentationLayoutDocument { + kind: 'presentation'; + id: string; + page: PageSpec; + slides: LayoutNode[]; + previewScale?: PresentationPreviewScale; + background?: LayoutNode; + foreground?: LayoutNode; +} + +export type LayoutDocument = FlowLayoutDocument | PresentationLayoutDocument; + +export type FragmentContinuation = 'none' | 'start' | 'middle' | 'end'; + +export interface LayoutFragment { + nodeId?: string; + pageIndex: number; + rect: LayoutRect; + continuation: FragmentContinuation; + artifactKind?: PageArtifactKind; + nodeKind?: LayoutNode['kind']; + renderData?: unknown; +} + +export interface PaginatedPage { + index: number; + page: ResolvedPageSpec; + bodyBox: LayoutRect; + fragments: LayoutFragment[]; +} + +export type PaginationDiagnosticSeverity = 'info' | 'warning' | 'error'; + +export interface PaginationDiagnostic { + code: string; + severity: PaginationDiagnosticSeverity; + message: string; + nodeId?: string; + pageIndex?: number; +} + +export interface PaginatedLayoutResult { + documentId: string; + page: ResolvedPageSpec; + pages: PaginatedPage[]; + diagnostics: PaginationDiagnostic[]; +} + +export interface LayoutPaginationOptions { + maxPages?: number; +} + +const DEFAULT_PAGE_MARGINS: LayoutInsets = { + top: 48, + right: 48, + bottom: 48, + left: 48 +}; + +const ZERO_INSETS: LayoutInsets = { + top: 0, + right: 0, + bottom: 0, + left: 0 +}; + +export function normalizeLayoutInsets( + insets?: Partial, + defaults: LayoutInsets = ZERO_INSETS +): LayoutInsets { + return { + top: insets?.top ?? defaults.top, + right: insets?.right ?? defaults.right, + bottom: insets?.bottom ?? defaults.bottom, + left: insets?.left ?? defaults.left + }; +} + +export function resolvePageSpec(page: PageSpec): ResolvedPageSpec { + if (!(page.width > 0) || !(page.height > 0)) { + throw new Error('Page width and height must be greater than zero.'); + } + + const margins = normalizeLayoutInsets(page.margins, DEFAULT_PAGE_MARGINS); + const contentWidth = page.width - margins.left - margins.right; + const contentHeight = page.height - margins.top - margins.bottom; + if (contentWidth < 0 || contentHeight < 0) { + throw new Error('Page margins exceed the page size.'); + } + + return { + id: page.id, + width: page.width, + height: page.height, + unit: page.unit ?? 'pt', + margins, + contentBox: { + x: margins.left, + y: margins.top, + width: contentWidth, + height: contentHeight + } + }; +} diff --git a/packages/layout-pagination/test/page-spec.test.ts b/packages/layout-pagination/test/page-spec.test.ts new file mode 100644 index 0000000..e078234 --- /dev/null +++ b/packages/layout-pagination/test/page-spec.test.ts @@ -0,0 +1,281 @@ +import { describe, expect, it } from 'vitest'; +import { + createPaginatedLayoutEngine, + createNullMeasureDelegate, + prepareLayoutDocument, + resolvePageSpec, + validateLayoutDocument +} from '../src/index.ts'; +import type { LayoutDocument, LayoutMeasureRequest, LayoutMeasuredContent } from '../src/index.ts'; + +describe('layout pagination foundations', () => { + it('resolves page specs into a content box', () => { + expect(resolvePageSpec({ + width: 612, + height: 792, + margins: { + top: 36, + right: 24, + bottom: 48, + left: 24 + } + })).toEqual({ + id: undefined, + width: 612, + height: 792, + unit: 'pt', + margins: { + top: 36, + right: 24, + bottom: 48, + left: 24 + }, + contentBox: { + x: 24, + y: 36, + width: 564, + height: 708 + } + }); + }); + + it('rejects margins larger than the page', () => { + expect(() => resolvePageSpec({ + width: 100, + height: 100, + margins: { + left: 60, + right: 60 + } + })).toThrow('Page margins exceed the page size.'); + }); + + it('prepares a validated document through the engine', () => { + const document: LayoutDocument = { + kind: 'flow', + id: 'doc-1', + page: { + width: 595, + height: 842, + unit: 'pt' + }, + body: { + kind: 'stack', + gap: 12, + children: [ + { kind: 'text', text: 'Hello page' }, + { kind: 'spacer', size: 24 } + ] + } + }; + + validateLayoutDocument(document); + const prepared = prepareLayoutDocument(document); + expect(prepared.kind).toBe('flow'); + expect(prepared.page.contentBox.width).toBe(499); + + const engine = createPaginatedLayoutEngine(); + expect(engine.prepare(document)).toEqual(prepared); + }); + + it('prepares presentation documents as explicit slide sequences', () => { + const document: LayoutDocument = { + kind: 'presentation', + id: 'deck-1', + page: { + width: 1280, + height: 720, + unit: 'px', + margins: { + top: 0, + right: 0, + bottom: 0, + left: 0 + } + }, + previewScale: 'contain', + slides: [ + { + kind: 'stack', + children: [ + { kind: 'text', text: 'Title slide' } + ] + }, + { + kind: 'stack', + children: [ + { kind: 'text', text: 'Agenda slide' } + ] + } + ] + }; + + const prepared = prepareLayoutDocument(document); + expect(prepared).toEqual({ + kind: 'presentation', + documentId: 'deck-1', + page: { + id: undefined, + width: 1280, + height: 720, + unit: 'px', + margins: { + top: 0, + right: 0, + bottom: 0, + left: 0 + }, + contentBox: { + x: 0, + y: 0, + width: 1280, + height: 720 + } + }, + previewScale: 'contain', + slides: document.slides, + background: undefined, + foreground: undefined + }); + }); + + it('lays out flow content across multiple pages with repeated artifacts', () => { + const document: LayoutDocument = { + kind: 'flow', + id: 'doc-flow', + page: { + width: 200, + height: 220, + unit: 'px', + margins: { + top: 10, + right: 10, + bottom: 10, + left: 10 + } + }, + header: { + kind: 'spacer', + id: 'header', + size: 20 + }, + footer: { + kind: 'spacer', + id: 'footer', + size: 15 + }, + background: { + kind: 'spacer', + id: 'bg', + size: 0 + }, + foreground: { + kind: 'spacer', + id: 'fg', + size: 0 + }, + body: { + kind: 'stack', + gap: 10, + children: [ + { kind: 'spacer', id: 'a', size: 60 }, + { kind: 'spacer', id: 'b', size: 60 }, + { kind: 'spacer', id: 'c', size: 60 } + ] + } + }; + + const result = createPaginatedLayoutEngine(createNullMeasureDelegate()).layout(document); + expect(result.pages).toHaveLength(2); + expect(result.pages[0]!.bodyBox).toEqual({ + x: 10, + y: 30, + width: 180, + height: 165 + }); + expect(result.pages[0]!.fragments.map((fragment) => `${fragment.artifactKind ?? 'body'}:${fragment.nodeId ?? fragment.nodeKind}`)).toEqual([ + 'background:bg', + 'header:header', + 'body:a', + 'body:b', + 'footer:footer', + 'foreground:fg' + ]); + expect(result.pages[1]!.fragments.some((fragment) => fragment.nodeId === 'c')).toBe(true); + }); + + it('honors explicit page breaks in flow documents', () => { + const document: LayoutDocument = { + kind: 'flow', + id: 'doc-break', + page: { + width: 200, + height: 180, + unit: 'px' + }, + body: { + kind: 'stack', + children: [ + { kind: 'text', id: 'intro', text: 'Intro' }, + { kind: 'pageBreak', id: 'break-1' }, + { kind: 'text', id: 'after-break', text: 'After break' } + ] + } + }; + + const result = createPaginatedLayoutEngine().layout(document); + expect(result.pages).toHaveLength(2); + expect(result.pages[0]!.fragments.some((fragment) => fragment.nodeId === 'intro')).toBe(true); + expect(result.pages[1]!.fragments.some((fragment) => fragment.nodeId === 'after-break')).toBe(true); + }); + + it('uses measurement delegates for measured nodes', () => { + const document: LayoutDocument = { + kind: 'flow', + id: 'doc-measured', + page: { + width: 300, + height: 220, + unit: 'px' + }, + body: { + kind: 'stack', + children: [ + { + kind: 'measured', + id: 'hero', + measureKey: 'hero', + content: { kind: 'hero' } + } + ] + } + }; + + const engine = createPaginatedLayoutEngine({ + measure(request: LayoutMeasureRequest): LayoutMeasuredContent | null { + if (request.node.kind !== 'measured') { + return null; + } + return { + intrinsicSize: { + minWidth: request.availableSpace.width, + maxWidth: request.availableSpace.width, + minHeight: 96 + }, + preferredRect: { + x: 0, + y: 0, + width: request.availableSpace.width, + height: 96 + }, + renderData: { measured: true } + }; + } + }); + + const result = engine.layout(document); + const fragment = result.pages[0]!.fragments.find((entry) => entry.nodeId === 'hero'); + expect(fragment?.rect.height).toBe(96); + expect(fragment?.renderData).toEqual({ measured: true }); + }); +}); diff --git a/packages/layout-pagination/tsconfig.json b/packages/layout-pagination/tsconfig.json new file mode 100644 index 0000000..f543bbd --- /dev/null +++ b/packages/layout-pagination/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "outDir": "./dist", + "rootDir": "./src", + "noEmit": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6618681..d69f74f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,6 +55,15 @@ importers: specifier: workspace:* version: link:../core + packages/layout-pagination: + devDependencies: + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@22.19.11) + packages/tables: devDependencies: typescript: From 878e27478aae237c1187cfbeb88573f99b03a78b Mon Sep 17 00:00:00 2001 From: frouaix Date: Tue, 21 Jul 2026 14:54:34 -0700 Subject: [PATCH 2/4] Refine layout pagination model and docs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/demo/demos/layout-pagination-flow.ts | 33 --- .../demos/layout-pagination-presentation.ts | 47 --- packages/demo/gallery.ts | 5 - packages/layout-pagination/README.md | 269 ++++++++++++++++++ packages/layout-pagination/src/common.ts | 217 ++++++++++++++ packages/layout-pagination/src/engine.ts | 141 +++++---- packages/layout-pagination/src/flow.ts | 42 +++ packages/layout-pagination/src/index.ts | 54 ++-- packages/layout-pagination/src/measure.ts | 13 +- packages/layout-pagination/src/model.ts | 226 --------------- packages/layout-pagination/src/pagination.ts | 95 +++++++ .../layout-pagination/test/page-spec.test.ts | 33 +++ 12 files changed, 778 insertions(+), 397 deletions(-) delete mode 100644 packages/demo/demos/layout-pagination-flow.ts delete mode 100644 packages/demo/demos/layout-pagination-presentation.ts create mode 100644 packages/layout-pagination/README.md create mode 100644 packages/layout-pagination/src/common.ts create mode 100644 packages/layout-pagination/src/flow.ts delete mode 100644 packages/layout-pagination/src/model.ts create mode 100644 packages/layout-pagination/src/pagination.ts diff --git a/packages/demo/demos/layout-pagination-flow.ts b/packages/demo/demos/layout-pagination-flow.ts deleted file mode 100644 index fbc32c4..0000000 --- a/packages/demo/demos/layout-pagination-flow.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { group } from 'vitrine'; -import { buildFlowDocumentPreview } from '../layout-pagination-preview.ts'; -import { documentFlow, flowResult } from '../layout-pagination-scenarios.ts'; - -export const demo = { - id: 'layout-pagination-flow', - name: 'Layout Pagination — Flow Document', - description: 'Markdown-style flow content with semantic page breaks', - category: 'documents', - size: { width: 1280, height: 900 }, - enableCulling: false, - - init: () => ({ - pageCount: flowResult.pages.length - }), - - render: () => group({}, [ - buildFlowDocumentPreview(documentFlow, flowResult, { - columns: 2 - }) - ]), - - code: `const doc: LayoutDocument = { - kind: 'flow', - page: { width: 612, height: 792, unit: 'pt' }, - header, - footer, - body: markdownAsLayoutNodes -}; - -const result = createPaginatedLayoutEngine().layout(doc); -const preview = buildFlowDocumentPreview(doc, result);` -}; diff --git a/packages/demo/demos/layout-pagination-presentation.ts b/packages/demo/demos/layout-pagination-presentation.ts deleted file mode 100644 index e7b3fd0..0000000 --- a/packages/demo/demos/layout-pagination-presentation.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { group } from 'vitrine'; -import { buildPresentationPreview } from '../layout-pagination-preview.ts'; -import { documentPresentation, presentationResult } from '../layout-pagination-scenarios.ts'; - -interface PresentationDemoState { - currentSlideIndex: number; - time: number; -} - -export const demo = { - id: 'layout-pagination-presentation', - name: 'Layout Pagination — Presentation', - description: 'Fixed-size slide pages for browser presentations', - category: 'documents', - size: { width: 1280, height: 860 }, - enableCulling: false, - - init: (): PresentationDemoState => ({ - currentSlideIndex: 0, - time: 0 - }), - - update: (state: PresentationDemoState, dt: number): void => { - state.time += dt; - if (state.time >= 3.5) { - state.time = 0; - state.currentSlideIndex = (state.currentSlideIndex + 1) % presentationResult.pages.length; - } - }, - - render: (state: PresentationDemoState) => group({}, [ - buildPresentationPreview(documentPresentation, presentationResult, { - currentPageIndex: state.currentSlideIndex - }) - ]), - - code: `const deck: LayoutDocument = { - kind: 'presentation', - page: { width: 1280, height: 720, unit: 'px' }, - slides: [titleSlide, agendaSlide, closingSlide] -}; - -const result = createPaginatedLayoutEngine().layout(deck); -const preview = buildPresentationPreview(deck, result, { - currentPageIndex -});` -}; diff --git a/packages/demo/gallery.ts b/packages/demo/gallery.ts index e6a6136..c3bdf33 100644 --- a/packages/demo/gallery.ts +++ b/packages/demo/gallery.ts @@ -49,8 +49,6 @@ import { demo as imageFiltersDemo } from './demos/image-filters.ts'; import { demo as textWrappingDemo } from './demos/text-wrapping.ts'; import { demo as linksDemo } from './demos/links.ts'; import { demo as basicBlocksDemo } from './demos/basic-blocks.ts'; -import { demo as layoutPaginationPresentationDemo } from './demos/layout-pagination-presentation.ts'; -import { demo as layoutPaginationFlowDemo } from './demos/layout-pagination-flow.ts'; // Demo registry const demos: GalleryDemo[] = [ @@ -74,8 +72,6 @@ const demos: GalleryDemo[] = [ { ...guiFormDemo, category: 'ui' }, { ...guiDashboardDemo, category: 'ui' }, { ...guiGalleryDemo, category: 'ui' }, - { ...layoutPaginationPresentationDemo, category: 'documents' }, - { ...layoutPaginationFlowDemo, category: 'documents' }, { ...snakeDemo, category: 'games' } ] as GalleryDemo[]; @@ -141,7 +137,6 @@ function renderDemoList() { 'productivity': { name: 'Productivity', icon: '📋' }, 'creative': { name: 'Creative', icon: '🎨' }, 'ui': { name: 'UI Components', icon: '🎛️' }, - 'documents': { name: 'Documents & Layout', icon: '📄' }, 'games': { name: 'Interactive', icon: '🎮' } }; diff --git a/packages/layout-pagination/README.md b/packages/layout-pagination/README.md new file mode 100644 index 0000000..8c4e7bb --- /dev/null +++ b/packages/layout-pagination/README.md @@ -0,0 +1,269 @@ +# Vitrine Layout & Pagination + +`vitrine-layout-pagination` is a renderer-agnostic package for describing document-like layout trees and turning them into paginated results. + +It exists to solve two related problems: + +1. **Presentation pages** — explicit slide/page sequences with fixed geometry or aspect ratio for browser previews. +2. **Flow documents** — semantic document content that should paginate across pages instead of being sliced arbitrarily by viewport. + +The package does **not** render to canvas or PDF directly. Instead, it produces a paginated model that other layers can preview or export. + +## Package architecture + +The code is split into three model files plus an engine: + +| File | Responsibility | +| --- | --- | +| `src/common.ts` | Shared node types, geometry/inset types, break policies, and layout-node typing helpers | +| `src/flow.ts` | Top-level document modes: flow documents and presentation documents | +| `src/pagination.ts` | Page specs, resolved page geometry, fragments, pages, diagnostics, and pagination results | +| `src/measure.ts` | Measurement contracts for custom/measured content | +| `src/engine.ts` | Validation, document preparation, and the current MVP pagination/layout algorithm | + +## Document modes + +### 1. Presentation documents + +Presentation documents are explicit page sequences: + +- each slide is already a page +- page geometry is fixed +- preview scaling is a display concern, not a layout concern + +Use this mode for browser-authored slide decks or other page-by-page experiences. + +### 2. Flow documents + +Flow documents are semantic content trees: + +- content is laid out into a page body region +- pages may repeat background/header/footer/foreground artifacts +- the engine decides where page breaks occur + +Use this mode for markdown-like or document-like content that should paginate meaningfully. + +## Layout node model + +The node typing pattern mirrors core Vitrine block typing: + +1. `LayoutNodeType` is the discriminant enum +2. `LayoutNodePropsByType` maps each node kind to its prop shape +3. `LayoutNodeForType` produces one concrete node shape +4. `LayoutNode` is the mapped discriminated union over all node types + +This gives the same benefits as core `BlockType` / `BlockPropsByType`: + +- exhaustive switching on `kind` +- precise per-node props +- strongly typed `LayoutNodeOfType` +- a consistent mental model across packages + +Core node kinds currently supported: + +- `stack` +- `box` +- `spacer` +- `pageBreak` +- `text` +- `table` +- `measured` +- `fixed` + +## Core concepts + +### Page spec + +`PageSpec` defines authored page geometry: + +- `width` +- `height` +- `unit` +- optional `margins` + +`resolvePageSpec(...)` turns that into a `ResolvedPageSpec` with an explicit `contentBox`. + +### Fragments + +Pagination output is fragment-based: + +- a fragment belongs to a page +- a fragment has a `rect` +- a fragment may be associated with a `nodeId` +- a fragment may represent a repeated page artifact (`header`, `footer`, etc.) + +This fragment model is the bridge between semantic layout and future preview/export adapters. + +### Measurement + +Measured content is delegated through `LayoutMeasureDelegate`: + +- the engine supplies available width/height and current page geometry +- the delegate returns intrinsic size and optional render data + +This keeps the package renderer-agnostic while still allowing text, tables, and custom content to participate in layout. + +## Current engine behavior + +The current `PaginatedLayoutEngine` is intentionally an MVP. + +### Preparation phase + +`prepareLayoutDocument(...)`: + +1. validates the document +2. resolves page geometry +3. normalizes document shape into a prepared form used by layout + +### Presentation path + +For `kind: 'presentation'`: + +1. each slide becomes exactly one page +2. page geometry is copied from the resolved page spec +3. optional background/foreground artifacts are injected per page + +This path is simple because page boundaries are explicit in the authored model. + +### Flow path + +For `kind: 'flow'`: + +1. the engine measures optional header and footer nodes +2. it reserves those heights from the page content box to form the page `bodyBox` +3. it traverses the body tree vertically +4. it emits fragments into pages +5. it inserts repeated background/header/footer/foreground artifacts on each page + +## Current core algorithms + +### 1. Text height estimation + +The engine currently estimates text height using: + +- `fontSize` +- `lineHeight` +- a simple character-per-line heuristic based on available width + +This is intentionally lightweight. It is good enough for the current MVP and can later be replaced or augmented by a richer text measurement integration. + +### 2. Vertical stack layout + +`stack` is the main flow primitive today: + +1. apply stack padding +2. lay out children top-to-bottom +3. insert `gap` between children +4. accumulate child heights to advance the vertical cursor + +This is the main algorithm for markdown-like flow content right now. + +### 3. Box sizing + +`box` computes height as: + +1. child height +2. plus top/bottom padding +3. then clamped by optional `minHeight` / `maxHeight` +4. or overridden by explicit `height` + +In the current engine, `box` behaves like a contained vertical block in the flow. + +### 4. Page breaking + +Current page breaking is deliberately simple: + +1. compute the next node’s total estimated height +2. compare it with remaining space in the current page `bodyBox` +3. if it does not fit, advance to the next page +4. if a node exceeds the full body height of a page, emit a diagnostic +5. explicit `pageBreak` nodes always force a new page +6. `breakBefore: 'page'` and `breakAfter: 'page'` are respected + +This gives deterministic pagination without yet implementing advanced fragmentation. + +### 5. Repeated page artifacts + +Flow documents may define: + +- `background` +- `header` +- `footer` +- `foreground` + +The engine injects these per page in two phases: + +1. start-of-page artifacts (`background`, `header`) +2. end-of-page artifacts (`footer`, `foreground`) + +That preserves a sensible layering order relative to body fragments. + +## Diagnostics + +The engine emits `PaginationDiagnostic` entries for layout conditions that need attention, such as: + +- missing measurement delegates for measured/table nodes +- nodes taller than a page body region +- other pagination anomalies + +Diagnostics are part of the public result so adapters and applications can surface or inspect them. + +## Current limitations + +This is still an MVP engine. It does **not** yet provide: + +- rich inline/text fragmentation +- widow/orphan control +- keep-with-next / avoid-break-inside enforcement beyond type-level support +- table-specific pagination behavior +- automatic Vitrine rendering adapters in this package +- PDF output +- semantic markdown parsing or markdown-to-layout conversion + +## Example + +```ts +import { + createPaginatedLayoutEngine, + type LayoutDocument +} from 'vitrine-layout-pagination'; + +const document: LayoutDocument<'title' | 'body'> = { + kind: 'flow', + id: 'doc-1', + page: { + width: 612, + height: 792, + unit: 'pt' + }, + body: { + kind: 'stack', + gap: 16, + children: [ + { + kind: 'text', + id: 'title', + text: 'Hello' + }, + { + kind: 'text', + id: 'body', + text: 'Paginated content' + } + ] + } +}; + +const engine = createPaginatedLayoutEngine(); +const result = engine.layout(document); +``` + +## Intended next steps + +Likely next areas of work: + +1. richer flow fragmentation +2. stronger measurement integration for text/texta/tables +3. preview adapters for Vitrine block trees +4. semantic markdown/document builders +5. PDF export integration based on the paginated result diff --git a/packages/layout-pagination/src/common.ts b/packages/layout-pagination/src/common.ts new file mode 100644 index 0000000..75dd9bd --- /dev/null +++ b/packages/layout-pagination/src/common.ts @@ -0,0 +1,217 @@ +export interface LayoutInsets { + top: number; + right: number; + bottom: number; + left: number; +} + +export interface LayoutSize { + width: number; + height: number; +} + +export interface LayoutRect extends LayoutSize { + x: number; + y: number; +} + +export interface LayoutTextStyle { + font?: string; + fontSize?: number; + lineHeight?: number; + fill?: string; +} + +export type LayoutContent = object; + +export type BreakBeforePolicy = 'auto' | 'page'; +export type BreakAfterPolicy = 'auto' | 'page'; +export type BreakInsidePolicy = 'auto' | 'avoid'; +export type PageArtifactKind = 'header' | 'footer' | 'background' | 'foreground'; + +export enum LayoutNodeType { + Stack = 'stack', + Box = 'box', + Spacer = 'spacer', + PageBreak = 'pageBreak', + Text = 'text', + Table = 'table', + Measured = 'measured', + Fixed = 'fixed' +} + +export interface BaseLayoutNodeProps { + id?: TNodeId; + breakBefore?: BreakBeforePolicy; + breakAfter?: BreakAfterPolicy; + breakInside?: BreakInsidePolicy; + keepWithNext?: boolean; +} + +export interface StackLayoutNodeProps< + TNodeId extends string = string, + TTableContent = LayoutContent, + TMeasuredContent = LayoutContent +> extends BaseLayoutNodeProps { + gap?: number; + padding?: Partial; + children: LayoutNode[]; +} + +export interface BoxLayoutNodeProps< + TNodeId extends string = string, + TTableContent = LayoutContent, + TMeasuredContent = LayoutContent +> extends BaseLayoutNodeProps { + padding?: Partial; + minWidth?: number; + maxWidth?: number; + minHeight?: number; + maxHeight?: number; + width?: number; + height?: number; + child?: LayoutNode; +} + +export interface SpacerLayoutNodeProps extends BaseLayoutNodeProps { + size: number; +} + +export interface PageBreakLayoutNodeProps extends BaseLayoutNodeProps { + reason?: string; +} + +export interface TextLayoutNodeProps< + TNodeId extends string = string, + TTextStyle extends LayoutTextStyle = LayoutTextStyle +> extends BaseLayoutNodeProps { + text: string; + textStyle?: TTextStyle; +} + +export interface TableLayoutNodeProps< + TNodeId extends string = string, + TTableContent = LayoutContent +> extends BaseLayoutNodeProps { + tableId: string; + content: TTableContent; +} + +export interface MeasuredLayoutNodeProps< + TNodeId extends string = string, + TMeasuredContent = LayoutContent +> extends BaseLayoutNodeProps { + measureKey: string; + content: TMeasuredContent; +} + +export interface FixedLayoutNodeProps< + TNodeId extends string = string, + TTableContent = LayoutContent, + TMeasuredContent = LayoutContent +> extends BaseLayoutNodeProps { + artifactKind: PageArtifactKind; + child: LayoutNode; +} + +export type LayoutNodePropsByType< + TNodeId extends string = string, + TTableContent = LayoutContent, + TMeasuredContent = LayoutContent, + TTextStyle extends LayoutTextStyle = LayoutTextStyle +> = { + [LayoutNodeType.Stack]: StackLayoutNodeProps; + [LayoutNodeType.Box]: BoxLayoutNodeProps; + [LayoutNodeType.Spacer]: SpacerLayoutNodeProps; + [LayoutNodeType.PageBreak]: PageBreakLayoutNodeProps; + [LayoutNodeType.Text]: TextLayoutNodeProps; + [LayoutNodeType.Table]: TableLayoutNodeProps; + [LayoutNodeType.Measured]: MeasuredLayoutNodeProps; + [LayoutNodeType.Fixed]: FixedLayoutNodeProps; +}; + +export type LayoutNodeForType< + T extends LayoutNodeType, + TNodeId extends string = string, + TTableContent = LayoutContent, + TMeasuredContent = LayoutContent, + TTextStyle extends LayoutTextStyle = LayoutTextStyle +> = { + kind: T; +} & LayoutNodePropsByType[T]; + +export type LayoutNode< + TNodeId extends string = string, + TTableContent = LayoutContent, + TMeasuredContent = LayoutContent, + TTextStyle extends LayoutTextStyle = LayoutTextStyle +> = + { + [T in LayoutNodeType]: LayoutNodeForType; + }[LayoutNodeType]; + +export type LayoutNodeOfType< + T extends LayoutNodeType, + TNodeId extends string = string, + TTableContent = LayoutContent, + TMeasuredContent = LayoutContent, + TTextStyle extends LayoutTextStyle = LayoutTextStyle +> = Extract, { kind: T }>; + +export type StackLayoutNode< + TNodeId extends string = string, + TTableContent = LayoutContent, + TMeasuredContent = LayoutContent +> = LayoutNodeForType; + +export type BoxLayoutNode< + TNodeId extends string = string, + TTableContent = LayoutContent, + TMeasuredContent = LayoutContent +> = LayoutNodeForType; + +export type SpacerLayoutNode = + LayoutNodeForType; + +export type PageBreakLayoutNode = + LayoutNodeForType; + +export type TextLayoutNode< + TNodeId extends string = string, + TTextStyle extends LayoutTextStyle = LayoutTextStyle +> = LayoutNodeForType; + +export type TableLayoutNode< + TNodeId extends string = string, + TTableContent = LayoutContent +> = LayoutNodeForType; + +export type MeasuredLayoutNode< + TNodeId extends string = string, + TMeasuredContent = LayoutContent +> = LayoutNodeForType; + +export type FixedLayoutNode< + TNodeId extends string = string, + TTableContent = LayoutContent, + TMeasuredContent = LayoutContent +> = LayoutNodeForType; + +const ZERO_INSETS: LayoutInsets = { + top: 0, + right: 0, + bottom: 0, + left: 0 +}; + +export function normalizeLayoutInsets( + insets?: Partial, + defaults: LayoutInsets = ZERO_INSETS +): LayoutInsets { + return { + top: insets?.top ?? defaults.top, + right: insets?.right ?? defaults.right, + bottom: insets?.bottom ?? defaults.bottom, + left: insets?.left ?? defaults.left + }; +} diff --git a/packages/layout-pagination/src/engine.ts b/packages/layout-pagination/src/engine.ts index b10aaae..3c4398b 100644 --- a/packages/layout-pagination/src/engine.ts +++ b/packages/layout-pagination/src/engine.ts @@ -1,63 +1,72 @@ +import type { + LayoutTextStyle, + LayoutNode, + LayoutRect, + PageArtifactKind +} from './common.ts'; +import { normalizeLayoutInsets } from './common.ts'; import type { FlowLayoutDocument, LayoutDocument, + PresentationLayoutDocument, +} from './flow.ts'; +import type { LayoutFragment, - LayoutNode, LayoutPaginationOptions, - LayoutRect, PaginatedLayoutResult, PaginationDiagnostic, - PageArtifactKind, - PresentationLayoutDocument, ResolvedPageSpec -} from './model.ts'; -import { normalizeLayoutInsets, resolvePageSpec } from './model.ts'; +} from './pagination.ts'; +import { resolvePageSpec } from './pagination.ts'; import type { LayoutMeasureDelegate, LayoutMeasuredContent } from './measure.ts'; import { createNullMeasureDelegate } from './measure.ts'; -export interface PreparedFlowLayoutDocument { +export interface PreparedFlowLayoutDocument { kind: 'flow'; documentId: string; page: ResolvedPageSpec; - body: LayoutNode; - header?: LayoutNode; - footer?: LayoutNode; - background?: LayoutNode; - foreground?: LayoutNode; + body: LayoutNode; + header?: LayoutNode; + footer?: LayoutNode; + background?: LayoutNode; + foreground?: LayoutNode; } -export interface PreparedPresentationLayoutDocument { +export interface PreparedPresentationLayoutDocument { kind: 'presentation'; documentId: string; page: ResolvedPageSpec; - slides: LayoutNode[]; + slides: LayoutNode[]; previewScale: 'contain' | 'cover' | 'stretch'; - background?: LayoutNode; - foreground?: LayoutNode; + background?: LayoutNode; + foreground?: LayoutNode; } -export type PreparedLayoutDocument = - | PreparedFlowLayoutDocument - | PreparedPresentationLayoutDocument; +export type PreparedLayoutDocument = + | PreparedFlowLayoutDocument + | PreparedPresentationLayoutDocument; -interface FlowLayoutRuntime { +interface FlowLayoutRuntime { page: ResolvedPageSpec; - delegate: LayoutMeasureDelegate; - diagnostics: PaginationDiagnostic[]; - pages: PaginatedLayoutResult['pages']; + delegate: LayoutMeasureDelegate; + diagnostics: PaginationDiagnostic[]; + pages: PaginatedLayoutResult['pages']; bodyBox: LayoutRect; maxPages: number; currentPageIndex: number; cursorY: number; } -function assertRootNodeAllowed(node: LayoutNode, role: 'body' | 'header' | 'footer' | 'background' | 'foreground'): void { +function assertRootNodeAllowed( + node: LayoutNode, + role: 'body' | 'header' | 'footer' | 'background' | 'foreground' +): void { if (node.kind === 'fixed' && role !== 'body') { throw new Error(`Nested fixed nodes are not allowed in ${role}.`); } } -function validateFlowDocument(document: FlowLayoutDocument): void { +function validateFlowDocument(document: FlowLayoutDocument): void { assertRootNodeAllowed(document.body, 'body'); if (document.header) { assertRootNodeAllowed(document.header, 'header'); @@ -73,7 +82,7 @@ function validateFlowDocument(document: FlowLayoutDocument): void { } } -function validatePresentationDocument(document: PresentationLayoutDocument): void { +function validatePresentationDocument(document: PresentationLayoutDocument): void { if (document.slides.length === 0) { throw new Error('Presentation documents must define at least one slide.'); } @@ -89,7 +98,7 @@ function validatePresentationDocument(document: PresentationLayoutDocument): voi } } -export function validateLayoutDocument(document: LayoutDocument): void { +export function validateLayoutDocument(document: LayoutDocument): void { if (document.id.trim().length === 0) { throw new Error('Layout document id must be non-empty.'); } @@ -102,7 +111,9 @@ export function validateLayoutDocument(document: LayoutDocument): void { validateFlowDocument(document); } -export function prepareLayoutDocument(document: LayoutDocument): PreparedLayoutDocument { +export function prepareLayoutDocument( + document: LayoutDocument +): PreparedLayoutDocument { validateLayoutDocument(document); const page = resolvePageSpec(document.page); @@ -152,7 +163,7 @@ function addDiagnostic( diagnostics.push(diagnostic); } -function estimateTextHeight(text: string, availableWidth: number, textStyle?: Record): number { +function estimateTextHeight(text: string, availableWidth: number, textStyle?: LayoutTextStyle): number { const fontSize = typeof textStyle?.fontSize === 'number' ? textStyle.fontSize : 16; const lineHeight = typeof textStyle?.lineHeight === 'number' ? textStyle.lineHeight : fontSize * 1.4; const charsPerLine = Math.max(1, Math.floor(Math.max(availableWidth, fontSize) / Math.max(1, fontSize * 0.6))); @@ -240,12 +251,12 @@ function estimateNodeHeight( } } -function createArtifactFragment( - node: LayoutNode, +function createArtifactFragment( + node: LayoutNode, pageIndex: number, rect: LayoutRect, artifactKind: PageArtifactKind -): LayoutFragment { +): LayoutFragment { return { nodeId: node.id, nodeKind: node.kind, @@ -256,7 +267,7 @@ function createArtifactFragment( }; } -function ensurePage(runtime: FlowLayoutRuntime): void { +function ensurePage(runtime: FlowLayoutRuntime): void { if (runtime.pages[runtime.currentPageIndex]) { return; } @@ -271,7 +282,9 @@ function ensurePage(runtime: FlowLayoutRuntime): void { }); } -function currentPage(runtime: FlowLayoutRuntime): PaginatedLayoutResult['pages'][number] { +function currentPage( + runtime: FlowLayoutRuntime +): PaginatedLayoutResult['pages'][number] { ensurePage(runtime); return runtime.pages[runtime.currentPageIndex]!; } @@ -309,9 +322,9 @@ function pushPageArtifacts( } } -function startNewFlowPage( - runtime: FlowLayoutRuntime, - prepared: PreparedFlowLayoutDocument, +function startNewFlowPage( + runtime: FlowLayoutRuntime, + prepared: PreparedFlowLayoutDocument, headerHeight: number, footerHeight: number ): void { @@ -323,9 +336,9 @@ function startNewFlowPage( runtime.cursorY = runtime.bodyBox.y; } -function advancePage( - runtime: FlowLayoutRuntime, - prepared: PreparedFlowLayoutDocument, +function advancePage( + runtime: FlowLayoutRuntime, + prepared: PreparedFlowLayoutDocument, headerHeight: number, footerHeight: number ): void { @@ -338,9 +351,9 @@ function advancePage( runtime.cursorY = runtime.bodyBox.y; } -function emitLeafFragment( - runtime: FlowLayoutRuntime, - node: LayoutNode, +function emitLeafFragment( + runtime: FlowLayoutRuntime, + node: LayoutNode, width: number, height: number, renderData?: unknown @@ -361,10 +374,10 @@ function emitLeafFragment( runtime.cursorY += height; } -function layoutNodeIntoPages( - node: LayoutNode, - runtime: FlowLayoutRuntime, - prepared: PreparedFlowLayoutDocument, +function layoutNodeIntoPages( + node: LayoutNode, + runtime: FlowLayoutRuntime, + prepared: PreparedFlowLayoutDocument, availableWidth: number, headerHeight: number, footerHeight: number @@ -431,12 +444,12 @@ function layoutNodeIntoPages( } } -function layoutFlowDocument( - prepared: PreparedFlowLayoutDocument, - delegate: LayoutMeasureDelegate, +function layoutFlowDocument( + prepared: PreparedFlowLayoutDocument, + delegate: LayoutMeasureDelegate, options?: LayoutPaginationOptions -): PaginatedLayoutResult { - const diagnostics: PaginationDiagnostic[] = []; +): PaginatedLayoutResult { + const diagnostics: PaginationDiagnostic[] = []; const headerHeight = prepared.header ? estimateNodeHeight(prepared.header, prepared.page.contentBox.width, prepared.page, delegate, diagnostics) : 0; @@ -449,7 +462,7 @@ function layoutFlowDocument( width: prepared.page.contentBox.width, height: Math.max(0, prepared.page.contentBox.height - headerHeight - footerHeight) }; - const runtime: FlowLayoutRuntime = { + const runtime: FlowLayoutRuntime = { page: prepared.page, delegate, diagnostics, @@ -474,7 +487,9 @@ function layoutFlowDocument( }; } -function layoutPresentationDocument(prepared: PreparedPresentationLayoutDocument): PaginatedLayoutResult { +function layoutPresentationDocument( + prepared: PreparedPresentationLayoutDocument +): PaginatedLayoutResult { return { documentId: prepared.documentId, page: prepared.page, @@ -498,22 +513,22 @@ function layoutPresentationDocument(prepared: PreparedPresentationLayoutDocument }; } -export class PaginatedLayoutEngine { - private delegate: LayoutMeasureDelegate; +export class PaginatedLayoutEngine { + private delegate: LayoutMeasureDelegate; - constructor(delegate: LayoutMeasureDelegate = createNullMeasureDelegate()) { + constructor(delegate: LayoutMeasureDelegate = createNullMeasureDelegate()) { this.delegate = delegate; } - getMeasureDelegate(): LayoutMeasureDelegate { + getMeasureDelegate(): LayoutMeasureDelegate { return this.delegate; } - prepare(document: LayoutDocument): PreparedLayoutDocument { + prepare(document: LayoutDocument): PreparedLayoutDocument { return prepareLayoutDocument(document); } - layout(document: LayoutDocument, options?: LayoutPaginationOptions): PaginatedLayoutResult { + layout(document: LayoutDocument, options?: LayoutPaginationOptions): PaginatedLayoutResult { const prepared = this.prepare(document); if (prepared.kind === 'presentation') { return layoutPresentationDocument(prepared); @@ -522,8 +537,8 @@ export class PaginatedLayoutEngine { } } -export function createPaginatedLayoutEngine( - delegate: LayoutMeasureDelegate = createNullMeasureDelegate() -): PaginatedLayoutEngine { +export function createPaginatedLayoutEngine( + delegate: LayoutMeasureDelegate = createNullMeasureDelegate() +): PaginatedLayoutEngine { return new PaginatedLayoutEngine(delegate); } diff --git a/packages/layout-pagination/src/flow.ts b/packages/layout-pagination/src/flow.ts new file mode 100644 index 0000000..b611bc1 --- /dev/null +++ b/packages/layout-pagination/src/flow.ts @@ -0,0 +1,42 @@ +import type { LayoutContent, LayoutNode } from './common.ts'; +import type { PageSpec } from './pagination.ts'; + +export type LayoutDocumentKind = 'flow' | 'presentation'; +export type PresentationPreviewScale = 'contain' | 'cover' | 'stretch'; + +export interface FlowLayoutDocument< + TNodeId extends string = string, + TTableContent = LayoutContent, + TMeasuredContent = LayoutContent +> { + kind: 'flow'; + id: string; + page: PageSpec; + body: LayoutNode; + header?: LayoutNode; + footer?: LayoutNode; + background?: LayoutNode; + foreground?: LayoutNode; +} + +export interface PresentationLayoutDocument< + TNodeId extends string = string, + TTableContent = LayoutContent, + TMeasuredContent = LayoutContent +> { + kind: 'presentation'; + id: string; + page: PageSpec; + slides: LayoutNode[]; + previewScale?: PresentationPreviewScale; + background?: LayoutNode; + foreground?: LayoutNode; +} + +export type LayoutDocument< + TNodeId extends string = string, + TTableContent = LayoutContent, + TMeasuredContent = LayoutContent +> = + | FlowLayoutDocument + | PresentationLayoutDocument; diff --git a/packages/layout-pagination/src/index.ts b/packages/layout-pagination/src/index.ts index e17dce0..52f3b3f 100644 --- a/packages/layout-pagination/src/index.ts +++ b/packages/layout-pagination/src/index.ts @@ -1,41 +1,61 @@ export type { - BaseLayoutNode, + BaseLayoutNodeProps, BoxLayoutNode, + BoxLayoutNodeProps, BreakAfterPolicy, BreakBeforePolicy, BreakInsidePolicy, FixedLayoutNode, - FlowLayoutDocument, - FragmentContinuation, - LayoutDocument, - LayoutDocumentKind, - LayoutFragment, + FixedLayoutNodeProps, + LayoutContent, LayoutInsets, LayoutNode, - LayoutPaginationOptions, + LayoutNodeForType, + LayoutNodeOfType, + LayoutNodePropsByType, + LayoutNodeType, + LayoutTextStyle, LayoutRect, LayoutSize, MeasuredLayoutNode, + MeasuredLayoutNodeProps, PageArtifactKind, PageBreakLayoutNode, + PageBreakLayoutNodeProps, + SpacerLayoutNode, + SpacerLayoutNodeProps, + StackLayoutNode, + StackLayoutNodeProps, + TableLayoutNode, + TableLayoutNodeProps, + TextLayoutNode, + TextLayoutNodeProps +} from './common.ts'; +export { + normalizeLayoutInsets +} from './common.ts'; +export type { + FlowLayoutDocument, + LayoutDocument, + LayoutDocumentKind, + PresentationLayoutDocument, + PresentationPreviewScale +} from './flow.ts'; +export type { + FragmentContinuation, + LayoutFragment, + LayoutPaginationOptions, PageSpec, PageUnit, PaginatedLayoutResult, PaginatedPage, PaginationDiagnostic, PaginationDiagnosticSeverity, - PresentationLayoutDocument, - PresentationPreviewScale, - ResolvedPageSpec, - SpacerLayoutNode, - StackLayoutNode, - TableLayoutNode, - TextLayoutNode -} from './model.ts'; + ResolvedPageSpec +} from './pagination.ts'; export { - normalizeLayoutInsets, resolvePageSpec -} from './model.ts'; +} from './pagination.ts'; export type { LayoutAvailableSpace, LayoutIntrinsicSize, diff --git a/packages/layout-pagination/src/measure.ts b/packages/layout-pagination/src/measure.ts index d0b19e5..b75e891 100644 --- a/packages/layout-pagination/src/measure.ts +++ b/packages/layout-pagination/src/measure.ts @@ -1,4 +1,5 @@ -import type { LayoutNode, LayoutRect, LayoutSize, ResolvedPageSpec } from './model.ts'; +import type { LayoutNode, LayoutRect, LayoutSize } from './common.ts'; +import type { ResolvedPageSpec } from './pagination.ts'; export interface LayoutAvailableSpace extends LayoutSize {} @@ -8,8 +9,8 @@ export interface LayoutIntrinsicSize { minHeight: number; } -export interface LayoutMeasureRequest { - node: LayoutNode; +export interface LayoutMeasureRequest { + node: LayoutNode; availableSpace: LayoutAvailableSpace; page: ResolvedPageSpec; } @@ -20,11 +21,11 @@ export interface LayoutMeasuredContent { renderData?: unknown; } -export interface LayoutMeasureDelegate { - measure(request: LayoutMeasureRequest): LayoutMeasuredContent | null; +export interface LayoutMeasureDelegate { + measure(request: LayoutMeasureRequest): LayoutMeasuredContent | null; } -export function createNullMeasureDelegate(): LayoutMeasureDelegate { +export function createNullMeasureDelegate(): LayoutMeasureDelegate { return { measure(): LayoutMeasuredContent | null { return null; diff --git a/packages/layout-pagination/src/model.ts b/packages/layout-pagination/src/model.ts deleted file mode 100644 index 082adbb..0000000 --- a/packages/layout-pagination/src/model.ts +++ /dev/null @@ -1,226 +0,0 @@ -export type PageUnit = 'pt' | 'px' | 'mm' | 'in'; - -export interface LayoutInsets { - top: number; - right: number; - bottom: number; - left: number; -} - -export interface LayoutSize { - width: number; - height: number; -} - -export interface LayoutRect extends LayoutSize { - x: number; - y: number; -} - -export interface PageSpec extends LayoutSize { - id?: string; - unit?: PageUnit; - margins?: Partial; -} - -export interface ResolvedPageSpec extends LayoutSize { - id?: string; - unit: PageUnit; - margins: LayoutInsets; - contentBox: LayoutRect; -} - -export type LayoutDocumentKind = 'flow' | 'presentation'; -export type PresentationPreviewScale = 'contain' | 'cover' | 'stretch'; - -export type BreakBeforePolicy = 'auto' | 'page'; -export type BreakAfterPolicy = 'auto' | 'page'; -export type BreakInsidePolicy = 'auto' | 'avoid'; -export type PageArtifactKind = 'header' | 'footer' | 'background' | 'foreground'; - -export interface BaseLayoutNode { - id?: string; - breakBefore?: BreakBeforePolicy; - breakAfter?: BreakAfterPolicy; - breakInside?: BreakInsidePolicy; - keepWithNext?: boolean; -} - -export interface StackLayoutNode extends BaseLayoutNode { - kind: 'stack'; - gap?: number; - padding?: Partial; - children: LayoutNode[]; -} - -export interface BoxLayoutNode extends BaseLayoutNode { - kind: 'box'; - padding?: Partial; - minWidth?: number; - maxWidth?: number; - minHeight?: number; - maxHeight?: number; - width?: number; - height?: number; - child?: LayoutNode; -} - -export interface SpacerLayoutNode extends BaseLayoutNode { - kind: 'spacer'; - size: number; -} - -export interface PageBreakLayoutNode extends BaseLayoutNode { - kind: 'pageBreak'; - reason?: string; -} - -export interface TextLayoutNode extends BaseLayoutNode { - kind: 'text'; - text: string; - textStyle?: Record; -} - -export interface TableLayoutNode extends BaseLayoutNode { - kind: 'table'; - tableId: string; - content: unknown; -} - -export interface MeasuredLayoutNode extends BaseLayoutNode { - kind: 'measured'; - measureKey: string; - content: unknown; -} - -export interface FixedLayoutNode extends BaseLayoutNode { - kind: 'fixed'; - artifactKind: PageArtifactKind; - child: LayoutNode; -} - -export type LayoutNode = - | StackLayoutNode - | BoxLayoutNode - | SpacerLayoutNode - | PageBreakLayoutNode - | TextLayoutNode - | TableLayoutNode - | MeasuredLayoutNode - | FixedLayoutNode; - -export interface FlowLayoutDocument { - kind: 'flow'; - id: string; - page: PageSpec; - body: LayoutNode; - header?: LayoutNode; - footer?: LayoutNode; - background?: LayoutNode; - foreground?: LayoutNode; -} - -export interface PresentationLayoutDocument { - kind: 'presentation'; - id: string; - page: PageSpec; - slides: LayoutNode[]; - previewScale?: PresentationPreviewScale; - background?: LayoutNode; - foreground?: LayoutNode; -} - -export type LayoutDocument = FlowLayoutDocument | PresentationLayoutDocument; - -export type FragmentContinuation = 'none' | 'start' | 'middle' | 'end'; - -export interface LayoutFragment { - nodeId?: string; - pageIndex: number; - rect: LayoutRect; - continuation: FragmentContinuation; - artifactKind?: PageArtifactKind; - nodeKind?: LayoutNode['kind']; - renderData?: unknown; -} - -export interface PaginatedPage { - index: number; - page: ResolvedPageSpec; - bodyBox: LayoutRect; - fragments: LayoutFragment[]; -} - -export type PaginationDiagnosticSeverity = 'info' | 'warning' | 'error'; - -export interface PaginationDiagnostic { - code: string; - severity: PaginationDiagnosticSeverity; - message: string; - nodeId?: string; - pageIndex?: number; -} - -export interface PaginatedLayoutResult { - documentId: string; - page: ResolvedPageSpec; - pages: PaginatedPage[]; - diagnostics: PaginationDiagnostic[]; -} - -export interface LayoutPaginationOptions { - maxPages?: number; -} - -const DEFAULT_PAGE_MARGINS: LayoutInsets = { - top: 48, - right: 48, - bottom: 48, - left: 48 -}; - -const ZERO_INSETS: LayoutInsets = { - top: 0, - right: 0, - bottom: 0, - left: 0 -}; - -export function normalizeLayoutInsets( - insets?: Partial, - defaults: LayoutInsets = ZERO_INSETS -): LayoutInsets { - return { - top: insets?.top ?? defaults.top, - right: insets?.right ?? defaults.right, - bottom: insets?.bottom ?? defaults.bottom, - left: insets?.left ?? defaults.left - }; -} - -export function resolvePageSpec(page: PageSpec): ResolvedPageSpec { - if (!(page.width > 0) || !(page.height > 0)) { - throw new Error('Page width and height must be greater than zero.'); - } - - const margins = normalizeLayoutInsets(page.margins, DEFAULT_PAGE_MARGINS); - const contentWidth = page.width - margins.left - margins.right; - const contentHeight = page.height - margins.top - margins.bottom; - if (contentWidth < 0 || contentHeight < 0) { - throw new Error('Page margins exceed the page size.'); - } - - return { - id: page.id, - width: page.width, - height: page.height, - unit: page.unit ?? 'pt', - margins, - contentBox: { - x: margins.left, - y: margins.top, - width: contentWidth, - height: contentHeight - } - }; -} diff --git a/packages/layout-pagination/src/pagination.ts b/packages/layout-pagination/src/pagination.ts new file mode 100644 index 0000000..bad7e5b --- /dev/null +++ b/packages/layout-pagination/src/pagination.ts @@ -0,0 +1,95 @@ +import type { LayoutNode, LayoutRect, LayoutInsets } from './common.ts'; +import { normalizeLayoutInsets } from './common.ts'; + +export type PageUnit = 'pt' | 'px' | 'mm' | 'in'; + +export interface PageSpec { + id?: string; + width: number; + height: number; + unit?: PageUnit; + margins?: Partial; +} + +export interface ResolvedPageSpec { + id?: string; + width: number; + height: number; + unit: PageUnit; + margins: LayoutInsets; + contentBox: LayoutRect; +} + +export type FragmentContinuation = 'none' | 'start' | 'middle' | 'end'; + +export interface LayoutFragment { + nodeId?: TNodeId; + pageIndex: number; + rect: LayoutRect; + continuation: FragmentContinuation; + artifactKind?: import('./common.ts').PageArtifactKind; + nodeKind?: LayoutNode['kind']; + renderData?: unknown; +} + +export interface PaginatedPage { + index: number; + page: ResolvedPageSpec; + bodyBox: LayoutRect; + fragments: LayoutFragment[]; +} + +export type PaginationDiagnosticSeverity = 'info' | 'warning' | 'error'; + +export interface PaginationDiagnostic { + code: string; + severity: PaginationDiagnosticSeverity; + message: string; + nodeId?: TNodeId; + pageIndex?: number; +} + +export interface PaginatedLayoutResult { + documentId: string; + page: ResolvedPageSpec; + pages: PaginatedPage[]; + diagnostics: PaginationDiagnostic[]; +} + +export interface LayoutPaginationOptions { + maxPages?: number; +} + +const DEFAULT_PAGE_MARGINS: LayoutInsets = { + top: 48, + right: 48, + bottom: 48, + left: 48 +}; + +export function resolvePageSpec(page: PageSpec): ResolvedPageSpec { + if (!(page.width > 0) || !(page.height > 0)) { + throw new Error('Page width and height must be greater than zero.'); + } + + const margins = normalizeLayoutInsets(page.margins, DEFAULT_PAGE_MARGINS); + const contentWidth = page.width - margins.left - margins.right; + const contentHeight = page.height - margins.top - margins.bottom; + if (contentWidth < 0 || contentHeight < 0) { + throw new Error('Page margins exceed the page size.'); + } + + return { + id: page.id, + width: page.width, + height: page.height, + unit: page.unit ?? 'pt', + margins, + contentBox: { + x: margins.left, + y: margins.top, + width: contentWidth, + height: contentHeight + } + }; +} diff --git a/packages/layout-pagination/test/page-spec.test.ts b/packages/layout-pagination/test/page-spec.test.ts index e078234..457e5bc 100644 --- a/packages/layout-pagination/test/page-spec.test.ts +++ b/packages/layout-pagination/test/page-spec.test.ts @@ -278,4 +278,37 @@ describe('layout pagination foundations', () => { expect(fragment?.rect.height).toBe(96); expect(fragment?.renderData).toEqual({ measured: true }); }); + + it('supports string-literal unions for node ids', () => { + type NodeId = 'intro' | 'body' | 'summary'; + const document: LayoutDocument = { + kind: 'flow', + id: 'typed-doc', + page: { + width: 300, + height: 220, + unit: 'px' + }, + body: { + kind: 'stack', + id: 'body', + children: [ + { + kind: 'text', + id: 'intro', + text: 'Intro copy' + }, + { + kind: 'text', + id: 'summary', + text: 'Summary copy' + } + ] + } + }; + + const result = createPaginatedLayoutEngine().layout(document); + const nodeIds = result.pages.flatMap((page) => page.fragments.map((fragment) => fragment.nodeId)).filter(Boolean); + expect(nodeIds).toEqual(['intro', 'summary']); + }); }); From 515fdf6e2d88b2f78baaae8da94ca0785d31e3e3 Mon Sep 17 00:00:00 2001 From: frouaix Date: Wed, 22 Jul 2026 18:17:59 -0700 Subject: [PATCH 3/4] Add paginated layout Vitrine adapter Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/demo/layout-pagination-flow-setup.js | 5 +- .../layout-pagination-presentation-setup.js | 5 +- packages/demo/layout-pagination-preview.ts | 485 ------------ packages/demo/layout-pagination-scenarios.ts | 559 +++++++++++--- packages/demo/package.json | 3 +- packages/demo/vite.config.ts | 1 + .../layout-pagination-vitrine/package.json | 31 + .../layout-pagination-vitrine/src/index.ts | 730 ++++++++++++++++++ .../layout-pagination-vitrine/tsconfig.json | 11 + packages/layout-pagination/README.md | 17 +- pnpm-lock.yaml | 19 + 11 files changed, 1267 insertions(+), 599 deletions(-) delete mode 100644 packages/demo/layout-pagination-preview.ts create mode 100644 packages/layout-pagination-vitrine/package.json create mode 100644 packages/layout-pagination-vitrine/src/index.ts create mode 100644 packages/layout-pagination-vitrine/tsconfig.json diff --git a/packages/demo/layout-pagination-flow-setup.js b/packages/demo/layout-pagination-flow-setup.js index 477f5c6..cee6ee1 100644 --- a/packages/demo/layout-pagination-flow-setup.js +++ b/packages/demo/layout-pagination-flow-setup.js @@ -1,7 +1,10 @@ import { VitrineComponent } from 'vitrine-gui'; -import { buildFlowDocumentPreview } from './layout-pagination-preview.ts'; +import { registerTextaBlockType } from 'texta/browser'; +import { buildFlowDocumentPreview } from 'vitrine-layout-pagination-adapter'; import { documentFlow, flowResult } from './layout-pagination-scenarios.ts'; +registerTextaBlockType(); + const component = VitrineComponent.block(() => ( buildFlowDocumentPreview(documentFlow, flowResult, { x: 70, diff --git a/packages/demo/layout-pagination-presentation-setup.js b/packages/demo/layout-pagination-presentation-setup.js index 4b4a6f0..387852c 100644 --- a/packages/demo/layout-pagination-presentation-setup.js +++ b/packages/demo/layout-pagination-presentation-setup.js @@ -1,7 +1,10 @@ import { VitrineComponent } from 'vitrine-gui'; -import { buildPresentationPreview } from './layout-pagination-preview.ts'; +import { registerTextaBlockType } from 'texta/browser'; +import { buildPresentationPreview } from 'vitrine-layout-pagination-adapter'; import { documentPresentation, presentationResult } from './layout-pagination-scenarios.ts'; +registerTextaBlockType(); + let currentSlideIndex = 0; let time = 0; diff --git a/packages/demo/layout-pagination-preview.ts b/packages/demo/layout-pagination-preview.ts deleted file mode 100644 index e659fe7..0000000 --- a/packages/demo/layout-pagination-preview.ts +++ /dev/null @@ -1,485 +0,0 @@ -import { group, rectangle, text } from 'vitrine'; -import type { Block } from 'vitrine'; -import { - normalizeLayoutInsets, - type BoxLayoutNode, - type FixedLayoutNode, - type LayoutDocument, - type LayoutFragment, - type LayoutInsets, - type LayoutNode, - type LayoutRect, - type PaginatedLayoutResult, - type PaginatedPage, - type ResolvedPageSpec, - type StackLayoutNode, - type TextLayoutNode -} from 'vitrine-layout-pagination'; - -interface RenderTheme { - canvasFill: string; - pageFill: string; - pageStroke: string; - shadowFill: string; - textFill: string; - mutedTextFill: string; - accentFill: string; - artifactFills: Record<'background' | 'header' | 'footer' | 'foreground', string>; -} - -interface FlowPreviewOptions { - x?: number; - y?: number; - scale?: number; - pageGap?: number; - columns?: number; - showPageLabels?: boolean; -} - -interface PresentationPreviewOptions { - x?: number; - y?: number; - mainScale?: number; - thumbnailScale?: number; - thumbnailGap?: number; - currentPageIndex?: number; -} - -function scaleLength(value: number, scale: number): number { - return value * scale; -} - -function scaleInsets(insets: LayoutInsets, scale: number): LayoutInsets { - return { - top: insets.top * scale, - right: insets.right * scale, - bottom: insets.bottom * scale, - left: insets.left * scale - }; -} - -function scaleFontString(font: string | undefined, scale: number): string | undefined { - if (!font) { - return undefined; - } - return font.replace(/(\d+(?:\.\d+)?)px/g, (_match, size) => `${Math.max(1, Number(size) * scale)}px`); -} - -const themeDefault: RenderTheme = { - canvasFill: '#0f172a', - pageFill: '#ffffff', - pageStroke: '#cbd5e1', - shadowFill: 'rgba(15, 23, 42, 0.14)', - textFill: '#0f172a', - mutedTextFill: '#475569', - accentFill: '#2563eb', - artifactFills: { - background: 'rgba(59, 130, 246, 0.06)', - header: 'rgba(59, 130, 246, 0.08)', - footer: 'rgba(148, 163, 184, 0.10)', - foreground: 'rgba(14, 165, 233, 0.05)' - } -}; - -function scaleRect(rect: LayoutRect, pageX: number, pageY: number, scale: number): LayoutRect { - return { - x: pageX + rect.x * scale, - y: pageY + rect.y * scale, - width: rect.width * scale, - height: rect.height * scale - }; -} - -function estimateTextHeight(node: TextLayoutNode, width: number): number { - const fontSize = typeof node.textStyle?.fontSize === 'number' ? node.textStyle.fontSize : 16; - const lineHeight = typeof node.textStyle?.lineHeight === 'number' ? node.textStyle.lineHeight : fontSize * 1.4; - const cCharsPerLine = Math.max(1, Math.floor(Math.max(width, fontSize) / Math.max(1, fontSize * 0.58))); - return node.text - .split('\n') - .reduce((count, line) => count + Math.max(1, Math.ceil(line.length / cCharsPerLine)), 0) * lineHeight; -} - -function estimateNodeHeight(node: LayoutNode, width: number): number { - switch (node.kind) { - case 'text': - return estimateTextHeight(node, width); - case 'spacer': - return node.size; - case 'box': { - const insets = normalizeLayoutInsets(node.padding); - const childHeight = node.child ? estimateNodeHeight(node.child, Math.max(0, width - insets.left - insets.right)) : 0; - const naturalHeight = childHeight + insets.top + insets.bottom; - let nextHeight = node.height ?? naturalHeight; - if (node.minHeight !== undefined) { - nextHeight = Math.max(nextHeight, node.minHeight); - } - if (node.maxHeight !== undefined) { - nextHeight = Math.min(nextHeight, node.maxHeight); - } - return nextHeight; - } - case 'stack': { - const insets = normalizeLayoutInsets(node.padding); - const innerWidth = Math.max(0, width - insets.left - insets.right); - let height = insets.top + insets.bottom; - node.children.forEach((child, index) => { - height += estimateNodeHeight(child, innerWidth); - if (index < node.children.length - 1) { - height += node.gap ?? 0; - } - }); - return height; - } - case 'measured': - case 'table': - return 96; - case 'pageBreak': - return 0; - case 'fixed': - return estimateNodeHeight(node.child, width); - } -} - -function findNodeById(node: LayoutNode | undefined, id: string): LayoutNode | null { - if (!node) { - return null; - } - if (node.id === id) { - return node; - } - switch (node.kind) { - case 'stack': - for (const child of node.children) { - const found = findNodeById(child, id); - if (found) { - return found; - } - } - return null; - case 'box': - return node.child ? findNodeById(node.child, id) : null; - case 'fixed': - return findNodeById(node.child, id); - default: - return null; - } -} - -function lookupNode(document: LayoutDocument, fragment: LayoutFragment, pageIndex: number): LayoutNode | null { - if (fragment.artifactKind) { - switch (fragment.artifactKind) { - case 'background': - return document.background ?? null; - case 'foreground': - return document.foreground ?? null; - case 'header': - return document.kind === 'flow' ? document.header ?? null : null; - case 'footer': - return document.kind === 'flow' ? document.footer ?? null : null; - } - } - - if (document.kind === 'presentation') { - return document.slides[pageIndex] ?? null; - } - - if (!fragment.nodeId) { - return null; - } - return findNodeById(document.body, fragment.nodeId) - ?? findNodeById(document.header, fragment.nodeId) - ?? findNodeById(document.footer, fragment.nodeId) - ?? findNodeById(document.background, fragment.nodeId) - ?? findNodeById(document.foreground, fragment.nodeId); -} - -function buildTextBlock(node: TextLayoutNode, rect: LayoutRect, theme: RenderTheme, scale: number): Block { - const fontSize = (typeof node.textStyle?.fontSize === 'number' ? node.textStyle.fontSize : 16) * scale; - const dyLineHeight = (typeof node.textStyle?.lineHeight === 'number' - ? node.textStyle.lineHeight - : (typeof node.textStyle?.fontSize === 'number' ? node.textStyle.fontSize : 16) * 1.4) * scale; - const fill = typeof node.textStyle?.fill === 'string' ? node.textStyle.fill : theme.textFill; - const font = typeof node.textStyle?.font === 'string' - ? scaleFontString(node.textStyle.font, scale) - : undefined; - return text({ - x: rect.x, - y: rect.y, - text: node.text, - dx: rect.width, - fontSize, - dyLineHeight, - font, - fill, - baseline: 'top' - }); -} - -function buildBoxBlocks(node: BoxLayoutNode, rect: LayoutRect, theme: RenderTheme, scale: number): Block[] { - const insets = scaleInsets(normalizeLayoutInsets(node.padding), scale); - const fill = rect.height <= 64 ? '#dbeafe' : '#eff6ff'; - const blocks: Block[] = [ - rectangle({ - x: rect.x, - y: rect.y, - dx: rect.width, - dy: rect.height, - fill, - stroke: '#93c5fd', - strokeWidth: 1, - cornerRadius: 12 * scale - }) - ]; - - if (node.child) { - blocks.push(...renderNodeIntoRect(node.child, { - x: rect.x + insets.left, - y: rect.y + insets.top, - width: Math.max(0, rect.width - insets.left - insets.right), - height: Math.max(0, rect.height - insets.top - insets.bottom) - }, theme, scale)); - } - return blocks; -} - -function buildMeasuredPlaceholder(node: LayoutNode, rect: LayoutRect, label: string, theme: RenderTheme, scale: number): Block[] { - return [ - rectangle({ - x: rect.x, - y: rect.y, - dx: rect.width, - dy: rect.height, - fill: '#f8fafc', - stroke: '#cbd5e1', - strokeWidth: 1, - cornerRadius: 10 * scale - }), - text({ - x: rect.x + 16 * scale, - y: rect.y + 16 * scale, - text: label, - fontSize: 14 * scale, - fill: theme.mutedTextFill, - baseline: 'top' - }) - ]; -} - -function renderStackIntoRect(node: StackLayoutNode, rect: LayoutRect, theme: RenderTheme, scale: number): Block[] { - const insets = scaleInsets(normalizeLayoutInsets(node.padding), scale); - const innerRect: LayoutRect = { - x: rect.x + insets.left, - y: rect.y + insets.top, - width: Math.max(0, rect.width - insets.left - insets.right), - height: Math.max(0, rect.height - insets.top - insets.bottom) - }; - let yCursor = innerRect.y; - const blocks: Block[] = []; - - node.children.forEach((child, index) => { - const childHeight = estimateNodeHeight(child, innerRect.width / Math.max(scale, 0.0001)) * scale; - const childRect: LayoutRect = { - x: innerRect.x, - y: yCursor, - width: innerRect.width, - height: childHeight - }; - blocks.push(...renderNodeIntoRect(child, childRect, theme, scale)); - yCursor += childHeight; - if (index < node.children.length - 1) { - yCursor += (node.gap ?? 0) * scale; - } - }); - - return blocks; -} - -function renderNodeIntoRect(node: LayoutNode, rect: LayoutRect, theme: RenderTheme, scale: number): Block[] { - switch (node.kind) { - case 'text': - return [buildTextBlock(node, rect, theme, scale)]; - case 'spacer': - return []; - case 'stack': - return renderStackIntoRect(node, rect, theme, scale); - case 'box': - return buildBoxBlocks(node, rect, theme, scale); - case 'measured': - return buildMeasuredPlaceholder(node, rect, `Measured: ${node.measureKey}`, theme, scale); - case 'table': - return buildMeasuredPlaceholder(node, rect, `Table: ${node.tableId}`, theme, scale); - case 'fixed': - return renderNodeIntoRect((node as FixedLayoutNode).child, rect, theme, scale); - case 'pageBreak': - return []; - } -} - -function renderPageFragments( - document: LayoutDocument, - page: PaginatedPage, - pageX: number, - pageY: number, - scale: number, - theme: RenderTheme -): Block[] { - const blocks: Block[] = [ - rectangle({ - x: pageX + 8, - y: pageY + 10, - dx: page.page.width * scale, - dy: page.page.height * scale, - fill: theme.shadowFill, - cornerRadius: 18 - }), - rectangle({ - x: pageX, - y: pageY, - dx: page.page.width * scale, - dy: page.page.height * scale, - fill: theme.pageFill, - stroke: theme.pageStroke, - strokeWidth: 1, - cornerRadius: 18 - }) - ]; - - for (const fragment of page.fragments) { - const rectScaled = scaleRect(fragment.rect, pageX, pageY, scale); - if (fragment.artifactKind) { - blocks.push(rectangle({ - x: rectScaled.x, - y: rectScaled.y, - dx: rectScaled.width, - dy: rectScaled.height, - fill: theme.artifactFills[fragment.artifactKind] - })); - } - const node = lookupNode(document, fragment, page.index); - if (!node) { - continue; - } - blocks.push(...renderNodeIntoRect(node, rectScaled, theme, scale)); - } - - return blocks; -} - -export function buildFlowDocumentPreview( - document: LayoutDocument, - result: PaginatedLayoutResult, - options: FlowPreviewOptions = {} -): Block { - const xStart = options.x ?? 40; - const yStart = options.y ?? 60; - const scale = options.scale ?? 0.34; - const pageGap = options.pageGap ?? 28; - const cColumns = Math.max(1, options.columns ?? 2); - const pageWidthScaled = result.page.width * scale; - const pageHeightScaled = result.page.height * scale; - const pageStrideX = pageWidthScaled + pageGap; - const pageStrideY = pageHeightScaled + pageGap; - const children: Block[] = [ - rectangle({ - dx: 1280, - dy: 900, - fill: themeDefault.canvasFill - }), - text({ - x: 44, - y: 26, - text: `Flow document preview · ${result.pages.length} pages`, - fontSize: 22, - fill: '#e2e8f0', - baseline: 'top' - }), - text({ - x: 44, - y: 58, - text: 'Semantic pagination with repeated page furniture and page breaks.', - fontSize: 13, - fill: '#94a3b8', - baseline: 'top' - }) - ]; - - result.pages.forEach((page, index) => { - const col = index % cColumns; - const row = Math.floor(index / cColumns); - const pageX = xStart + col * pageStrideX; - const pageY = yStart + row * pageStrideY; - children.push(...renderPageFragments(document, page, pageX, pageY, scale, themeDefault)); - if (options.showPageLabels !== false) { - children.push(text({ - x: pageX + 14, - y: pageY + 14, - text: `Page ${index + 1}`, - fontSize: 11, - fill: themeDefault.mutedTextFill, - baseline: 'top' - })); - } - }); - - return group({}, children); -} - -export function buildPresentationPreview( - document: LayoutDocument, - result: PaginatedLayoutResult, - options: PresentationPreviewOptions = {} -): Block { - const iCurrent = Math.max(0, Math.min(options.currentPageIndex ?? 0, result.pages.length - 1)); - const mainScale = options.mainScale ?? 0.5; - const thumbScale = options.thumbnailScale ?? 0.14; - const thumbGap = options.thumbnailGap ?? 18; - const xStart = options.x ?? 40; - const yStart = options.y ?? 64; - const pageMain = result.pages[iCurrent]!; - const dxMain = result.page.width * mainScale; - const dyMain = result.page.height * mainScale; - const children: Block[] = [ - rectangle({ - dx: 1280, - dy: 860, - fill: '#020617' - }), - text({ - x: 44, - y: 26, - text: `Presentation preview · slide ${iCurrent + 1} of ${result.pages.length}`, - fontSize: 22, - fill: '#e2e8f0', - baseline: 'top' - }), - text({ - x: 44, - y: 58, - text: 'Explicit slide pages with fixed 16:9 geometry for browser presentations.', - fontSize: 13, - fill: '#94a3b8', - baseline: 'top' - }), - ...renderPageFragments(document, pageMain, xStart, yStart, mainScale, themeDefault) - ]; - - result.pages.forEach((page, index) => { - const thumbX = xStart + index * (result.page.width * thumbScale + thumbGap); - const thumbY = yStart + dyMain + 34; - const scale = thumbScale; - children.push(...renderPageFragments(document, page, thumbX, thumbY, scale, { - ...themeDefault, - pageStroke: index === iCurrent ? themeDefault.accentFill : themeDefault.pageStroke - })); - children.push(text({ - x: thumbX + 8, - y: thumbY + 8, - text: String(index + 1), - fontSize: 10, - fill: index === iCurrent ? themeDefault.accentFill : themeDefault.mutedTextFill, - baseline: 'top' - })); - }); - - return group({}, children); -} diff --git a/packages/demo/layout-pagination-scenarios.ts b/packages/demo/layout-pagination-scenarios.ts index 8055705..abb5e4e 100644 --- a/packages/demo/layout-pagination-scenarios.ts +++ b/packages/demo/layout-pagination-scenarios.ts @@ -1,11 +1,31 @@ -import type { LayoutDocument } from 'vitrine-layout-pagination'; +import { group, rectangle, text } from 'vitrine'; import { createPaginatedLayoutEngine } from 'vitrine-layout-pagination'; +import { + createVitrineLayoutMeasureDelegate, + type VitrineLayoutDocument, + type VitrineLayoutLeafContent +} from 'vitrine-layout-pagination-adapter'; +import { generateSampleEvents, rsCalendarDayView, rsCalendarMonthView } from 'vitrine-gui'; +import { + applyStyle, + detectRgStorageMode, + getRgCodePointBoundaryUtf16, + getRgGraphemeBoundaryUtf16, + texta as textaBlock, + type AttributedTextValue +} from 'texta/browser'; +import { parseMarkdownToRender } from 'texta/markdown'; +import { buildDebugTextaScene } from './text-selection-debug-texta-scene.js'; -function paragraph(id: string, text: string): LayoutDocument['body'] { +type DemoDocument = VitrineLayoutDocument; + +const rgCalendarEvent = generateSampleEvents(); + +function paragraph(id: string, value: string): DemoDocument['body'] { return { kind: 'text', id, - text, + text: value, textStyle: { fontSize: 16, lineHeight: 24, @@ -14,7 +34,364 @@ function paragraph(id: string, text: string): LayoutDocument['body'] { }; } -export const documentPresentation: LayoutDocument = { +function createLeafContent( + minHeight: number, + renderBlocks: VitrineLayoutLeafContent['renderBlocks'], + clip: boolean = true +): VitrineLayoutLeafContent { + return { + intrinsicSize: { + minWidth: 0, + maxWidth: Number.POSITIVE_INFINITY, + minHeight + }, + clip, + renderBlocks + }; +} + +function getUnitCount(strText: string, rgStorageMode: AttributedTextValue['rgStorageMode']): number { + if (rgStorageMode === 'fastCodeUnit') { + return strText.length; + } + if (rgStorageMode === 'fastCodePoint') { + return getRgCodePointBoundaryUtf16(strText).length - 1; + } + return getRgGraphemeBoundaryUtf16(strText).length - 1; +} + +function getRgSegGraphemeToUtf16( + strText: string, + rgStorageMode: AttributedTextValue['rgStorageMode'] +): number[] { + if (rgStorageMode !== 'segmentedGrapheme') { + return []; + } + return getRgGraphemeBoundaryUtf16(strText).slice(1); +} + +function createAttributedText( + strText: string, + styleDefault: Record +): AttributedTextValue { + const rgStorageMode = detectRgStorageMode(strText); + const iUnits = getUnitCount(strText, rgStorageMode); + return { + iVersion: 1, + rgUnits: 'grapheme', + rgStorageMode, + strText, + rgSegGraphemeToUtf16: getRgSegGraphemeToUtf16(strText, rgStorageMode), + rgIdStyleRef: new Array(iUnits).fill(1), + mpId_StyleEntry: { 1: styleDefault }, + idStyleDefault: 1 + }; +} + +function applyRanges( + value: AttributedTextValue, + ranges: Array<{ start: number; end: number; style: Record }> +): AttributedTextValue { + let next = value; + for (const range of ranges) { + next = applyStyle(next, range.start, range.end, range.style, 'merge'); + } + return next; +} + +const valueSlideAside = applyRanges( + createAttributedText( + 'Selection geometry, styled runs, and markdown all flow through the same Vitrine block pipeline.', + { fontFamily: 'ui-sans-serif', fontSize: 18, fill: '#0f172a', lineHeight: 28 } + ), + [ + { start: 0, end: 18, style: { fontWeight: '700', fill: '#2563eb' } }, + { start: 20, end: 31, style: { fontStyle: 'italic', fill: '#7c3aed' } }, + { start: 63, end: 84, style: { fontWeight: '700', background: '#dbeafe', fill: '#1d4ed8' } } + ] +); + +const valueDocumentCallout = applyRanges( + createAttributedText( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Styled spans, background highlights, and mixed emphasis should remain native block content even after pagination.', + { fontFamily: 'Georgia', fontSize: 18, fill: '#334155', lineHeight: 30 } + ), + [ + { start: 0, end: 11, style: { fontWeight: '700', fill: '#1d4ed8' } }, + { start: 28, end: 39, style: { fontStyle: 'italic', fill: '#7c3aed' } }, + { start: 64, end: 85, style: { background: '#fef3c7', fill: '#92400e' } }, + { start: 126, end: 145, style: { fontWeight: '700', fill: '#0f766e' } } + ] +); + +const valueDocumentSummary = applyRanges( + createAttributedText( + 'Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam.', + { fontFamily: 'ui-sans-serif', fontSize: 17, fill: '#1f2937', lineHeight: 26 } + ), + [ + { start: 0, end: 22, style: { fontWeight: '700', fill: '#2563eb' } }, + { start: 51, end: 73, style: { fontStyle: 'italic', fill: '#7c3aed' } }, + { start: 89, end: 111, style: { background: '#dcfce7', fill: '#166534' } } + ] +); + +const stSlideMarkdown = `## Demo collage + +- **Calendar Views** brings dense visual structure. +- *Texta* contributes styled runs and rich inline formatting. +- Both now sit inside paginated slides as regular Vitrine blocks.`; + +const stDocumentMarkdownIntro = `# Layout package demo + +This document borrows directly from the **Markdown** and **Texta** demos. + +> The page model decides pagination. The content stays renderer-native. + +Lorem ipsum dolor sit amet, *consectetur adipiscing elit*, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + +- **Bold** emphasis for section keywords +- Inline \`code\` for implementation details +- Mixed rhythm with paragraphs and lists`; + +const stDocumentMarkdownChapter = `## Formatted narrative + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. **Praesent** commodo cursus magna, vel scelerisque nisl consectetur et. + +1. Build semantic content with markdown or texta. +2. Measure it as a paginated layout node. +3. Render the resulting fragments as Vitrine blocks. + +--- + +\`\`\` +render(layoutDocument) + -> paginate() + -> buildVitrineBlocksFromPaginatedLayout() +\`\`\``; + +const stDocumentMarkdownClosing = `### Closing note + +*Curabitur blandit tempus porttitor.* Donec sed odio dui. Integer posuere erat a ante venenatis dapibus posuere velit aliquet. + +> Lorem ipsum pages can still carry polished formatting without leaving the Vitrine block model.`; + +const valueMarkdownIntro = parseMarkdownToRender(stDocumentMarkdownIntro); +const valueMarkdownChapter = parseMarkdownToRender(stDocumentMarkdownChapter); +const valueMarkdownClosing = parseMarkdownToRender(stDocumentMarkdownClosing); +const valueSlideMarkdown = parseMarkdownToRender(stSlideMarkdown); + +function frameCard(x: number, y: number, width: number, height: number, title: string, subtitle?: string): ReturnType { + return group({ x, y }, [ + rectangle({ + dx: width, + dy: height, + fill: '#ffffff', + stroke: '#d7e1ee', + strokeWidth: 1, + cornerRadius: 20, + shadow: { offsetX: 0, offsetY: 10, blur: 24, color: 'rgba(15, 23, 42, 0.08)' } + }), + text({ + x: 20, + y: 18, + text: title, + fontSize: 16, + fill: '#0f172a', + font: '600 16px ui-sans-serif', + baseline: 'top' + }), + ...(subtitle + ? [text({ + x: 20, + y: 42, + text: subtitle, + fontSize: 12, + fill: '#64748b', + baseline: 'top' + })] + : []) + ]); +} + +function monthCalendarBlocks(x: number, y: number, scale: number): ReturnType { + return group({ x, y, scaleX: scale, scaleY: scale }, [ + rsCalendarMonthView({ + cMonth: 1, + dateStart: new Date(2026, 1, 1), + dayStartWeek: 0, + rgEvent: rgCalendarEvent, + x: 0, + y: 0 + }) + ]); +} + +function weekCalendarBlocks(x: number, y: number, scale: number): ReturnType { + return group({ x, y, scaleX: scale, scaleY: scale }, [ + rsCalendarDayView({ + cDay: 5, + dateStart: new Date(2026, 1, 16), + hourStart: 8, + hourEnd: 18, + rgEvent: rgCalendarEvent, + x: 0, + y: 0 + }) + ]); +} + +function buildMarkdownPanel( + valueMarkdown: AttributedTextValue, + width: number, + height: number +): ReturnType { + return group({}, [ + rectangle({ + dx: width, + dy: height, + fill: '#ffffff', + stroke: '#d7e1ee', + strokeWidth: 1, + cornerRadius: 18 + }), + group({ x: 22, y: 22, clip: true, dx: width - 44, dy: height - 44 }, [ + textaBlock({ + x: 0, + y: 0, + dx: width - 44, + texta: valueMarkdown, + baseline: 'top' + }) + ]) + ]); +} + +function buildTextaPanel( + title: string, + valueTexta: AttributedTextValue, + width: number, + height: number +): ReturnType { + return group({}, [ + rectangle({ + dx: width, + dy: height, + fill: '#ffffff', + stroke: '#d7e1ee', + strokeWidth: 1, + cornerRadius: 18 + }), + text({ + x: 18, + y: 16, + text: title, + fontSize: 13, + fill: '#64748b', + font: '600 13px ui-sans-serif', + baseline: 'top' + }), + group({ x: 18, y: 42, clip: true, dx: width - 36, dy: height - 60 }, [ + textaBlock({ + x: 0, + y: 0, + dx: width - 36, + texta: valueTexta, + baseline: 'top' + }) + ]) + ]); +} + +function slideShowcaseContent(): VitrineLayoutLeafContent { + return createLeafContent(408, ({ frame }) => { + const dxLeft = frame.width * 0.58; + const dxRight = frame.width - dxLeft - 24; + const dyInner = frame.height - 70; + const sMonth = Math.min((dxLeft - 40) / 1160, (dyInner - 34) / 560); + const sTexta = Math.min((dxRight - 24) / 1000, (dyInner - 34) / 920); + + return [ + frameCard(0, 0, dxLeft, frame.height, 'Calendar Views demo', 'Month view scaled into slide geometry'), + frameCard(dxLeft + 24, 0, dxRight, frame.height, 'Texta selection demo', 'The same debug scene embedded as slide content'), + group({ x: 20, y: 64, clip: true, dx: dxLeft - 40, dy: dyInner - 14 }, [ + monthCalendarBlocks(0, 0, sMonth) + ]), + group({ x: dxLeft + 36, y: 64, clip: true, dx: dxRight - 24, dy: dyInner - 14 }, [ + group({ scaleX: sTexta, scaleY: sTexta }, [buildDebugTextaScene()]) + ]) + ]; + }); +} + +function slidePlannerContent(): VitrineLayoutLeafContent { + return createLeafContent(430, ({ frame }) => { + const dxVisual = frame.width * 0.68; + const dxAside = frame.width - dxVisual - 24; + const sWeek = Math.min((dxVisual - 40) / 1360, (frame.height - 98) / 800); + + return [ + frameCard(0, 0, dxVisual, frame.height, 'Calendar demo reuse', 'Week view adapted to fit a slide panel'), + frameCard(dxVisual + 24, 0, dxAside, frame.height, 'Compact markdown/texta aside', 'Small amount of copy, heavy visual bias'), + group({ x: 20, y: 68, clip: true, dx: dxVisual - 40, dy: frame.height - 88 }, [ + weekCalendarBlocks(0, 0, sWeek) + ]), + group({ x: dxVisual + 40, y: 64 }, [ + buildMarkdownPanel(valueSlideMarkdown, dxAside - 32, 150) + ]), + group({ x: dxVisual + 40, y: 232 }, [ + buildTextaPanel('Texta aside', valueSlideAside, dxAside - 32, 150) + ]) + ]; + }); +} + +function slideTypographyContent(): VitrineLayoutLeafContent { + return createLeafContent(392, ({ frame }) => { + const dxLeft = frame.width * 0.52; + const dxRight = frame.width - dxLeft - 24; + const sTexta = Math.min((dxLeft - 24) / 1000, (frame.height - 78) / 920); + const sMonth = Math.min((dxRight - 40) / 1160, (frame.height - 250) / 560); + + return [ + frameCard(0, 0, dxLeft, frame.height, 'Texta debug collage', 'The existing scene becomes a slide-scale visual texture'), + frameCard(dxLeft + 24, 0, dxRight, 170, 'Markdown panel', 'Formatted text still renders as native blocks'), + frameCard(dxLeft + 24, 190, dxRight, frame.height - 190, 'Mini calendar', 'A second embedded visual borrowed from the calendar demo'), + group({ x: 12, y: 60, clip: true, dx: dxLeft - 24, dy: frame.height - 72 }, [ + group({ scaleX: sTexta, scaleY: sTexta }, [buildDebugTextaScene()]) + ]), + group({ x: dxLeft + 42, y: 58 }, [ + buildMarkdownPanel(valueSlideMarkdown, dxRight - 36, 100) + ]), + group({ x: dxLeft + 44, y: 254, clip: true, dx: dxRight - 40, dy: frame.height - 278 }, [ + monthCalendarBlocks(0, 0, sMonth) + ]) + ]; + }); +} + +function markdownSectionContent( + valueMarkdown: AttributedTextValue, + minHeight: number +): VitrineLayoutLeafContent { + return createLeafContent(minHeight, ({ frame }) => [ + buildMarkdownPanel(valueMarkdown, frame.width, frame.height) + ]); +} + +function textaSectionContent( + title: string, + valueTexta: AttributedTextValue, + minHeight: number +): VitrineLayoutLeafContent { + return createLeafContent(minHeight, ({ frame }) => [ + buildTextaPanel(title, valueTexta, frame.width, frame.height) + ]); +} + +const layoutMeasureDelegate = createVitrineLayoutMeasureDelegate(); + +export const documentPresentation: DemoDocument = { kind: 'presentation', id: 'browser-deck', page: { @@ -32,122 +409,84 @@ export const documentPresentation: LayoutDocument = { { kind: 'stack', id: 'slide-title', - gap: 28, + gap: 26, children: [ { kind: 'text', id: 'slide-title-heading', - text: 'Q3 Product Narrative', + text: 'Paginated slides with real demo content', textStyle: { fontSize: 42, fill: '#0f172a', lineHeight: 52 } }, { kind: 'text', id: 'slide-title-subheading', - text: 'A browser-authored slide deck with fixed 16:9 page geometry and reusable page semantics.', - textStyle: { fontSize: 22, fill: '#334155', lineHeight: 32 } + text: 'Calendar Views and the Texta selection scene now live inside the slide deck as Vitrine blocks, not placeholders.', + textStyle: { fontSize: 21, fill: '#334155', lineHeight: 31 } }, - { kind: 'spacer', size: 32 }, { - kind: 'box', - id: 'slide-title-hero', - height: 220, - padding: { top: 24, right: 24, bottom: 24, left: 24 }, - child: { - kind: 'text', - id: 'slide-title-hero-text', - text: 'Every slide is an explicit page, not a flow fragment. That means browser preview, PDF export, and page-level transitions can all share the same authored geometry.', - textStyle: { fontSize: 24, fill: '#1e293b', lineHeight: 34 } - } + kind: 'measured', + id: 'slide-title-showcase', + measureKey: 'slide-title-showcase', + content: slideShowcaseContent() } ] }, { kind: 'stack', id: 'slide-agenda', - gap: 20, + gap: 22, children: [ { kind: 'text', id: 'slide-agenda-heading', - text: 'Why explicit presentation pages?', + text: 'Mostly visual slides, with small rich-text asides', textStyle: { fontSize: 36, fill: '#0f172a', lineHeight: 44 } }, { - kind: 'box', - id: 'slide-agenda-card-1', - height: 132, - padding: { top: 18, right: 20, bottom: 18, left: 20 }, - child: { - kind: 'text', - id: 'slide-agenda-card-1-text', - text: '1. Page size and aspect ratio stay stable across browsers and projectors.', - textStyle: { fontSize: 20, fill: '#1e293b', lineHeight: 30 } - } - }, - { - kind: 'box', - id: 'slide-agenda-card-2', - height: 132, - padding: { top: 18, right: 20, bottom: 18, left: 20 }, - child: { - kind: 'text', - id: 'slide-agenda-card-2-text', - text: '2. Slide-level semantics make PDF export and page thumbnails straightforward.', - textStyle: { fontSize: 20, fill: '#1e293b', lineHeight: 30 } - } + kind: 'text', + id: 'slide-agenda-subheading', + text: 'The page model stays explicit while measured regions host calendar components, markdown-rendered texta, and selection-debug content.', + textStyle: { fontSize: 20, fill: '#475569', lineHeight: 30 } }, { - kind: 'box', - id: 'slide-agenda-card-3', - height: 132, - padding: { top: 18, right: 20, bottom: 18, left: 20 }, - child: { - kind: 'text', - id: 'slide-agenda-card-3-text', - text: '3. A later adapter can turn the same page model into browser previews, canvases, or exported files.', - textStyle: { fontSize: 20, fill: '#1e293b', lineHeight: 30 } - } + kind: 'measured', + id: 'slide-agenda-showcase', + measureKey: 'slide-agenda-showcase', + content: slidePlannerContent() } ] }, { kind: 'stack', id: 'slide-closing', - gap: 26, + gap: 22, children: [ { kind: 'text', id: 'slide-closing-heading', - text: 'Next step', + text: 'The slide deck is now a content host', textStyle: { fontSize: 38, fill: '#0f172a', lineHeight: 48 } }, { kind: 'text', id: 'slide-closing-body', - text: 'Keep the document model renderer-agnostic, then add richer slide composition and a Vitrine preview adapter without confusing slide pages with paginated flow content.', - textStyle: { fontSize: 24, fill: '#334155', lineHeight: 34 } + text: 'That means presentation pages can mix existing demo scenes, formatted markdown, and attributed text without any separate rendering path.', + textStyle: { fontSize: 23, fill: '#334155', lineHeight: 34 } }, - { kind: 'spacer', size: 30 }, { - kind: 'box', - id: 'slide-closing-banner', - height: 150, - padding: { top: 28, right: 28, bottom: 28, left: 28 }, - child: { - kind: 'text', - id: 'slide-closing-banner-text', - text: 'Presentation pages: explicit, stable, and ready for browser preview.', - textStyle: { fontSize: 28, fill: '#1d4ed8', lineHeight: 38 } - } + kind: 'measured', + id: 'slide-closing-showcase', + measureKey: 'slide-closing-showcase', + content: slideTypographyContent() } ] } ] }; -export const presentationResult = createPaginatedLayoutEngine().layout(documentPresentation); +export const presentationResult = createPaginatedLayoutEngine(layoutMeasureDelegate).layout(documentPresentation); -export const documentFlow: LayoutDocument = { +export const documentFlow: DemoDocument = { kind: 'flow', id: 'markdown-doc', page: { @@ -169,7 +508,7 @@ export const documentFlow: LayoutDocument = { header: { kind: 'text', id: 'doc-header', - text: 'Vitrine Layout & Pagination — Draft Notes', + text: 'Markdown + Texta inside paginated Vitrine pages', textStyle: { fontSize: 14, lineHeight: 18, @@ -179,7 +518,7 @@ export const documentFlow: LayoutDocument = { footer: { kind: 'text', id: 'doc-footer', - text: 'Semantic flow document · repeated footer region', + text: 'Renderer-native content · semantic pagination · reusable page model', textStyle: { fontSize: 12, lineHeight: 16, @@ -189,12 +528,12 @@ export const documentFlow: LayoutDocument = { body: { kind: 'stack', id: 'doc-body', - gap: 16, + gap: 18, children: [ { kind: 'text', id: 'doc-title', - text: 'Paginated Markdown-like Documentation', + text: 'Paginated pages with markdown and texta content', textStyle: { fontSize: 28, lineHeight: 36, @@ -203,43 +542,41 @@ export const documentFlow: LayoutDocument = { }, paragraph( 'doc-intro', - 'This scenario represents markdown or extended-markdown content rendered through text and texta primitives. The authored content should paginate semantically, with repeated headers and footers, instead of arbitrary page slicing.' - ), - paragraph( - 'doc-section-1', - 'The flow engine should own page geometry, content boxes, and break rules. Rich text, tables, images, and custom blocks can participate through measurement contracts so the pagination layer stays renderer-agnostic.' + 'This flow demo now pulls content patterns from the markdown and texta demos: formatted headings, lists, code, and rich attributed text all appear as measured Vitrine content inside page fragments.' ), + { + kind: 'measured', + id: 'doc-markdown-intro', + measureKey: 'doc-markdown-intro', + content: markdownSectionContent(valueMarkdownIntro, 310) + }, paragraph( - 'doc-section-2', - 'That same paginated structure should later feed browser previews and PDF export. The goal is for document flow to decide where page boundaries belong, while renderers decide how the resulting pages are drawn.' + 'doc-bridge', + 'The paginator still operates over semantic layout nodes, but those nodes now host renderer-native payloads instead of placeholder rectangles.' ), { - kind: 'box', - id: 'doc-callout', - height: 150, - padding: { top: 18, right: 20, bottom: 18, left: 20 }, - child: { - kind: 'text', - id: 'doc-callout-text', - text: 'Callout: a semantic document paginator should prefer stable section and paragraph breaks over viewport slicing, especially for exported PDFs.', - textStyle: { - fontSize: 18, - lineHeight: 28, - fill: '#1e293b' - } - } + kind: 'measured', + id: 'doc-texta-callout', + measureKey: 'doc-texta-callout', + content: textaSectionContent('Texta excerpt', valueDocumentCallout, 208) }, paragraph( - 'doc-section-3', - 'Tables and other measured blocks should integrate through delegates so pagination can see their intrinsic sizes and make better break decisions. This is especially important for documentation with callouts, examples, and embedded diagrams.' + 'doc-middle', + 'Lorem ipsum is enough to show the mechanics: spacing, emphasis, and decorative backgrounds survive because the content adapter hands the page preview ordinary Vitrine blocks.' ), + { + kind: 'measured', + id: 'doc-markdown-chapter', + measureKey: 'doc-markdown-chapter', + content: markdownSectionContent(valueMarkdownChapter, 300) + }, { kind: 'pageBreak', id: 'doc-break-1' }, { kind: 'text', - id: 'doc-section-4-title', + id: 'doc-section-2-title', text: 'Second chapter', textStyle: { fontSize: 22, @@ -248,19 +585,27 @@ export const documentFlow: LayoutDocument = { } }, paragraph( - 'doc-section-4', - 'An explicit page break is still useful in documentation workflows. It lets authors control chapter starts or front matter while still relying on automatic pagination within each chapter.' - ), - paragraph( - 'doc-section-5', - 'The preview shown here is intentionally document-like: multiple pages at once, repeated furniture, and consistent body boxes. It is not a presentation deck or a freeform canvas scene.' + 'doc-second-intro', + 'The second page leans more heavily on texta-specific styling: run-level emphasis, color accents, and highlighted spans drawn through the same custom block type used elsewhere in the repo.' ), + { + kind: 'measured', + id: 'doc-texta-summary', + measureKey: 'doc-texta-summary', + content: textaSectionContent('Styled summary', valueDocumentSummary, 190) + }, + { + kind: 'measured', + id: 'doc-markdown-closing', + measureKey: 'doc-markdown-closing', + content: markdownSectionContent(valueMarkdownClosing, 220) + }, paragraph( - 'doc-section-6', - 'Later phases can add richer fragmentation for complex boxes, better typography-aware text handling, and adapters that convert these paginated results into Vitrine block trees or PDF pages with higher fidelity.' + 'doc-outro', + 'The result is the core promise of the package: slides and documents can be composed from real Vitrine blocks while pagination remains a separate, reusable concern.' ) ] } }; -export const flowResult = createPaginatedLayoutEngine().layout(documentFlow); +export const flowResult = createPaginatedLayoutEngine(layoutMeasureDelegate).layout(documentFlow); diff --git a/packages/demo/package.json b/packages/demo/package.json index cbb1125..598faec 100644 --- a/packages/demo/package.json +++ b/packages/demo/package.json @@ -14,7 +14,8 @@ "texta": "workspace:*", "vitrine": "workspace:*", "vitrine-gui": "workspace:*", - "vitrine-layout-pagination": "workspace:*" + "vitrine-layout-pagination": "workspace:*", + "vitrine-layout-pagination-adapter": "workspace:*" }, "devDependencies": { "@types/node": "^22.10.5", diff --git a/packages/demo/vite.config.ts b/packages/demo/vite.config.ts index a994b59..c88b618 100644 --- a/packages/demo/vite.config.ts +++ b/packages/demo/vite.config.ts @@ -40,6 +40,7 @@ export default defineConfig({ 'vitrine-tables': resolve(__dirname, '../tables/src/index.ts'), 'vitrine-tables-adapter': resolve(__dirname, '../tables-vitrine/src/index.ts'), 'vitrine-layout-pagination': resolve(__dirname, '../layout-pagination/src/index.ts'), + 'vitrine-layout-pagination-adapter': resolve(__dirname, '../layout-pagination-vitrine/src/index.ts'), 'vitrine': resolve(__dirname, '../core/src/index.ts'), 'vitrine-gui': resolve(__dirname, '../gui/src/index.ts') } diff --git a/packages/layout-pagination-vitrine/package.json b/packages/layout-pagination-vitrine/package.json new file mode 100644 index 0000000..69e890c --- /dev/null +++ b/packages/layout-pagination-vitrine/package.json @@ -0,0 +1,31 @@ +{ + "name": "vitrine-layout-pagination-adapter", + "version": "0.1.0", + "description": "Vitrine adapter for vitrine-layout-pagination documents and paginated results", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "dependencies": { + "vitrine": "workspace:*", + "vitrine-layout-pagination": "workspace:*" + }, + "scripts": { + "build": "tsc", + "lint": "tsc --noEmit", + "clean": "rm -rf dist" + }, + "license": "MIT", + "devDependencies": { + "typescript": "^5.7.3" + } +} diff --git a/packages/layout-pagination-vitrine/src/index.ts b/packages/layout-pagination-vitrine/src/index.ts new file mode 100644 index 0000000..b0e464c --- /dev/null +++ b/packages/layout-pagination-vitrine/src/index.ts @@ -0,0 +1,730 @@ +import { group, rectangle, text } from 'vitrine'; +import type { Block, GroupProps } from 'vitrine'; +import { + normalizeLayoutInsets, + type BoxLayoutNode, + type LayoutAvailableSpace, + type LayoutDocument, + type LayoutFragment, + type LayoutIntrinsicSize, + type LayoutMeasureDelegate, + type LayoutMeasureRequest, + type LayoutMeasuredContent, + type LayoutNode, + type LayoutRect, + type MeasuredLayoutNode, + type PageArtifactKind, + type PaginatedLayoutResult, + type PaginatedPage, + type ResolvedPageSpec, + type StackLayoutNode, + type TableLayoutNode, + type TextLayoutNode +} from 'vitrine-layout-pagination'; + +export interface VitrineLayoutContentRenderContext { + nodeId?: TNodeId; + nodeKind: LayoutNode['kind']; + pageIndex: number; + frame: LayoutRect; + pageRect: LayoutRect; + scale: number; + renderData?: unknown; +} + +export interface VitrineLayoutLeafContent { + blocks?: Block[]; + renderBlocks?: (context: VitrineLayoutContentRenderContext) => Block[]; + intrinsicSize?: LayoutIntrinsicSize; + clip?: boolean; +} + +export type VitrineLayoutDocument = LayoutDocument< + TNodeId, + VitrineLayoutLeafContent, + VitrineLayoutLeafContent +>; + +type VitrineLayoutNode = LayoutNode< + TNodeId, + VitrineLayoutLeafContent, + VitrineLayoutLeafContent +>; + +type VitrineLeafLayoutNode = + | MeasuredLayoutNode> + | TableLayoutNode>; + +export interface VitrineLayoutMeasureRequest { + node: VitrineLeafLayoutNode; + content: VitrineLayoutLeafContent; + availableSpace: LayoutAvailableSpace; + page: ResolvedPageSpec; +} + +export interface CreateVitrineLayoutMeasureDelegateOptions { + measureMeasuredContent?: (request: VitrineLayoutMeasureRequest) => LayoutMeasuredContent | null; + measureTableContent?: (request: VitrineLayoutMeasureRequest) => LayoutMeasuredContent | null; + fallbackIntrinsicSize?: LayoutIntrinsicSize; +} + +export interface BuildVitrineBlocksFromPaginatedPageOptions { + x?: number; + y?: number; + scale?: number; + rootGroupProps?: GroupProps; + showPageShadow?: boolean; + showPageFrame?: boolean; + pageFill?: string; + pageStroke?: string; + pageStrokeWidth?: number; + pageCornerRadius?: number; + pageShadowFill?: string; + pageShadowOffsetX?: number; + pageShadowOffsetY?: number; + pageShadowCornerRadius?: number; + artifactFills?: Partial>; + defaultTextFill?: string; +} + +export interface BuildVitrineBlocksFromPaginatedLayoutOptions + extends Omit { + x?: number; + y?: number; + columns?: number; + pageGap?: number; + rootGroupProps?: GroupProps; +} + +export interface VitrinePreviewTheme { + canvasFill: string; + pageFill: string; + pageStroke: string; + shadowFill: string; + textFill: string; + mutedTextFill: string; + accentFill: string; + artifactFills: Partial>; +} + +export interface FlowPreviewOptions { + x?: number; + y?: number; + scale?: number; + pageGap?: number; + columns?: number; + showPageLabels?: boolean; + theme?: Partial; +} + +export interface PresentationPreviewOptions { + x?: number; + y?: number; + mainScale?: number; + thumbnailScale?: number; + thumbnailGap?: number; + currentPageIndex?: number; + theme?: Partial; +} + +export const themeDefault: VitrinePreviewTheme = { + canvasFill: '#0f172a', + pageFill: '#ffffff', + pageStroke: '#cbd5e1', + shadowFill: 'rgba(15, 23, 42, 0.14)', + textFill: '#0f172a', + mutedTextFill: '#475569', + accentFill: '#2563eb', + artifactFills: { + background: 'rgba(59, 130, 246, 0.06)', + header: 'rgba(59, 130, 246, 0.08)', + footer: 'rgba(148, 163, 184, 0.10)', + foreground: 'rgba(14, 165, 233, 0.05)' + } +}; + +function mergePreviewTheme(theme?: Partial): VitrinePreviewTheme { + return { + ...themeDefault, + ...theme, + artifactFills: { + ...themeDefault.artifactFills, + ...theme?.artifactFills + } + }; +} + +function estimateTextHeight(node: TextLayoutNode, availableWidth: number): number { + const fontSize = typeof node.textStyle?.fontSize === 'number' ? node.textStyle.fontSize : 16; + const lineHeight = typeof node.textStyle?.lineHeight === 'number' ? node.textStyle.lineHeight : fontSize * 1.4; + const cCharsPerLine = Math.max(1, Math.floor(Math.max(availableWidth, fontSize) / Math.max(1, fontSize * 0.58))); + return node.text + .split('\n') + .reduce((count: number, line: string) => count + Math.max(1, Math.ceil(line.length / cCharsPerLine)), 0) * lineHeight; +} + +function resolveLeafContent( + node: VitrineLayoutNode +): VitrineLayoutLeafContent | null { + switch (node.kind) { + case 'measured': + case 'table': + return node.content; + default: + return null; + } +} + +function estimateNodeHeight( + node: VitrineLayoutNode, + availableWidth: number +): number { + switch (node.kind) { + case 'text': + return estimateTextHeight(node, availableWidth); + case 'spacer': + return node.size; + case 'box': { + const insets = normalizeLayoutInsets(node.padding); + const childHeight = node.child + ? estimateNodeHeight(node.child, Math.max(0, availableWidth - insets.left - insets.right)) + : 0; + const naturalHeight = childHeight + insets.top + insets.bottom; + let nextHeight = node.height ?? naturalHeight; + if (node.minHeight !== undefined) { + nextHeight = Math.max(nextHeight, node.minHeight); + } + if (node.maxHeight !== undefined) { + nextHeight = Math.min(nextHeight, node.maxHeight); + } + return nextHeight; + } + case 'stack': { + const insets = normalizeLayoutInsets(node.padding); + const innerWidth = Math.max(0, availableWidth - insets.left - insets.right); + let height = insets.top + insets.bottom; + node.children.forEach((child: VitrineLayoutNode, index: number) => { + height += estimateNodeHeight(child, innerWidth); + if (index < node.children.length - 1) { + height += node.gap ?? 0; + } + }); + return height; + } + case 'measured': + case 'table': + return resolveLeafContent(node)?.intrinsicSize?.minHeight ?? 0; + case 'pageBreak': + return 0; + case 'fixed': + return estimateNodeHeight(node.child, availableWidth); + } + + return 0; +} + +function findNodeById( + node: VitrineLayoutNode | undefined, + id: string +): VitrineLayoutNode | null { + if (!node) { + return null; + } + if (node.id === id) { + return node; + } + switch (node.kind) { + case 'stack': + for (const child of node.children) { + const found = findNodeById(child, id); + if (found) { + return found; + } + } + return null; + case 'box': + return node.child ? findNodeById(node.child, id) : null; + case 'fixed': + return findNodeById(node.child, id); + default: + return null; + } +} + +function lookupNode( + document: VitrineLayoutDocument, + fragment: LayoutFragment, + pageIndex: number +): VitrineLayoutNode | null { + if (fragment.artifactKind) { + switch (fragment.artifactKind) { + case 'background': + return document.background ?? null; + case 'foreground': + return document.foreground ?? null; + case 'header': + return document.kind === 'flow' ? document.header ?? null : null; + case 'footer': + return document.kind === 'flow' ? document.footer ?? null : null; + } + } + + if (document.kind === 'presentation') { + return document.slides[pageIndex] ?? null; + } + + if (!fragment.nodeId) { + return null; + } + + return findNodeById(document.body, fragment.nodeId) + ?? findNodeById(document.header, fragment.nodeId) + ?? findNodeById(document.footer, fragment.nodeId) + ?? findNodeById(document.background, fragment.nodeId) + ?? findNodeById(document.foreground, fragment.nodeId); +} + +function buildTextBlock( + node: TextLayoutNode, + rect: LayoutRect, + defaultTextFill: string +): Block { + return text({ + x: rect.x, + y: rect.y, + text: node.text, + dx: rect.width, + font: node.textStyle?.font, + fontSize: node.textStyle?.fontSize, + dyLineHeight: node.textStyle?.lineHeight, + fill: node.textStyle?.fill ?? defaultTextFill, + baseline: 'top' + }); +} + +function renderLeafNode( + node: VitrineLeafLayoutNode, + rect: LayoutRect, + pageIndex: number, + scale: number, + renderData?: unknown +): Block[] { + const content = resolveLeafContent(node); + if (!content) { + return []; + } + + const rgblChildren = content.renderBlocks + ? content.renderBlocks({ + nodeId: node.id, + nodeKind: node.kind, + pageIndex, + frame: { + x: 0, + y: 0, + width: rect.width, + height: rect.height + }, + pageRect: rect, + scale, + renderData + }) + : (content.blocks ?? []); + + return [ + group({ + x: rect.x, + y: rect.y, + clip: content.clip ?? false, + dx: rect.width, + dy: rect.height + }, rgblChildren) + ]; +} + +function renderBoxIntoRect( + node: BoxLayoutNode, + rect: LayoutRect, + pageIndex: number, + scale: number, + defaultTextFill: string +): Block[] { + if (!node.child) { + return []; + } + const insets = normalizeLayoutInsets(node.padding); + return renderNodeIntoRect(node.child, { + x: rect.x + insets.left, + y: rect.y + insets.top, + width: Math.max(0, rect.width - insets.left - insets.right), + height: Math.max(0, rect.height - insets.top - insets.bottom) + }, pageIndex, scale, defaultTextFill); +} + +function renderStackIntoRect( + node: StackLayoutNode, + rect: LayoutRect, + pageIndex: number, + scale: number, + defaultTextFill: string +): Block[] { + const insets = normalizeLayoutInsets(node.padding); + const innerRect: LayoutRect = { + x: rect.x + insets.left, + y: rect.y + insets.top, + width: Math.max(0, rect.width - insets.left - insets.right), + height: Math.max(0, rect.height - insets.top - insets.bottom) + }; + let yCursor = innerRect.y; + const rgblBlocks: Block[] = []; + + node.children.forEach((child: VitrineLayoutNode, index: number) => { + const dyChild = estimateNodeHeight(child, innerRect.width); + const childRect: LayoutRect = { + x: innerRect.x, + y: yCursor, + width: innerRect.width, + height: dyChild + }; + rgblBlocks.push(...renderNodeIntoRect(child, childRect, pageIndex, scale, defaultTextFill)); + yCursor += dyChild; + if (index < node.children.length - 1) { + yCursor += node.gap ?? 0; + } + }); + + return rgblBlocks; +} + +function renderNodeIntoRect( + node: VitrineLayoutNode, + rect: LayoutRect, + pageIndex: number, + scale: number, + defaultTextFill: string, + renderData?: unknown +): Block[] { + switch (node.kind) { + case 'text': + return [buildTextBlock(node, rect, defaultTextFill)]; + case 'spacer': + case 'pageBreak': + return []; + case 'stack': + return renderStackIntoRect(node, rect, pageIndex, scale, defaultTextFill); + case 'box': + return renderBoxIntoRect(node, rect, pageIndex, scale, defaultTextFill); + case 'measured': + case 'table': + return renderLeafNode(node, rect, pageIndex, scale, renderData); + case 'fixed': + return renderNodeIntoRect(node.child, rect, pageIndex, scale, defaultTextFill, renderData); + } + + return []; +} + +function resolvePreferredMeasuredContent( + intrinsicSize: LayoutIntrinsicSize, + availableSpace: LayoutAvailableSpace +): LayoutMeasuredContent { + const dxPreferred = Math.max( + intrinsicSize.minWidth, + Math.min(availableSpace.width, intrinsicSize.maxWidth) + ); + + return { + intrinsicSize, + preferredRect: { + x: 0, + y: 0, + width: Number.isFinite(dxPreferred) ? dxPreferred : availableSpace.width, + height: intrinsicSize.minHeight + } + }; +} + +export function createVitrineLayoutMeasureDelegate( + options: CreateVitrineLayoutMeasureDelegateOptions = {} +): LayoutMeasureDelegate { + const fallbackIntrinsicSize = options.fallbackIntrinsicSize ?? { + minWidth: 0, + maxWidth: Number.POSITIVE_INFINITY, + minHeight: 0 + }; + + return { + measure(request: LayoutMeasureRequest): LayoutMeasuredContent | null { + if (request.node.kind !== 'measured' && request.node.kind !== 'table') { + return null; + } + + const content = request.node.content as VitrineLayoutLeafContent; + const rsRequest: VitrineLayoutMeasureRequest = { + node: request.node as VitrineLeafLayoutNode, + content, + availableSpace: request.availableSpace, + page: request.page + }; + + const measured = request.node.kind === 'measured' + ? options.measureMeasuredContent?.(rsRequest) + : options.measureTableContent?.(rsRequest); + if (measured) { + return measured; + } + + return resolvePreferredMeasuredContent(content.intrinsicSize ?? fallbackIntrinsicSize, request.availableSpace); + } + }; +} + +export function buildVitrineBlocksFromPaginatedPage( + document: VitrineLayoutDocument, + page: PaginatedPage, + options: BuildVitrineBlocksFromPaginatedPageOptions = {} +): Block { + const x = options.x ?? 0; + const y = options.y ?? 0; + const scale = options.scale ?? 1; + const rgblChildren: Block[] = []; + + if (options.showPageShadow) { + rgblChildren.push(rectangle({ + x: options.pageShadowOffsetX ?? 8, + y: options.pageShadowOffsetY ?? 10, + dx: page.page.width, + dy: page.page.height, + fill: options.pageShadowFill ?? themeDefault.shadowFill, + cornerRadius: options.pageShadowCornerRadius ?? 18 + })); + } + + if (options.showPageFrame) { + rgblChildren.push(rectangle({ + x: 0, + y: 0, + dx: page.page.width, + dy: page.page.height, + fill: options.pageFill ?? themeDefault.pageFill, + stroke: options.pageStroke ?? themeDefault.pageStroke, + strokeWidth: options.pageStrokeWidth ?? 1, + cornerRadius: options.pageCornerRadius ?? 18 + })); + } + + for (const fragment of page.fragments) { + if (fragment.artifactKind) { + const artifactFill = options.artifactFills?.[fragment.artifactKind]; + if (artifactFill !== undefined) { + rgblChildren.push(rectangle({ + x: fragment.rect.x, + y: fragment.rect.y, + dx: fragment.rect.width, + dy: fragment.rect.height, + fill: artifactFill + })); + } + } + + const node = lookupNode(document, fragment, page.index); + if (!node) { + continue; + } + + rgblChildren.push( + ...renderNodeIntoRect( + node, + fragment.rect, + page.index, + scale, + options.defaultTextFill ?? themeDefault.textFill, + fragment.renderData + ) + ); + } + + return group({ + ...(options.rootGroupProps ?? {}), + x, + y, + scaleX: scale, + scaleY: scale + }, rgblChildren); +} + +export function buildVitrineBlocksFromPaginatedLayout( + document: VitrineLayoutDocument, + result: PaginatedLayoutResult, + options: BuildVitrineBlocksFromPaginatedLayoutOptions = {} +): Block { + const xStart = options.x ?? 0; + const yStart = options.y ?? 0; + const scale = options.scale ?? 1; + const gap = options.pageGap ?? 24; + const cColumns = Math.max(1, options.columns ?? 1); + const dxPageScaled = result.page.width * scale; + const dyPageScaled = result.page.height * scale; + const dxStride = dxPageScaled + gap; + const dyStride = dyPageScaled + gap; + + const rgblChildren = result.pages.map((page: PaginatedPage, index: number) => { + const col = index % cColumns; + const row = Math.floor(index / cColumns); + return buildVitrineBlocksFromPaginatedPage(document, page, { + ...options, + x: xStart + col * dxStride, + y: yStart + row * dyStride, + scale + }); + }); + + return group(options.rootGroupProps ?? {}, rgblChildren); +} + +export function buildFlowDocumentPreview( + document: VitrineLayoutDocument, + result: PaginatedLayoutResult, + options: FlowPreviewOptions = {} +): Block { + const theme = mergePreviewTheme(options.theme); + const xStart = options.x ?? 40; + const yStart = options.y ?? 60; + const scale = options.scale ?? 0.34; + const gap = options.pageGap ?? 28; + const cColumns = Math.max(1, options.columns ?? 2); + const dxPageScaled = result.page.width * scale; + const dyPageScaled = result.page.height * scale; + const dxStride = dxPageScaled + gap; + const dyStride = dyPageScaled + gap; + const rgblChildren: Block[] = [ + rectangle({ + dx: 1280, + dy: 900, + fill: theme.canvasFill + }), + text({ + x: 44, + y: 26, + text: `Flow document preview · ${result.pages.length} pages`, + fontSize: 22, + fill: '#e2e8f0', + baseline: 'top' + }), + text({ + x: 44, + y: 58, + text: 'Semantic pagination with repeated page furniture and page breaks.', + fontSize: 13, + fill: '#94a3b8', + baseline: 'top' + }) + ]; + + result.pages.forEach((page: PaginatedPage, index: number) => { + const col = index % cColumns; + const row = Math.floor(index / cColumns); + const xPage = xStart + col * dxStride; + const yPage = yStart + row * dyStride; + rgblChildren.push(buildVitrineBlocksFromPaginatedPage(document, page, { + x: xPage, + y: yPage, + scale, + showPageShadow: true, + showPageFrame: true, + pageFill: theme.pageFill, + pageStroke: theme.pageStroke, + pageShadowFill: theme.shadowFill, + artifactFills: theme.artifactFills, + defaultTextFill: theme.textFill + })); + if (options.showPageLabels !== false) { + rgblChildren.push(text({ + x: xPage + 14, + y: yPage + 14, + text: `Page ${index + 1}`, + fontSize: 11, + fill: theme.mutedTextFill, + baseline: 'top' + })); + } + }); + + return group({}, rgblChildren); +} + +export function buildPresentationPreview( + document: VitrineLayoutDocument, + result: PaginatedLayoutResult, + options: PresentationPreviewOptions = {} +): Block { + const theme = mergePreviewTheme(options.theme); + const iCurrent = Math.max(0, Math.min(options.currentPageIndex ?? 0, result.pages.length - 1)); + const mainScale = options.mainScale ?? 0.5; + const thumbScale = options.thumbnailScale ?? 0.14; + const thumbGap = options.thumbnailGap ?? 18; + const xStart = options.x ?? 40; + const yStart = options.y ?? 64; + const pageCurrent = result.pages[iCurrent]!; + const dyMain = result.page.height * mainScale; + const rgblChildren: Block[] = [ + rectangle({ + dx: 1280, + dy: 860, + fill: '#020617' + }), + text({ + x: 44, + y: 26, + text: `Presentation preview · slide ${iCurrent + 1} of ${result.pages.length}`, + fontSize: 22, + fill: '#e2e8f0', + baseline: 'top' + }), + text({ + x: 44, + y: 58, + text: 'Explicit slide pages with fixed 16:9 geometry for browser presentations.', + fontSize: 13, + fill: '#94a3b8', + baseline: 'top' + }), + buildVitrineBlocksFromPaginatedPage(document, pageCurrent, { + x: xStart, + y: yStart, + scale: mainScale, + showPageShadow: true, + showPageFrame: true, + pageFill: theme.pageFill, + pageStroke: theme.pageStroke, + pageShadowFill: theme.shadowFill, + artifactFills: theme.artifactFills, + defaultTextFill: theme.textFill + }) + ]; + + result.pages.forEach((page: PaginatedPage, index: number) => { + const thumbX = xStart + index * (result.page.width * thumbScale + thumbGap); + const thumbY = yStart + dyMain + 34; + rgblChildren.push(buildVitrineBlocksFromPaginatedPage(document, page, { + x: thumbX, + y: thumbY, + scale: thumbScale, + showPageFrame: true, + pageFill: theme.pageFill, + pageStroke: index === iCurrent ? theme.accentFill : theme.pageStroke, + artifactFills: theme.artifactFills, + defaultTextFill: theme.textFill + })); + rgblChildren.push(text({ + x: thumbX + 8, + y: thumbY + 8, + text: String(index + 1), + fontSize: 10, + fill: index === iCurrent ? theme.accentFill : theme.mutedTextFill, + baseline: 'top' + })); + }); + + return group({}, rgblChildren); +} diff --git a/packages/layout-pagination-vitrine/tsconfig.json b/packages/layout-pagination-vitrine/tsconfig.json new file mode 100644 index 0000000..f543bbd --- /dev/null +++ b/packages/layout-pagination-vitrine/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "outDir": "./dist", + "rootDir": "./src", + "noEmit": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/layout-pagination/README.md b/packages/layout-pagination/README.md index 8c4e7bb..75b2c83 100644 --- a/packages/layout-pagination/README.md +++ b/packages/layout-pagination/README.md @@ -103,6 +103,17 @@ Measured content is delegated through `LayoutMeasureDelegate`: This keeps the package renderer-agnostic while still allowing text, tables, and custom content to participate in layout. +For Vitrine rendering, use the companion adapter package: + +```ts +import { + buildVitrineBlocksFromPaginatedLayout, + createVitrineLayoutMeasureDelegate +} from 'vitrine-layout-pagination-adapter'; +``` + +That adapter lets `measured` and `table` nodes carry intrinsic size plus Vitrine block content while this package remains layout-only. + ## Current engine behavior The current `PaginatedLayoutEngine` is intentionally an MVP. @@ -216,7 +227,6 @@ This is still an MVP engine. It does **not** yet provide: - widow/orphan control - keep-with-next / avoid-break-inside enforcement beyond type-level support - table-specific pagination behavior -- automatic Vitrine rendering adapters in this package - PDF output - semantic markdown parsing or markdown-to-layout conversion @@ -264,6 +274,5 @@ Likely next areas of work: 1. richer flow fragmentation 2. stronger measurement integration for text/texta/tables -3. preview adapters for Vitrine block trees -4. semantic markdown/document builders -5. PDF export integration based on the paginated result +3. semantic markdown/document builders +4. PDF export integration based on the paginated result diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d69f74f..929a896 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,12 @@ importers: vitrine-gui: specifier: workspace:* version: link:../gui + vitrine-layout-pagination: + specifier: workspace:* + version: link:../layout-pagination + vitrine-layout-pagination-adapter: + specifier: workspace:* + version: link:../layout-pagination-vitrine devDependencies: '@types/node': specifier: ^22.10.5 @@ -64,6 +70,19 @@ importers: specifier: ^3.0.0 version: 3.2.4(@types/node@22.19.11) + packages/layout-pagination-vitrine: + dependencies: + vitrine: + specifier: workspace:* + version: link:../core + vitrine-layout-pagination: + specifier: workspace:* + version: link:../layout-pagination + devDependencies: + typescript: + specifier: ^5.7.3 + version: 5.9.3 + packages/tables: devDependencies: typescript: From 67171c8991305241fb91a73e3e13159aaf1232e8 Mon Sep 17 00:00:00 2001 From: frouaix Date: Sun, 2 Aug 2026 17:14:04 -0700 Subject: [PATCH 4/4] Add paginated page flip wrappers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/demo/layout-pagination-flow-setup.js | 19 +- .../layout-pagination-presentation-setup.js | 24 +- .../layout-pagination-vitrine/src/index.ts | 446 +++++++++++++++++- 3 files changed, 463 insertions(+), 26 deletions(-) diff --git a/packages/demo/layout-pagination-flow-setup.js b/packages/demo/layout-pagination-flow-setup.js index cee6ee1..74c1d8d 100644 --- a/packages/demo/layout-pagination-flow-setup.js +++ b/packages/demo/layout-pagination-flow-setup.js @@ -1,17 +1,22 @@ import { VitrineComponent } from 'vitrine-gui'; import { registerTextaBlockType } from 'texta/browser'; -import { buildFlowDocumentPreview } from 'vitrine-layout-pagination-adapter'; +import { + buildFlowPageFlipWrapper, + createFlowPageFlipState +} from 'vitrine-layout-pagination-adapter'; import { documentFlow, flowResult } from './layout-pagination-scenarios.ts'; registerTextaBlockType(); +const flowFlipState = createFlowPageFlipState(); + const component = VitrineComponent.block(() => ( - buildFlowDocumentPreview(documentFlow, flowResult, { - x: 70, - y: 86, - scale: 1.1, - pageGap: 42, - columns: 2 + buildFlowPageFlipWrapper(documentFlow, flowResult, flowFlipState, { + x: 270, + y: 96, + mainScale: 1.12, + thumbnailScale: 0.18, + thumbnailGap: 22 }) ), { width: 1600, diff --git a/packages/demo/layout-pagination-presentation-setup.js b/packages/demo/layout-pagination-presentation-setup.js index 387852c..3789947 100644 --- a/packages/demo/layout-pagination-presentation-setup.js +++ b/packages/demo/layout-pagination-presentation-setup.js @@ -1,16 +1,17 @@ import { VitrineComponent } from 'vitrine-gui'; import { registerTextaBlockType } from 'texta/browser'; -import { buildPresentationPreview } from 'vitrine-layout-pagination-adapter'; +import { + buildPresentationPageFlipWrapper, + createPresentationPageFlipState +} from 'vitrine-layout-pagination-adapter'; import { documentPresentation, presentationResult } from './layout-pagination-scenarios.ts'; registerTextaBlockType(); -let currentSlideIndex = 0; -let time = 0; +const slideFlipState = createPresentationPageFlipState(); const component = VitrineComponent.block(() => ( - buildPresentationPreview(documentPresentation, presentationResult, { - currentPageIndex: currentSlideIndex, + buildPresentationPageFlipWrapper(documentPresentation, presentationResult, slideFlipState, { x: 72, y: 92, mainScale: 0.86, @@ -24,16 +25,3 @@ const component = VitrineComponent.block(() => ( }); component.mount(document.getElementById('canvas')); - -let lastTime = performance.now(); -function animate(now) { - const dt = (now - lastTime) / 1000; - lastTime = now; - time += dt; - if (time >= 4) { - time = 0; - currentSlideIndex = (currentSlideIndex + 1) % presentationResult.pages.length; - } - requestAnimationFrame(animate); -} -requestAnimationFrame(animate); diff --git a/packages/layout-pagination-vitrine/src/index.ts b/packages/layout-pagination-vitrine/src/index.ts index b0e464c..f06b77a 100644 --- a/packages/layout-pagination-vitrine/src/index.ts +++ b/packages/layout-pagination-vitrine/src/index.ts @@ -1,5 +1,5 @@ import { group, rectangle, text } from 'vitrine'; -import type { Block, GroupProps } from 'vitrine'; +import type { Block, GroupProps, VitrinePointerEvent } from 'vitrine'; import { normalizeLayoutInsets, type BoxLayoutNode, @@ -127,6 +127,36 @@ export interface PresentationPreviewOptions { theme?: Partial; } +interface PageFlipStateBase { + currentPageIndex: number; + dragStartXs?: number; + dragCommitted?: boolean; +} + +export interface PresentationPageFlipState extends PageFlipStateBase {} + +export interface FlowPageFlipState extends PageFlipStateBase {} + +export interface PresentationPageFlipWrapperOptions { + x?: number; + y?: number; + mainScale?: number; + thumbnailScale?: number; + thumbnailGap?: number; + dragThreshold?: number; + theme?: Partial; +} + +export interface FlowPageFlipWrapperOptions { + x?: number; + y?: number; + mainScale?: number; + thumbnailScale?: number; + thumbnailGap?: number; + dragThreshold?: number; + theme?: Partial; +} + export const themeDefault: VitrinePreviewTheme = { canvasFill: '#0f172a', pageFill: '#ffffff', @@ -154,6 +184,169 @@ function mergePreviewTheme(theme?: Partial): VitrinePreview }; } +function clampPageIndex(index: number, pageCount: number): number { + if (pageCount <= 0) { + return 0; + } + return Math.max(0, Math.min(index, pageCount - 1)); +} + +function setCurrentPageIndex( + state: PageFlipStateBase, + pageCount: number, + nextPageIndex: number +): void { + state.currentPageIndex = clampPageIndex(nextPageIndex, pageCount); +} + +function stepPage( + state: PageFlipStateBase, + pageCount: number, + delta: number +): void { + setCurrentPageIndex(state, pageCount, state.currentPageIndex + delta); +} + +function beginPageFlipDrag( + state: PageFlipStateBase, + event: VitrinePointerEvent +): void { + state.dragStartXs = event.xs; + state.dragCommitted = false; +} + +function resetPageFlipDrag(state: PageFlipStateBase): void { + state.dragStartXs = undefined; + state.dragCommitted = false; +} + +function handlePageFlipDrag( + state: PageFlipStateBase, + event: VitrinePointerEvent, + pageCount: number, + threshold: number +): void { + if (state.dragCommitted || state.dragStartXs === undefined || event.xs === undefined) { + return; + } + + const dxDrag = event.xs - state.dragStartXs; + if (dxDrag <= -threshold) { + stepPage(state, pageCount, 1); + state.dragCommitted = true; + } else if (dxDrag >= threshold) { + stepPage(state, pageCount, -1); + state.dragCommitted = true; + } +} + +function pageFlipOverlay( + x: number, + y: number, + width: number, + height: number, + state: PageFlipStateBase, + pageCount: number, + dragThreshold: number +): Block { + return rectangle({ + x, + y, + dx: width, + dy: height, + fill: 'rgba(0, 0, 0, 0)', + onClick: (event: VitrinePointerEvent) => { + if (event.xl === undefined) { + return; + } + if (event.xl < width * 0.35) { + stepPage(state, pageCount, -1); + } else if (event.xl > width * 0.65) { + stepPage(state, pageCount, 1); + } + }, + onPointerDown: (event: VitrinePointerEvent) => { + beginPageFlipDrag(state, event); + }, + onDrag: (event: VitrinePointerEvent) => { + handlePageFlipDrag(state, event, pageCount, dragThreshold); + }, + onPointerUp: () => { + resetPageFlipDrag(state); + } + }); +} + +function navButton( + x: number, + y: number, + width: number, + height: number, + label: string, + enabled: boolean, + onClick: () => void +): Block { + const fill = enabled ? '#ffffff' : 'rgba(255,255,255,0.45)'; + const stroke = enabled ? '#cbd5e1' : '#cbd5e1'; + const textFill = enabled ? '#0f172a' : '#94a3b8'; + return group({}, [ + rectangle({ + x, + y, + dx: width, + dy: height, + fill, + stroke, + strokeWidth: 1, + cornerRadius: 12, + onClick: enabled ? () => onClick() : undefined + }), + text({ + x: x + width / 2, + y: y + height / 2, + text: label, + fontSize: 14, + fill: textFill, + align: 'center', + baseline: 'middle' + }) + ]); +} + +function pageCounterLabel( + x: number, + y: number, + label: string, + theme: VitrinePreviewTheme +): Block { + return text({ + x, + y, + text: label, + fontSize: 13, + fill: theme.mutedTextFill, + baseline: 'top' + }); +} + +export function createPresentationPageFlipState( + currentPageIndex: number = 0 +): PresentationPageFlipState { + return { + currentPageIndex, + dragCommitted: false + }; +} + +export function createFlowPageFlipState( + currentPageIndex: number = 0 +): FlowPageFlipState { + return { + currentPageIndex, + dragCommitted: false + }; +} + function estimateTextHeight(node: TextLayoutNode, availableWidth: number): number { const fontSize = typeof node.textStyle?.fontSize === 'number' ? node.textStyle.fontSize : 16; const lineHeight = typeof node.textStyle?.lineHeight === 'number' ? node.textStyle.lineHeight : fontSize * 1.4; @@ -728,3 +921,254 @@ export function buildPresentationPreview( return group({}, rgblChildren); } + +export function buildPresentationPageFlipWrapper( + document: VitrineLayoutDocument, + result: PaginatedLayoutResult, + state: PresentationPageFlipState, + options: PresentationPageFlipWrapperOptions = {} +): Block { + const theme = mergePreviewTheme(options.theme); + const pageCount = result.pages.length; + const iCurrent = clampPageIndex(state.currentPageIndex, pageCount); + state.currentPageIndex = iCurrent; + + const xStart = options.x ?? 40; + const yStart = options.y ?? 64; + const mainScale = options.mainScale ?? 0.5; + const thumbScale = options.thumbnailScale ?? 0.14; + const thumbGap = options.thumbnailGap ?? 18; + const dragThreshold = options.dragThreshold ?? 72; + const pageCurrent = result.pages[iCurrent]!; + const dxMain = result.page.width * mainScale; + const dyMain = result.page.height * mainScale; + const rgblChildren: Block[] = [ + rectangle({ + dx: 1280, + dy: 860, + fill: '#020617' + }), + text({ + x: 44, + y: 26, + text: `Presentation flip view · slide ${iCurrent + 1} of ${pageCount}`, + fontSize: 22, + fill: '#e2e8f0', + baseline: 'top' + }), + text({ + x: 44, + y: 58, + text: 'Click, drag, or use the thumbnail strip to flip between explicit slide pages.', + fontSize: 13, + fill: '#94a3b8', + baseline: 'top' + }), + buildVitrineBlocksFromPaginatedPage(document, pageCurrent, { + x: xStart, + y: yStart, + scale: mainScale, + showPageShadow: true, + showPageFrame: true, + pageFill: theme.pageFill, + pageStroke: theme.pageStroke, + pageShadowFill: theme.shadowFill, + artifactFills: theme.artifactFills, + defaultTextFill: theme.textFill + }), + pageFlipOverlay( + xStart, + yStart, + dxMain, + dyMain, + state, + pageCount, + dragThreshold + ), + navButton( + xStart, + yStart + dyMain + 18, + 96, + 38, + 'Prev', + iCurrent > 0, + () => stepPage(state, pageCount, -1) + ), + navButton( + xStart + dxMain - 96, + yStart + dyMain + 18, + 96, + 38, + 'Next', + iCurrent < pageCount - 1, + () => stepPage(state, pageCount, 1) + ), + pageCounterLabel( + xStart + dxMain / 2 - 46, + yStart + dyMain + 28, + `Slide ${iCurrent + 1} / ${pageCount}`, + theme + ) + ]; + + result.pages.forEach((page: PaginatedPage, index: number) => { + const thumbX = xStart + index * (result.page.width * thumbScale + thumbGap); + const thumbY = yStart + dyMain + 74; + rgblChildren.push(buildVitrineBlocksFromPaginatedPage(document, page, { + x: thumbX, + y: thumbY, + scale: thumbScale, + showPageFrame: true, + pageFill: theme.pageFill, + pageStroke: index === iCurrent ? theme.accentFill : theme.pageStroke, + artifactFills: theme.artifactFills, + defaultTextFill: theme.textFill + })); + rgblChildren.push(rectangle({ + x: thumbX, + y: thumbY, + dx: result.page.width * thumbScale, + dy: result.page.height * thumbScale, + fill: 'rgba(0, 0, 0, 0)', + onClick: () => { + setCurrentPageIndex(state, pageCount, index); + } + })); + rgblChildren.push(text({ + x: thumbX + 8, + y: thumbY + 8, + text: String(index + 1), + fontSize: 10, + fill: index === iCurrent ? theme.accentFill : theme.mutedTextFill, + baseline: 'top' + })); + }); + + return group({}, rgblChildren); +} + +export function buildFlowPageFlipWrapper( + document: VitrineLayoutDocument, + result: PaginatedLayoutResult, + state: FlowPageFlipState, + options: FlowPageFlipWrapperOptions = {} +): Block { + const theme = mergePreviewTheme(options.theme); + const pageCount = result.pages.length; + const iCurrent = clampPageIndex(state.currentPageIndex, pageCount); + state.currentPageIndex = iCurrent; + + const xStart = options.x ?? 92; + const yStart = options.y ?? 92; + const mainScale = options.mainScale ?? 1.02; + const thumbScale = options.thumbnailScale ?? 0.16; + const thumbGap = options.thumbnailGap ?? 20; + const dragThreshold = options.dragThreshold ?? 58; + const pageCurrent = result.pages[iCurrent]!; + const dxMain = result.page.width * mainScale; + const dyMain = result.page.height * mainScale; + const thumbStride = result.page.width * thumbScale + thumbGap; + const thumbStartX = xStart + Math.max(0, (dxMain - ((Math.min(pageCount, 5) * thumbStride) - thumbGap)) / 2); + const iThumbStart = Math.max(0, Math.min(iCurrent - 2, Math.max(0, pageCount - 5))); + const iThumbEnd = Math.min(pageCount, iThumbStart + 5); + + const rgblChildren: Block[] = [ + rectangle({ + dx: 1600, + dy: 1120, + fill: theme.canvasFill + }), + text({ + x: 52, + y: 28, + text: `Flow document flip view · page ${iCurrent + 1} of ${pageCount}`, + fontSize: 22, + fill: '#e2e8f0', + baseline: 'top' + }), + text({ + x: 52, + y: 60, + text: 'Document pages render one at a time here; drag or click to turn pages while keeping paginated content intact.', + fontSize: 13, + fill: '#94a3b8', + baseline: 'top' + }), + buildVitrineBlocksFromPaginatedPage(document, pageCurrent, { + x: xStart, + y: yStart, + scale: mainScale, + showPageShadow: true, + showPageFrame: true, + pageFill: theme.pageFill, + pageStroke: theme.pageStroke, + pageShadowFill: theme.shadowFill, + artifactFills: theme.artifactFills, + defaultTextFill: theme.textFill + }), + pageFlipOverlay( + xStart, + yStart, + dxMain, + dyMain, + state, + pageCount, + dragThreshold + ), + navButton( + xStart, + yStart + dyMain + 18, + 110, + 40, + 'Previous', + iCurrent > 0, + () => stepPage(state, pageCount, -1) + ), + navButton( + xStart + dxMain - 110, + yStart + dyMain + 18, + 110, + 40, + 'Next', + iCurrent < pageCount - 1, + () => stepPage(state, pageCount, 1) + ), + pageCounterLabel( + xStart + dxMain / 2 - 42, + yStart + dyMain + 30, + `Page ${iCurrent + 1} / ${pageCount}`, + theme + ) + ]; + + for (let iPage = iThumbStart; iPage < iThumbEnd; iPage += 1) { + const page = result.pages[iPage]!; + const xThumb = thumbStartX + (iPage - iThumbStart) * thumbStride; + const yThumb = yStart + dyMain + 84; + rgblChildren.push(buildVitrineBlocksFromPaginatedPage(document, page, { + x: xThumb, + y: yThumb, + scale: thumbScale, + showPageFrame: true, + pageFill: theme.pageFill, + pageStroke: iPage === iCurrent ? theme.accentFill : theme.pageStroke, + artifactFills: theme.artifactFills, + defaultTextFill: theme.textFill, + rootGroupProps: { + opacity: iPage === iCurrent ? 1 : 0.82 + } + })); + rgblChildren.push(rectangle({ + x: xThumb, + y: yThumb, + dx: result.page.width * thumbScale, + dy: result.page.height * thumbScale, + fill: 'rgba(0, 0, 0, 0)', + onClick: () => { + setCurrentPageIndex(state, pageCount, iPage); + } + })); + } + + return group({}, rgblChildren); +}