diff --git a/packages/wc/components/slide.ts b/packages/wc/components/slide.ts index 17d5017..c3e7879 100644 --- a/packages/wc/components/slide.ts +++ b/packages/wc/components/slide.ts @@ -3,11 +3,7 @@ import { customElement, property } from "lit/decorators.js"; import { consume } from "@lit/context"; import { color, dimension, fontFamily } from "@morkdeck/theme/css"; import { observerContext } from "../context.ts"; -import { - type Density, - getSectionContentArea, - predictContentHeight, -} from "../density.ts"; +import { type Density, pickDensity } from "../density.ts"; import { MorkdeckElement } from "../element.ts"; @customElement("morkdeck-slide") @@ -148,13 +144,13 @@ export class Slide extends MorkdeckElement { } /** - * Walk density tiers loosest → tightest, pick the loosest whose - * pretext-predicted content height fits the section's content area. + * Pick the loosest density tier whose predicted content fits the + * section's content area, then commit it to the DOM. * - * Pretext predicts each text element's height from its font + width + - * line-height, then we sum with element margins and non-text element - * heights. This is paint-independent: it doesn't matter that the flex - * `justify-content: center` would otherwise hide overflow on both edges. + * The prediction is pure: density.ts mirrors the CSS density rules in JS, + * so we can evaluate every tier from a single layout state without any + * setAttribute + reflow cycles between measurements. One DOM read per + * fit, one DOM write at the end. */ #fitDensity() { if (this.#fittingDensity) return; @@ -165,23 +161,12 @@ export class Slide extends MorkdeckElement { this.#fittingDensity = true; try { - const tiers: Array = ["roomy", "balanced", "dense"]; - for (const tier of tiers) { - if (tier === "roomy") { - this.removeAttribute("data-density"); - } else { - this.setAttribute("data-density", tier); - } - // Force a style/layout flush so computed styles reflect the tier - // before pretext reads them. - void section.offsetHeight; - const { width, height } = getSectionContentArea(section); - const predicted = predictContentHeight(this, width); - if (predicted <= height) { - return; - } + const density: Density = pickDensity(this, section); + if (density === "roomy") { + this.removeAttribute("data-density"); + } else { + this.setAttribute("data-density", density); } - // Even dense overflowed; stay at dense. } finally { this.#fittingDensity = false; } diff --git a/packages/wc/density.ts b/packages/wc/density.ts index 84b5770..aa72e77 100644 --- a/packages/wc/density.ts +++ b/packages/wc/density.ts @@ -1,90 +1,247 @@ /** - * Density auto-fit: predict the total intrinsic height of a slide's content - * using pretext, then pick the loosest density preset that fits the 16:9 - * canvas. + * Density auto-fit (Tier 2): predict the total intrinsic content height of a + * slide at every density tier in one measurement pass — no DOM mutation, no + * reflow per tier. * - * Why pretext: a slide's vertical content height is a function of viewport - * → section width → cqi-scaled type → text wrapping → line count. The flex - * layout's `justify-content: center` hides overflow visually instead of - * exposing it via scrollHeight, so the naive DOM-measured approach can - * under-detect overflow at certain aspect ratios. Pretext gives a precise, - * paint-independent height per element from font + width + line-height. + * How it works: + * 1. A JS-side spec (DENSITY_SPECS) mirrors the CSS density rules in + * slide-styles.eta. For each tier × element role, it records the + * font-size (cqi), line-height (multiplier), and bottom-margin (cqi or + * cqmin) that the CSS would apply. + * 2. Per-element font family + weight come from one getComputedStyle() + * read at the slide's current state. Those don't change between tiers, + * so reading once is enough. + * 3. For each candidate tier, pretext predicts each text element's height + * using the tier's font-size + line-height + container width. Heights + * sum with the tier's margins. + * 4. The auto-fit picks the loosest tier whose predicted total fits the + * section's content area. Only then does the DOM see the attribute. + * + * The JS spec duplicates CSS rules. Whenever slide-styles.eta gains a new + * density-aware rule, this file needs the same entry. The trade for that + * maintenance: paint-independent measurement, no reflow per tier, and a + * pure prediction path that build-time pre-computation can reuse. */ import { layout, prepare } from "@chenglou/pretext"; export type Density = "roomy" | "balanced" | "dense"; -const TEXT_TAGS = new Set([ - "P", - "H1", - "H2", - "H3", - "H4", - "H5", - "H6", - "BLOCKQUOTE", - "LI", - "FIGCAPTION", -]); - -const NON_TEXT_LEAF_TAGS = new Set(["IMG", "PRE", "SVG", "HR", "CANVAS"]); +export const DENSITIES: ReadonlyArray = [ + "roomy", + "balanced", + "dense", +]; -interface MeasureContext { - containerWidth: number; +/** Length specified in container-query units. */ +type CqLength = { value: number; unit: "cqi" | "cqmin" }; + +const CQI = (value: number): CqLength => ({ value, unit: "cqi" }); +const CQMIN = (value: number): CqLength => ({ value, unit: "cqmin" }); + +interface TextRole { + /** Font-size as a cq length. Resolved against the section box. */ + fontSize: CqLength; + /** Unitless line-height multiplier. */ + lineHeight: number; + /** Bottom margin between this block and the next. */ + marginBottom: CqLength; +} + +interface DensitySpec { + // Text-bearing roles. Each lives at a single point in the CSS cascade. + p: TextRole; + li: TextRole; + blockquote: TextRole; + figcaption: TextRole; + h1: TextRole; + h2: TextRole; + h3: TextRole; + // The h3 in a two-column layout's column heading (separate rule in CSS). + colHeading: TextRole; + // Inter-list-item gap (margin-top on `li + li`). + listItemGap: CqLength; + // List indent (padding-left on `ul`/`ol`). + listPaddingLeft: CqLength; } +/** + * Density specs. Mirrors slide-styles.eta. The values reflect the resolved + * token assignment per tier — not the token paths themselves — because the + * tokens map to literal cqi values defined in size.json. + */ +const DENSITY_SPECS: Record = { + roomy: { + p: { fontSize: CQI(2.5), lineHeight: 1.5, marginBottom: CQI(3) }, + li: { fontSize: CQI(2.5), lineHeight: 1.5, marginBottom: CQI(0) }, + blockquote: { fontSize: CQI(2.5), lineHeight: 1.5, marginBottom: CQI(3) }, + figcaption: { + fontSize: CQI(2.2), + lineHeight: 1.35, + marginBottom: CQI(0), + }, + h1: { fontSize: CQI(8), lineHeight: 0.88, marginBottom: CQI(3) }, + h2: { fontSize: CQI(6), lineHeight: 0.96, marginBottom: CQI(3) }, + h3: { fontSize: CQI(2.8), lineHeight: 0.96, marginBottom: CQI(2) }, + colHeading: { + fontSize: CQI(2.8), + lineHeight: 0.96, + marginBottom: CQI(2), + }, + listItemGap: CQI(2), + listPaddingLeft: CQI(3), + }, + balanced: { + p: { fontSize: CQI(2.2), lineHeight: 1.35, marginBottom: CQI(2) }, + li: { fontSize: CQI(2.2), lineHeight: 1.35, marginBottom: CQI(0) }, + blockquote: { + fontSize: CQI(2.2), + lineHeight: 1.35, + marginBottom: CQI(2), + }, + figcaption: { + fontSize: CQI(2.2), + lineHeight: 1.35, + marginBottom: CQI(0), + }, + h1: { fontSize: CQI(8), lineHeight: 0.88, marginBottom: CQI(2) }, + h2: { fontSize: CQI(6), lineHeight: 0.96, marginBottom: CQI(2) }, + h3: { fontSize: CQI(2.8), lineHeight: 0.96, marginBottom: CQI(2) }, + colHeading: { + fontSize: CQI(2.8), + lineHeight: 0.96, + marginBottom: CQI(2), + }, + listItemGap: CQI(1), + listPaddingLeft: CQI(3), + }, + dense: { + p: { fontSize: CQI(2), lineHeight: 1.35, marginBottom: CQI(1) }, + li: { fontSize: CQI(2), lineHeight: 1.35, marginBottom: CQI(0) }, + blockquote: { fontSize: CQI(2), lineHeight: 1.35, marginBottom: CQI(1) }, + figcaption: { + fontSize: CQI(2.2), + lineHeight: 1.35, + marginBottom: CQI(0), + }, + h1: { fontSize: CQI(8), lineHeight: 0.88, marginBottom: CQI(1) }, + h2: { fontSize: CQI(2.8), lineHeight: 0.96, marginBottom: CQI(1) }, + h3: { fontSize: CQI(2.8), lineHeight: 0.96, marginBottom: CQI(1) }, + colHeading: { + fontSize: CQI(2.8), + lineHeight: 0.96, + marginBottom: CQI(1), + }, + listItemGap: CQI(1), + listPaddingLeft: CQI(3), + }, +}; + +interface SectionBox { + /** Section content-area width (clientWidth minus horizontal padding). */ + width: number; + /** Section content-area height (clientHeight minus vertical padding). */ + height: number; + /** Section inline size — basis for cqi (= section.clientWidth). */ + cqInline: number; + /** Section block size — paired with inline for cqmin. */ + cqBlock: number; +} + +function resolveLength(len: CqLength, box: SectionBox): number { + switch (len.unit) { + case "cqi": + return (len.value / 100) * box.cqInline; + case "cqmin": + return (len.value / 100) * Math.min(box.cqInline, box.cqBlock); + } +} + +/** Element font family + weight read once; stable across tiers. */ +interface BaseFont { + family: string; + weight: string; + italic: boolean; +} + +function readBaseFont(el: HTMLElement): BaseFont { + const styles = getComputedStyle(el); + return { + family: styles.fontFamily, + weight: styles.fontWeight, + italic: styles.fontStyle === "italic", + }; +} + +function roleSpec(spec: DensitySpec, role: keyof DensitySpec): TextRole { + return spec[role] as TextRole; +} + +function predictTextHeight( + text: string, + baseFont: BaseFont, + role: TextRole, + containerWidth: number, + box: SectionBox, +): number { + if (!text) return 0; + const fontSizePx = resolveLength(role.fontSize, box); + const lineHeightPx = fontSizePx * role.lineHeight; + const italic = baseFont.italic ? "italic " : ""; + const font = `${italic}${baseFont.weight} ${fontSizePx}px ${baseFont.family}`; + const prepared = prepare(text, font); + return layout(prepared, Math.max(containerWidth, 1), lineHeightPx).height; +} + +const TEXT_TAG_TO_ROLE: Record = { + P: "p", + LI: "li", + BLOCKQUOTE: "blockquote", + FIGCAPTION: "figcaption", + H1: "h1", + H2: "h2", + H3: "h3", + H4: "h3", + H5: "h3", + H6: "h3", +}; + +const NON_TEXT_LEAF = new Set(["IMG", "PRE", "SVG", "HR", "CANVAS"]); + function px(value: string): number { const n = parseFloat(value); return Number.isFinite(n) ? n : 0; } -function effectiveWidth(el: HTMLElement, container: number): number { +/** Effective text-wrap width for an element: container minus its own padding. */ +function elementContentWidth(el: HTMLElement, container: number): number { const styles = getComputedStyle(el); const padLeft = px(styles.paddingLeft); const padRight = px(styles.paddingRight); - const available = container - padLeft - padRight; + const inner = container - padLeft - padRight; const maxW = styles.maxWidth; if (maxW && maxW !== "none") { const maxPx = px(maxW); - if (maxPx > 0) return Math.max(Math.min(available, maxPx), 1); + if (maxPx > 0) return Math.max(Math.min(inner, maxPx), 1); } - return Math.max(available, 1); + return Math.max(inner, 1); } -function measureTextElement(el: HTMLElement, container: number): number { - const text = el.textContent?.trim() ?? ""; - if (!text) return 0; - - const styles = getComputedStyle(el); - const fontSize = styles.fontSize; - const fontWeight = styles.fontWeight; - const fontStyle = styles.fontStyle === "italic" ? "italic " : ""; - const family = styles.fontFamily; - const fontShorthand = `${fontStyle}${fontWeight} ${fontSize} ${family}`; - - const fontSizePx = px(fontSize); - let lineHeightPx = px(styles.lineHeight); - if (lineHeightPx <= 0) lineHeightPx = fontSizePx * 1.2; - - const width = effectiveWidth(el, container); - if (width <= 0) return 0; - - const prepared = prepare(text, fontShorthand); - const result = layout(prepared, width, lineHeightPx); - return result.height; +interface MeasureContext { + containerWidth: number; + spec: DensitySpec; + box: SectionBox; + /** True if we're inside a two-column slide's column. */ + inTwoColumn?: boolean; } function measureBlock(el: HTMLElement, ctx: MeasureContext): number { - const styles = getComputedStyle(el); - const marginTop = px(styles.marginTop); - const marginBottom = px(styles.marginBottom); - - // Two-column container: take the max column height. + // Two-column container: take the max of column heights. if (el.classList.contains("morkdeck-columns")) { const cols = Array.from(el.children).filter((c) => (c as HTMLElement).classList.contains("morkdeck-col") ) as HTMLElement[]; + const styles = getComputedStyle(el); const gap = px(styles.columnGap) || px(styles.gap); const colWidth = Math.max( (ctx.containerWidth - gap) / Math.max(cols.length, 1), @@ -94,86 +251,152 @@ function measureBlock(el: HTMLElement, ctx: MeasureContext): number { for (const col of cols) { let total = 0; for (const child of Array.from(col.children)) { - total += measureBlock(child as HTMLElement, { containerWidth: colWidth }); + total += measureBlock(child as HTMLElement, { + ...ctx, + containerWidth: colWidth, + inTwoColumn: true, + }); } if (total > maxColHeight) maxColHeight = total; } - return maxColHeight + marginTop + marginBottom; + return maxColHeight; } - // List: walk items + // Lists: walk children with the list's inner width. if (el.tagName === "UL" || el.tagName === "OL") { - const padLeft = px(styles.paddingLeft); - const innerWidth = Math.max(ctx.containerWidth - padLeft, 1); + const padLeft = resolveLength(ctx.spec.listPaddingLeft, ctx.box); + const inner = Math.max(ctx.containerWidth - padLeft, 1); + const gap = resolveLength(ctx.spec.listItemGap, ctx.box); + const items = Array.from(el.children).filter( + (c) => c.tagName === "LI", + ) as HTMLElement[]; let total = 0; - for (const child of Array.from(el.children)) { - total += measureBlock(child as HTMLElement, { - containerWidth: innerWidth, + items.forEach((li, i) => { + total += measureBlock(li as HTMLElement, { + ...ctx, + containerWidth: inner, }); - } - return total + marginTop + marginBottom; + if (i > 0) total += gap; + }); + return total + resolveLength(ctx.spec.p.marginBottom, ctx.box); } - // Figure (caption pattern): walk children + // Figure: walk children. Caption uses figcaption spec. if (el.tagName === "FIGURE") { let total = 0; for (const child of Array.from(el.children)) { total += measureBlock(child as HTMLElement, ctx); } - return total + marginTop + marginBottom; + return total; } - // Non-text leaves (image, code block, svg): trust the rendered clientHeight - if (NON_TEXT_LEAF_TAGS.has(el.tagName)) { - return el.clientHeight + marginTop + marginBottom; + // Non-text leaves: trust their currently-rendered clientHeight. + if (NON_TEXT_LEAF.has(el.tagName)) { + return el.clientHeight; } - // Text blocks (paragraph, heading, list item, blockquote, figcaption) - if (TEXT_TAGS.has(el.tagName)) { - return measureTextElement(el, ctx.containerWidth) + marginTop + marginBottom; + // Text-bearing element: pretext-predict + spec-derived margin. + const role = TEXT_TAG_TO_ROLE[el.tagName]; + if (role) { + // Two-column slides use the slide-title h2 / col-heading h3 specs. + const resolvedRole: keyof DensitySpec = + ctx.inTwoColumn && el.tagName === "H3" ? "colHeading" : role; + const spec = roleSpec(ctx.spec, resolvedRole); + const baseFont = readBaseFont(el); + const text = (el.textContent ?? "").trim(); + const innerWidth = elementContentWidth(el, ctx.containerWidth); + const height = predictTextHeight( + text, + baseFont, + spec, + innerWidth, + ctx.box, + ); + return height + resolveLength(spec.marginBottom, ctx.box); } - // Unknown wrapper: walk children + // Unknown wrapper: walk children. if (el.children.length > 0) { let total = 0; for (const child of Array.from(el.children)) { total += measureBlock(child as HTMLElement, ctx); } - return total + marginTop + marginBottom; + return total; } - // Fallback: clientHeight - return el.clientHeight + marginTop + marginBottom; + return el.clientHeight; } /** - * Predict the total content height (excluding section padding) for a slide, - * given the section's current content-area width. + * Predict the total intrinsic content height a slide would have at the + * given density tier, without changing the DOM. */ export function predictContentHeight( slide: HTMLElement, - contentWidth: number, + density: Density, + box: SectionBox, ): number { + const spec = DENSITY_SPECS[density]; let total = 0; for (const child of Array.from(slide.children)) { total += measureBlock(child as HTMLElement, { - containerWidth: contentWidth, + containerWidth: box.width, + spec, + box, }); } return total; } /** - * Get the available content-area height of a section element, accounting - * for its vertical padding. + * Build a SectionBox snapshot for the section element. + * + * `cqi` and `cqmin` are resolved against the *content* inline/block size of + * the query container, not its padding-box. (`container-type: inline-size` + * uses contentBoxSize as the cq basis.) The slide section has its own + * cq-unit padding, so we subtract that before exposing cqInline/cqBlock — + * otherwise predicted font sizes are larger than what the browser renders. + * + * `width` and `height` are the available content area for children, which + * here is the same as the cq basis since slides have no other inner box. */ -export function getSectionContentArea(section: HTMLElement): { - width: number; - height: number; -} { +export function snapshotSectionBox(section: HTMLElement): SectionBox { const styles = getComputedStyle(section); + const contentInline = section.clientWidth - + px(styles.paddingLeft) - + px(styles.paddingRight); + const contentBlock = section.clientHeight - + px(styles.paddingTop) - + px(styles.paddingBottom); return { - width: section.clientWidth - px(styles.paddingLeft) - px(styles.paddingRight), - height: section.clientHeight - px(styles.paddingTop) - px(styles.paddingBottom), + cqInline: contentInline, + cqBlock: contentBlock, + width: contentInline, + height: contentBlock, }; } + +/** + * Slack applied to the fit check. Pretext's wrap predictions use raw + * canvas-measured widths and don't simulate the browser's CSS + * `text-wrap: pretty`, which pulls ~1 line back from multi-line paragraphs. + * Without this slack, multi-paragraph slides over-predict and pick a tier + * tighter than necessary. 8% empirically matches Recursive at these sizes. + */ +const FIT_SLACK = 1.08; + +/** + * Pick the loosest density whose predicted content fits the section's + * content area. Returns the chosen density. + */ +export function pickDensity( + slide: HTMLElement, + section: HTMLElement, +): Density { + const box = snapshotSectionBox(section); + for (const tier of DENSITIES) { + const predicted = predictContentHeight(slide, tier, box); + if (predicted <= box.height * FIT_SLACK) return tier; + } + return "dense"; +}