@michaelyagi/shoji 0.1.0-alpha.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +81 -0
- package/dist/esm/core/EventBus.d.ts +16 -0
- package/dist/esm/core/FocusTrap.d.ts +9 -0
- package/dist/esm/core/Gallery.d.ts +221 -0
- package/dist/esm/core/GestureController.d.ts +50 -0
- package/dist/esm/core/LiveRegion.d.ts +6 -0
- package/dist/esm/core/SlideManager.d.ts +83 -0
- package/dist/esm/core/bodyScrollLock.d.ts +2 -0
- package/dist/esm/core/dom.d.ts +27 -0
- package/dist/esm/core/icons.d.ts +6 -0
- package/dist/esm/core/index.d.ts +5 -0
- package/dist/esm/core/index.js +1966 -0
- package/dist/esm/core/index.js.map +1 -0
- package/dist/esm/core/plugin.d.ts +54 -0
- package/dist/esm/core/rotateFlipNormalize.d.ts +19 -0
- package/dist/esm/core/scan.d.ts +8 -0
- package/dist/esm/core/types.d.ts +268 -0
- package/dist/esm/core/zoomTransition.d.ts +33 -0
- package/dist/esm/gestures/GestureEngine.d.ts +78 -0
- package/dist/esm/index.css +475 -0
- package/dist/esm/index.d.ts +32 -0
- package/dist/esm/index.js +24 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/index2.css +22 -0
- package/dist/esm/index3.css +166 -0
- package/dist/esm/index4.css +27 -0
- package/dist/esm/plugins/activeThumbnail/index.d.ts +26 -0
- package/dist/esm/plugins/activeThumbnail/index.js +48 -0
- package/dist/esm/plugins/activeThumbnail/index.js.map +1 -0
- package/dist/esm/plugins/autoplay/icons.d.ts +3 -0
- package/dist/esm/plugins/autoplay/index.d.ts +17 -0
- package/dist/esm/plugins/autoplay/index.js +177 -0
- package/dist/esm/plugins/autoplay/index.js.map +1 -0
- package/dist/esm/plugins/fullscreen/icons.d.ts +3 -0
- package/dist/esm/plugins/fullscreen/index.d.ts +17 -0
- package/dist/esm/plugins/fullscreen/index.js +74 -0
- package/dist/esm/plugins/fullscreen/index.js.map +1 -0
- package/dist/esm/plugins/layout/index.d.ts +191 -0
- package/dist/esm/plugins/layout/index.js +746 -0
- package/dist/esm/plugins/layout/index.js.map +1 -0
- package/dist/esm/plugins/layout/justified.d.ts +68 -0
- package/dist/esm/plugins/layout/masonry.d.ts +92 -0
- package/dist/esm/plugins/rotateFlip/icons.d.ts +5 -0
- package/dist/esm/plugins/rotateFlip/index.d.ts +17 -0
- package/dist/esm/plugins/rotateFlip/index.js +138 -0
- package/dist/esm/plugins/rotateFlip/index.js.map +1 -0
- package/dist/esm/plugins/video/index.d.ts +13 -0
- package/dist/esm/plugins/video/index.js +95 -0
- package/dist/esm/plugins/video/index.js.map +1 -0
- package/dist/esm/plugins/video/youtube.d.ts +61 -0
- package/dist/esm/plugins/zoom/icons.d.ts +4 -0
- package/dist/esm/plugins/zoom/index.d.ts +30 -0
- package/dist/esm/plugins/zoom/index.js +274 -0
- package/dist/esm/plugins/zoom/index.js.map +1 -0
- package/dist/esm/plugins/zoom/zoomMath.d.ts +24 -0
- package/dist/esm/transitions/SlideTransition.d.ts +25 -0
- package/dist/esm/transitions/presets.d.ts +21 -0
- package/dist/esm/zoomTransition-bbKHpVpA.js +110 -0
- package/dist/esm/zoomTransition-bbKHpVpA.js.map +1 -0
- package/dist/shoji.css +690 -0
- package/dist/shoji.js +3605 -0
- package/dist/shoji.js.map +1 -0
- package/dist/shoji.min.css +1 -0
- package/dist/shoji.min.js +2 -0
- package/dist/shoji.min.js.map +1 -0
- package/package.json +77 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sources":["../../../../src/plugins/layout/masonry.ts","../../../../src/plugins/layout/justified.ts","../../../../src/plugins/layout/index.ts"],"sourcesContent":["/**\n * DESIGN.md §5.2 — deterministic, measure-free layout: positions come purely\n * from each tile's aspect ratio, never from measuring a loaded image (that's\n * exactly what causes the layout-jump problem most masonry libraries have).\n * Pure functions, no DOM — testable in isolation from rendering.\n */\n\nexport interface MasonryTile {\n /** Natural aspect ratio only — any unit works as long as width/height share one. */\n width: number;\n height: number;\n}\n\nexport interface MasonryPosition {\n x: number;\n y: number;\n width: number;\n height: number;\n}\n\nexport interface MasonryOptions {\n containerWidth: number;\n /** Gap *between* columns (horizontal). */\n gutterX: number;\n /** Gap *between* tiles stacked within a column (vertical). */\n gutterY: number;\n columns: number | 'auto';\n columnWidth: number;\n minColumnWidth: number;\n maxColumnWidth: number;\n fill: 'shortest' | 'ordered';\n}\n\nexport interface MasonryResult {\n positions: MasonryPosition[];\n columnCount: number;\n columnWidth: number;\n /** Per-column running height, incl. trailing gutterY — feed back in as `startHeights` to continue packing (e.g. an appended page) without relaying out earlier tiles. */\n columnHeights: number[];\n containerHeight: number;\n}\n\n/**\n * `'auto'` column count is `containerWidth / columnWidth`, floored — at odd\n * container widths that division can drift the *effective* per-column width\n * well past `columnWidth` before the count itself changes (e.g. 601px at a\n * 200px target floors to 3 columns of ~200px, but 999px floors to 4 columns\n * of ~250px). `minColumnWidth`/`maxColumnWidth` cap how far that drift goes\n * before the column count itself adjusts, so columns never go stringy-narrow\n * or stretch too wide at awkward container sizes. Only `gutterX` (the axis\n * columns are laid out along) factors into this — `gutterY` never affects\n * column count/width.\n */\nexport function computeColumnCount(options: MasonryOptions): number {\n const { containerWidth, gutterX, columns } = options;\n if (typeof columns === 'number') return Math.max(1, Math.floor(columns));\n\n const target = Math.max(\n 1,\n Math.floor((containerWidth + gutterX) / (options.columnWidth + gutterX)),\n );\n const maxAllowedByMin = Math.max(\n 1,\n Math.floor((containerWidth + gutterX) / (options.minColumnWidth + gutterX)),\n );\n const minRequiredByMax = Math.max(\n 1,\n Math.ceil((containerWidth + gutterX) / (options.maxColumnWidth + gutterX)),\n );\n // minRequiredByMax can exceed maxAllowedByMin at a pathological config\n // (minColumnWidth > maxColumnWidth) — minRequiredByMax wins rather than\n // silently violating maxColumnWidth in that case.\n return Math.max(minRequiredByMax, Math.min(target, maxAllowedByMin));\n}\n\nexport function computeColumnWidth(\n containerWidth: number,\n gutterX: number,\n columnCount: number,\n): number {\n return (containerWidth - gutterX * (columnCount - 1)) / columnCount;\n}\n\n/**\n * Packs `tiles` into columns. `startHeights` (one entry per column, from a\n * previous `MasonryResult.columnHeights`) continues packing after already-\n * placed tiles instead of relaying out from scratch — this is what makes an\n * infinite-scroll append O(new items): earlier tiles' positions never change.\n */\nexport function layoutMasonry(\n tiles: readonly MasonryTile[],\n options: MasonryOptions,\n startHeights?: readonly number[],\n): MasonryResult {\n const columnCount = computeColumnCount(options);\n const colWidth = computeColumnWidth(options.containerWidth, options.gutterX, columnCount);\n const columnHeights =\n startHeights && startHeights.length === columnCount\n ? [...startHeights]\n : new Array<number>(columnCount).fill(0);\n\n const positions: MasonryPosition[] = tiles.map((tile, i) => {\n const colIndex = options.fill === 'ordered' ? i % columnCount : indexOfShortest(columnHeights);\n const tileHeight = tile.width > 0 ? colWidth * (tile.height / tile.width) : colWidth;\n const x = colIndex * (colWidth + options.gutterX);\n const y = columnHeights[colIndex]!;\n columnHeights[colIndex] = y + tileHeight + options.gutterY;\n return { x, y, width: colWidth, height: tileHeight };\n });\n\n const containerHeight = tiles.length === 0 ? 0 : Math.max(...columnHeights) - options.gutterY;\n return { positions, columnCount, columnWidth: colWidth, columnHeights, containerHeight };\n}\n\nfunction indexOfShortest(heights: readonly number[]): number {\n let shortest = 0;\n for (let i = 1; i < heights.length; i++) {\n if (heights[i]! < heights[shortest]!) shortest = i;\n }\n return shortest;\n}\n\nexport interface MasonryHorizontalOptions {\n containerWidth: number;\n /** Gap between tiles packed rightward within a row. */\n gutterX: number;\n /** Gap *between* rows (vertical). */\n gutterY: number;\n /** Every row's exact, fixed height — never scaled/solved-for (that's what distinguishes this from `justified`, whose row heights are the free variable it adjusts to make each row's width land exactly on `containerWidth`; here, height is the fixed input and width is left wherever a row's tiles naturally end). */\n rowHeight: number;\n}\n\nexport interface MasonryHorizontalResult {\n positions: MasonryPosition[];\n rowCount: number;\n containerHeight: number;\n}\n\n/**\n * DESIGN.md §5.1 — packs tiles left-to-right into rows of `rowHeight` (each\n * tile scaled to that exact height at its own true aspect ratio, never\n * stretched/cropped), wrapping to a new row the moment the next tile would\n * push the current one past `containerWidth` — never stretching the\n * finished row to close the remaining gap. The result: every row is\n * *exactly* `rowHeight` tall (the fixed constraint), and every row's right\n * edge lands wherever its tiles' natural widths summed to (the free,\n * \"ragged\" one) — the direct masonry-family transpose of vertical masonry,\n * whose *columns* share a fixed width and whose ragged edge is the bottom.\n * `justified` (`justified.ts`) is the inverse tradeoff: width is the fixed\n * constraint (every row forced flush to `containerWidth`) and height is the\n * free variable solved per row to make that work.\n *\n * A single tile whose natural width at `rowHeight` alone already exceeds\n * `containerWidth` (a wide panorama) is still placed on its own row rather\n * than shrunk — nothing narrower to fall back to without violating the\n * fixed-height constraint, the same tradeoff `justified`'s `minRowHeight`\n * makes in its own analogous single-tile edge case.\n */\nexport function layoutMasonryHorizontal(\n tiles: readonly MasonryTile[],\n options: MasonryHorizontalOptions,\n): MasonryHorizontalResult {\n const { containerWidth, gutterX, gutterY, rowHeight } = options;\n const positions: MasonryPosition[] = new Array(tiles.length);\n let currentY = 0;\n let rowStart = 0;\n let rowWidth = 0; // cumulative width of tiles committed to the current row so far, gutters included\n let rowCount = 0;\n\n function tileWidth(tile: MasonryTile): number {\n return tile.height > 0 ? rowHeight * (tile.width / tile.height) : rowHeight;\n }\n\n function placeRow(start: number, endExclusive: number): void {\n let x = 0;\n for (let i = start; i < endExclusive; i++) {\n const w = tileWidth(tiles[i]!);\n positions[i] = { x, y: currentY, width: w, height: rowHeight };\n x += w + gutterX;\n }\n rowCount++;\n currentY += rowHeight + gutterY;\n }\n\n for (let i = 0; i < tiles.length; i++) {\n const w = tileWidth(tiles[i]!);\n const countInRow = i - rowStart;\n const widthIfIncluded = rowWidth + (countInRow > 0 ? gutterX : 0) + w;\n if (countInRow > 0 && widthIfIncluded > containerWidth) {\n placeRow(rowStart, i); // finalize the row without tile i — it starts the next one instead\n rowStart = i;\n rowWidth = w;\n } else {\n rowWidth = widthIfIncluded;\n }\n }\n if (rowStart < tiles.length) placeRow(rowStart, tiles.length);\n\n const containerHeight = tiles.length === 0 ? 0 : currentY - gutterY;\n return { positions, rowCount, containerHeight };\n}\n","/**\n * DESIGN.md §5.2 — Flickr/Google-Photos-style row filler: tiles are grouped\n * into rows, each row scaled as a whole (every tile in it keeps its own\n * aspect ratio, only the shared row height changes) until the row's total\n * width exactly matches the container — the opposite visual signature from\n * masonry: row *right edges* line up flush, row *heights* vary instead of\n * column bottoms varying. Pure, no DOM — same shape/testing pattern as\n * masonry.ts.\n */\n\nexport interface JustifiedTile {\n width: number;\n height: number;\n}\n\nexport interface JustifiedPosition {\n x: number;\n y: number;\n width: number;\n height: number;\n}\n\nexport interface JustifiedOptions {\n containerWidth: number;\n /** Gap between tiles within a row (horizontal). */\n gutterX: number;\n /** Gap *between* rows (vertical). */\n gutterY: number;\n /** Target row height — rows are greedily filled until adding the next tile would exceed containerWidth at this height, then the whole row is rescaled to fit exactly (so actual row heights end up *near*, not always exactly at, this value). */\n rowHeight: number;\n /**\n * Bounds on a row's *scaled* (fill-the-container) height — an unusual mix\n * of aspect ratios (very few tiles, or very narrow/wide ones, e.g. a lone\n * portrait tile stretched to fill the container width under\n * `lastRow: 'justify'`) can otherwise scale a row to an extreme height.\n * `maxRowHeight` clamping means that row no longer exactly fills\n * `containerWidth` at its capped height — it falls short of the right\n * edge instead, the same tradeoff masonry's `maxColumnWidth` already\n * makes for the analogous reason. `minRowHeight` never causes that kind\n * of overflow, though, unlike a naive clamp would: a row that would need\n * to go below `minRowHeight` to fit exactly instead sheds tiles off its\n * end (rolled into the next row) until what's left *does* fit at\n * `minRowHeight` — rows never extend past `containerWidth`. The one\n * unavoidable exception is a single tile so wide/panoramic that even\n * alone it can't satisfy both bounds at once (`minRowHeight` at its own\n * aspect ratio is inherently wider than the container) — there,\n * `minRowHeight` itself yields rather than the row overflowing. Neither\n * bound applies to `lastRow: 'left'`, which already uses `rowHeight` as\n * a fixed, unscaled height rather than deriving one (and, being the\n * left-over *under*-full case by definition, never overflows either).\n */\n minRowHeight: number;\n maxRowHeight: number;\n /** How to handle the trailing row when there aren't enough tiles left to naturally reach containerWidth: 'justify' stretches it like any other row (Flickr-style — the default); 'left' keeps it at natural rowHeight-scaled size, left-aligned, empty space on the right; 'hide' excludes it from `positions` (returned as `null`) entirely. */\n lastRow: 'justify' | 'left' | 'hide';\n}\n\nexport interface JustifiedRow {\n /** Tile index range finalized into this row, `[start, end)`. */\n start: number;\n end: number;\n y: number;\n height: number;\n}\n\nexport interface JustifiedResult {\n /** One entry per input tile, same order — `null` only for a `lastRow: 'hide'`-excluded trailing tile. */\n positions: Array<JustifiedPosition | null>;\n containerHeight: number;\n /** One entry per finalized row, in order — lets a caller find which tiles landed on the same row (e.g. to decide where a compact section label belongs) without re-deriving it from `positions[i].y` equality. */\n rows: JustifiedRow[];\n}\n\nfunction aspectRatioOf(tile: JustifiedTile): number {\n return tile.width > 0 && tile.height > 0 ? tile.width / tile.height : 1;\n}\n\nexport function layoutJustified(\n tiles: readonly JustifiedTile[],\n options: JustifiedOptions,\n): JustifiedResult {\n const { containerWidth, gutterX, gutterY, rowHeight, minRowHeight, maxRowHeight, lastRow } =\n options;\n const positions: Array<JustifiedPosition | null> = new Array(tiles.length).fill(null);\n const rows: JustifiedRow[] = [];\n let currentY = 0;\n let rowStart = 0;\n let aspectSum = 0;\n\n function naturalHeightOf(startIndex: number, endExclusive: number, sum: number): number {\n const availableWidth = containerWidth - gutterX * (endExclusive - startIndex - 1);\n return availableWidth / sum;\n }\n\n function placeRow(startIndex: number, endExclusive: number, height: number): void {\n let x = 0;\n for (let i = startIndex; i < endExclusive; i++) {\n const width = height * aspectRatioOf(tiles[i]!);\n positions[i] = { x, y: currentY, width, height };\n x += width + gutterX;\n }\n rows.push({ start: startIndex, end: endExclusive, y: currentY, height });\n currentY += height + gutterY;\n }\n\n /**\n * Resolves `[startIndex, endExclusive)` (whose combined aspect ratio is\n * `sum`) into its final row, shedding tiles off the end — rolled into\n * whatever comes after — if honoring `minRowHeight` would otherwise push\n * this row's width past `containerWidth`. Returns the `endExclusive`\n * actually used, so the caller knows how many tiles (if any) got rolled\n * over and need to start the next row instead.\n */\n function resolveScaledRow(startIndex: number, endExclusive: number, sum: number): number {\n let end = endExclusive;\n let rowSum = sum;\n while (end - startIndex > 1 && naturalHeightOf(startIndex, end, rowSum) < minRowHeight) {\n end--;\n rowSum -= aspectRatioOf(tiles[end]!);\n }\n const natural = naturalHeightOf(startIndex, end, rowSum);\n // Down to one tile and *still* under minRowHeight: that single tile's\n // own aspect ratio makes minRowHeight-at-full-width inherently wider\n // than the container — nothing left to shed, so minRowHeight yields\n // (the natural height keeps width exactly at containerWidth) rather\n // than ever letting the row overflow.\n const height =\n end - startIndex === 1 && natural < minRowHeight\n ? natural\n : Math.min(Math.max(natural, minRowHeight), maxRowHeight);\n placeRow(startIndex, end, height);\n return end;\n }\n\n for (let i = 0; i < tiles.length; i++) {\n aspectSum += aspectRatioOf(tiles[i]!);\n const count = i - rowStart + 1;\n const widthAtTargetHeight = rowHeight * aspectSum + gutterX * (count - 1);\n if (widthAtTargetHeight >= containerWidth) {\n const end = resolveScaledRow(rowStart, i + 1, aspectSum);\n rowStart = end;\n aspectSum = 0;\n for (let j = end; j <= i; j++) aspectSum += aspectRatioOf(tiles[j]!); // rolled-over tiles seed the next row\n }\n }\n\n if (rowStart < tiles.length) {\n if (lastRow === 'justify') {\n // resolveScaledRow can itself shed tiles (the same overflow-avoidance\n // as the main loop above) — unlike there, there's no \"next row\" this\n // far down for the outer loop to naturally roll them into, so keep\n // resolving whatever's left until every trailing tile lands\n // somewhere. Always makes forward progress (resolveScaledRow places\n // at least one tile per call), so this always terminates.\n let start = rowStart;\n while (start < tiles.length) {\n let sum = 0;\n for (let j = start; j < tiles.length; j++) sum += aspectRatioOf(tiles[j]!);\n start = resolveScaledRow(start, tiles.length, sum);\n }\n } else if (lastRow === 'left') {\n placeRow(rowStart, tiles.length, rowHeight);\n }\n // 'hide': positions stay null for this trailing range, currentY doesn't advance\n }\n\n const containerHeight = tiles.length === 0 ? 0 : Math.max(0, currentY - gutterY);\n return { positions, containerHeight, rows };\n}\n","import type { PluginContext, ShojiPlugin } from '../../core/plugin';\nimport type { GalleryItem } from '../../core/types';\nimport { layoutMasonry, layoutMasonryHorizontal, type MasonryTile } from './masonry';\nimport { layoutJustified, type JustifiedOptions, type JustifiedResult } from './justified';\nimport './layout.css';\n\n/**\n * DESIGN.md §5 — `type: 'grid' | 'masonry' | 'justified'`; masonry supports\n * both `orientation`s, `breakpoints` (§5.4), and `groupBy`/headings (§5.2;\n * see that option's own doc comment for the v1 scope cuts vs. the full\n * spec, including why `groupBy` itself is excluded from `orientation:\n * 'horizontal'`). Deliberately still\n * deferred: jump-free *prepend* coordination (§5.2's scroll-anchoring\n * handoff needs the not-yet-built scroll plugin on the other end —\n * prepending today triggers a full relayout, which can shift scroll\n * position if content above the fold grew).\n */\nexport interface LayoutOptions {\n /** Default `'justified'`. */\n type?: 'grid' | 'masonry' | 'justified';\n gutter?: number | { x: number; y: number };\n /**\n * `type: 'grid'` only — every cell's shape, cropped to fit via\n * `object-fit: cover`; default 1 (square). Deliberately *not* each\n * photo's own aspect ratio: plain CSS grid sizes a row's height to its\n * tallest cell, so per-item ratios leave a visible blank gap under every\n * shorter tile sharing a row with a taller one. Masonry/justified are\n * the two types that preserve true per-photo aspect ratios instead (via\n * their own JS packing, which doesn't have this row-height problem).\n */\n aspectRatio?: number;\n /**\n * `type: 'masonry'` only. `'vertical'` (default): fixed-width columns,\n * tiles stack top-to-bottom, ragged *bottom* edge. `'horizontal'`: fixed-\n * height rows, tiles pack left-to-right, ragged *right* edge — the direct\n * transpose. Both wrap/grow within the container and scroll vertically\n * only, same as every other layout type; neither ever needs horizontal\n * scroll. See `rowHeight` for `'horizontal'`'s one fixed input, and\n * `justified` (a different `type` entirely) for the *opposite* tradeoff —\n * width forced flush, height left to solve for.\n */\n orientation?: 'vertical' | 'horizontal';\n columns?: number | 'auto';\n columnWidth?: number;\n minColumnWidth?: number;\n maxColumnWidth?: number;\n fill?: 'shortest' | 'ordered';\n /** `type: 'justified'`, or `type: 'masonry', orientation: 'horizontal'`. For horizontal masonry this is the row's exact, fixed height (see `orientation`'s doc comment) — `minRowHeight`/`maxRowHeight` don't apply there, since nothing is solved/clamped, only for justified, where it's a *target* a solved-for height converges near. */\n rowHeight?: number;\n minRowHeight?: number;\n maxRowHeight?: number;\n lastRow?: 'justify' | 'left' | 'hide';\n animate?: boolean;\n /**\n * Container-width → partial overrides, applied on top of the base options\n * above whenever the container's current width is <= that key (the\n * *narrowest* matching key wins if more than one applies). Re-evaluated on\n * every relayout, so crossing a threshold while resizing takes effect\n * live. Cannot override `type` or `orientation` — swapping the DOM\n * structure/positioning strategy live is a bigger feature than a sizing\n * tweak; use a media query + two `Shoji` instances if that's genuinely\n * needed.\n */\n breakpoints?: Record<number, Omit<LayoutOptions, 'type' | 'orientation' | 'breakpoints'>>;\n /**\n * Derives a section key per item (e.g. a formatted date) — a heading is\n * inserted whenever the key changes between consecutive items, real\n * \"Today / Yesterday / March 2024\" (Google Photos-style) sectioning.\n * Items must already be grouped consecutively; a key reappearing later\n * after a different key started a new section anyway.\n *\n * Section boundaries behave differently per `type`: `grid`'s heading is a\n * full-span (`grid-column: 1 / -1`) element, native CSS grid gives it its\n * own row for free. `masonry`'s heading is a full-width blocking element\n * too — each section is its own fresh column-packing pass, no two\n * sections ever share visual space. `justified` is the odd one out, by\n * design (this is the actual Google Photos behavior, not a simplified\n * approximation of it): sections do **not** force a row break. Row\n * *composition* ignores section boundaries entirely — one continuous\n * `layoutJustified` pass packs every item regardless of section, so a\n * section with too few items to reach a full row's target width (a\n * single lone photo, say) shares that row with whatever comes right\n * after it, instead of getting stretched into its own awkward\n * almost-empty row. A heading only marks *where* a new section starts:\n * a compact, inline label sized to its own content (not full width),\n * positioned at the x-coordinate of the tile that starts its section,\n * in a slim label-band above whichever row that tile landed on — more\n * than one label can share the same band if more than one section\n * happens to start within the same row (see `renderHeading`'s\n * `{title, subtitle}` form for exactly this — that's what the reference\n * behavior actually looks like).\n *\n * v1 scope cuts vs. DESIGN.md §5.2's full spec: (1) any items change\n * while `groupBy` is set does a full relayout, same simplification\n * justified's non-grouped path already makes for appends (§5.4) — the\n * \"merge across load boundaries\" incremental optimization is deferred;\n * (2) headings are always `<h2>`, not a configurable level; (3) not\n * supported with `orientation: 'horizontal'` (warns, ignored) —\n * DESIGN.md itself calls that pairing rare.\n */\n groupBy?: (item: GalleryItem) => string;\n /**\n * Customizes a heading's content; default renders the key as plain text.\n * Three return shapes:\n * - `string` — plain text (default behavior, just with custom content).\n * - `HTMLElement` — full control, own tag/children; the plugin adds its\n * own class/positioning on top of whatever's returned, nothing else.\n * - `{ title: string; subtitle?: string }` — the built-in structured\n * form (bold title + a smaller, muted `subtitle`, e.g. a date + a\n * location) — the only form the plugin can apply automatic overflow\n * handling to (see `headingOverflow`), since it owns the DOM structure\n * (a raw `HTMLElement` is opaque to it, no safe way to guess what's\n * droppable).\n */\n renderHeading?: (\n key: string,\n items: GalleryItem[],\n ) => string | HTMLElement | { title: string; subtitle?: string };\n /**\n * `type: 'justified'` + the `{ title, subtitle }` structured heading\n * form only — controls what happens when a compact label doesn't fit\n * its available width (the gap until the next section's label sharing\n * its row, or the container edge). Ignored otherwise: `grid`/`masonry`\n * headings are full-width and this kind of squeeze can't happen there;\n * a raw `renderHeading`-returned `HTMLElement`/`string` is left exactly\n * as returned regardless, since the plugin has no safe way to guess\n * what's droppable in an opaque element.\n * - `'show'` (default) — always render the full title + subtitle, one\n * line, however wide; a label can visually run past the next\n * section's label or the container edge if the content is long\n * enough. The simple, predictable default — nothing gets silently\n * dropped or reflowed.\n * - `'fit'` — never wraps a label's text. First the subtitle is dropped\n * if title+subtitle together don't fit; if the title *alone* still\n * doesn't fit next to whatever's sharing its row, that section is\n * pushed to start a fresh row instead, giving its label the room it\n * needs — row *composition* responds to label width, not just label\n * content. Real Google Photos behavior; opt in when labels regularly\n * need to share tight row space. The one unavoidable exception is a\n * single label wider than the entire container even alone on its own\n * row — there, nothing left to break onto, so the label is\n * ellipsis-truncated (still one line, never wrapped, never\n * overflowing).\n * - `'wrap'` — the in-between: same continuous row-packing as `'show'`\n * (row *composition* never reacts to label width, unlike `'fit'` — no\n * section is ever pushed to a fresh row), but a label that doesn't fit\n * its available width (same gap `'fit'` measures) wraps its own text\n * onto additional lines instead of running past it. Height is capped\n * at `maxRowHeight` (or its default, 360) — a label tall enough to hit\n * that cap ellipsis-truncates its last visible line rather than\n * growing the row further. Real Google Photos behavior for its date\n * headers; pick this over `'fit'` when you'd rather a label take an\n * extra line than shove a whole section onto its own row.\n */\n headingOverflow?: 'show' | 'fit' | 'wrap';\n /**\n * Pins the active section's heading via `position: sticky`. Only\n * implemented for `type: 'grid'`, where headings sit in normal document\n * flow — masonry/justified headings are absolutely positioned by JS (like\n * their tiles), and `position: sticky` doesn't do anything meaningful\n * layered onto an already explicitly-positioned element. Warns and is\n * ignored for those two types.\n */\n stickyHeadings?: boolean;\n /**\n * When an item is missing `width`/`height`, measure its tile's own `<img>`\n * once it loads (`naturalWidth`/`naturalHeight`) and correct that tile's\n * aspect ratio in place, rather than leaving it at the permanent 4:3\n * placeholder fallback. Default `true`.\n *\n * The *first* layout pass never waits on this — it always uses the 4:3\n * placeholder immediately, same as before this option existed, so opening\n * the gallery is never blocked on image loads (DESIGN.md §5.2's\n * never-measure-mid-layout-pass rule still holds for that first pass).\n * This only ever *corrects* a placeholder after the fact, once a real\n * measurement lands — the same \"async correction after an initial\n * synchronous pass\" pattern already used for a `containerWidth <= 0`\n * container (§5.4) and the zoom transition's origin-rect race (§2.3b),\n * extended to per-item dimensions. Multiple corrections landing close\n * together (e.g. several cached images resolving in the same tick) are\n * coalesced into one relayout via `requestAnimationFrame`, not one\n * relayout per image.\n *\n * Effectively does automatically, for `thumb`/`poster`/`src`, what a host\n * would otherwise have to do by hand before constructing the gallery\n * (`new Image(); img.onload = () => ...`) — set `false` to keep the old\n * behavior (permanent 4:3 fallback, no self-correction) if you'd rather\n * guarantee zero extra relayouts under any circumstance.\n */\n autoMeasure?: boolean;\n}\n\ninterface ResolvedConfig {\n gutter: { x: number; y: number };\n columns: number | 'auto';\n columnWidth: number;\n minColumnWidth: number;\n maxColumnWidth: number;\n fill: 'shortest' | 'ordered';\n rowHeight: number;\n minRowHeight: number;\n maxRowHeight: number;\n lastRow: 'justify' | 'left' | 'hide';\n}\n\nconst DEFAULT_ASPECT: MasonryTile = { width: 4, height: 3 };\n\ninterface Tile {\n index: number;\n root: HTMLAnchorElement;\n /** The tile's own thumbnail `<img>` — reused by `autoMeasure` to read `naturalWidth`/`naturalHeight` once it loads, rather than fetching the same image a second time with a separate `Image()`. */\n img: HTMLImageElement;\n}\n\ninterface HeadingEntry {\n root: HTMLElement;\n /** index into `tiles[]` (== item index) where this section's tiles start. */\n tileStart: number;\n /** Present only for the built-in `{ title, subtitle }` form — the two elements `applyJustified`'s overflow handling is allowed to hide/wrap, since the plugin itself built them (a raw `renderHeading`-returned `HTMLElement` is opaque; nothing is touched there). */\n structured: { titleEl: HTMLElement; subtitleEl: HTMLElement | null } | null;\n}\n\n/** `gutter: 8` means `{x: 8, y: 8}` — a single number applies to both axes; `{x, y}` sets them independently. */\nfunction resolveGutter(raw: unknown): { x: number; y: number } {\n if (raw && typeof raw === 'object') {\n const obj = raw as { x?: number; y?: number };\n return { x: Number(obj.x ?? 8), y: Number(obj.y ?? 8) };\n }\n const n = Number(raw ?? 8);\n return { x: n, y: n };\n}\n\nfunction keyOf(item: GalleryItem): string {\n return item.id ?? item.src;\n}\n\n/** `newItems` is a superset of `oldItems` sharing an identical prefix — i.e. purely appended, nothing reordered/removed/prepended. */\nfunction isPureAppend(oldItems: readonly GalleryItem[], newItems: readonly GalleryItem[]): boolean {\n if (newItems.length <= oldItems.length) return false;\n for (let i = 0; i < oldItems.length; i++) {\n if (keyOf(oldItems[i]!) !== keyOf(newItems[i]!)) return false;\n }\n return true;\n}\n\nexport const Layout: ShojiPlugin = {\n name: 'layout',\n defaults: {\n type: 'justified',\n gutter: 8,\n columns: 'auto',\n columnWidth: 240,\n minColumnWidth: 160,\n maxColumnWidth: 480,\n fill: 'shortest',\n animate: true,\n autoMeasure: true,\n },\n\n init(ctx: PluginContext): () => void {\n const { gallery } = ctx;\n const type = (ctx.options.type as 'grid' | 'masonry' | 'justified' | undefined) ?? 'justified';\n const gutter = resolveGutter(ctx.options.gutter);\n const aspectRatio = Number(ctx.options.aspectRatio ?? 1);\n const orientation =\n (ctx.options.orientation as 'vertical' | 'horizontal' | undefined) ?? 'vertical';\n const columns = (ctx.options.columns as number | 'auto' | undefined) ?? 'auto';\n const columnWidth = Number(ctx.options.columnWidth ?? 240);\n const minColumnWidth = Number(ctx.options.minColumnWidth ?? 160);\n const maxColumnWidth = Number(ctx.options.maxColumnWidth ?? 480);\n const fill = (ctx.options.fill as 'shortest' | 'ordered' | undefined) ?? 'shortest';\n const rowHeight = Number(ctx.options.rowHeight ?? 220);\n const minRowHeight = Number(ctx.options.minRowHeight ?? 120);\n const maxRowHeight = Number(ctx.options.maxRowHeight ?? 360);\n const lastRow = (ctx.options.lastRow as 'justify' | 'left' | 'hide' | undefined) ?? 'justify';\n const animate = ctx.options.animate !== false;\n const autoMeasureEnabled = ctx.options.autoMeasure !== false;\n const horizontal = type === 'masonry' && orientation === 'horizontal';\n const groupByFn = ctx.options.groupBy as ((item: GalleryItem) => string) | undefined;\n const renderHeadingFn = ctx.options.renderHeading as\n | ((\n key: string,\n items: GalleryItem[],\n ) => string | HTMLElement | { title: string; subtitle?: string })\n | undefined;\n const headingOverflow =\n (ctx.options.headingOverflow as 'show' | 'fit' | 'wrap' | undefined) ?? 'show';\n const stickyHeadingsRaw = ctx.options.stickyHeadings === true;\n const stickyHeadings = stickyHeadingsRaw && type === 'grid';\n if (stickyHeadingsRaw && type !== 'grid') {\n console.warn(\n \"Shoji: layout plugin — stickyHeadings is only implemented for type: 'grid'; ignored (see the option's doc comment).\",\n );\n }\n if (groupByFn && horizontal) {\n console.warn(\n \"Shoji: layout plugin — groupBy isn't supported with orientation: 'horizontal'; ignored.\",\n );\n }\n const groupingEnabled = !!groupByFn && !horizontal;\n\n const baseConfig: ResolvedConfig = {\n gutter,\n columns,\n columnWidth,\n minColumnWidth,\n maxColumnWidth,\n fill,\n rowHeight,\n minRowHeight,\n maxRowHeight,\n lastRow,\n };\n\n const breakpointsRaw = ctx.options.breakpoints as\n Record<number, Partial<LayoutOptions>> | undefined;\n const sortedBreakpoints = breakpointsRaw\n ? Object.entries(breakpointsRaw)\n .map(([key, overrides]) => [Number(key), overrides] as const)\n .sort((a, b) => a[0] - b[0])\n : [];\n\n /** Re-run on every relayout — the narrowest breakpoint whose key is still >= the current width wins; falls back to `baseConfig` if none match or no breakpoints were given. */\n function resolveConfig(width: number): ResolvedConfig {\n const match = sortedBreakpoints.find(([maxWidth]) => width <= maxWidth);\n if (!match) return baseConfig;\n const overrides = match[1];\n return {\n gutter:\n overrides.gutter !== undefined ? resolveGutter(overrides.gutter) : baseConfig.gutter,\n columns: overrides.columns ?? baseConfig.columns,\n columnWidth: overrides.columnWidth ?? baseConfig.columnWidth,\n minColumnWidth: overrides.minColumnWidth ?? baseConfig.minColumnWidth,\n maxColumnWidth: overrides.maxColumnWidth ?? baseConfig.maxColumnWidth,\n fill: overrides.fill ?? baseConfig.fill,\n rowHeight: overrides.rowHeight ?? baseConfig.rowHeight,\n minRowHeight: overrides.minRowHeight ?? baseConfig.minRowHeight,\n maxRowHeight: overrides.maxRowHeight ?? baseConfig.maxRowHeight,\n lastRow: overrides.lastRow ?? baseConfig.lastRow,\n };\n }\n\n const container = gallery.element;\n container.classList.add('shoji-layout', `shoji-layout--${type}`);\n if (animate) container.classList.add('shoji-layout--animate');\n\n let tiles: Tile[] = [];\n let headings: HeadingEntry[] = [];\n let columnHeights: number[] | undefined;\n let previousItems: readonly GalleryItem[] = [];\n let warnedMissingDims = false;\n\n /** [startIndex, endExclusive) into `items`/`tiles` per section, split wherever `groupByFn`'s key changes. */\n function computeSections(\n items: readonly GalleryItem[],\n ): Array<{ key: string; startIndex: number; endIndex: number }> {\n const sections: Array<{ key: string; startIndex: number; endIndex: number }> = [];\n for (let i = 0; i < items.length; i++) {\n const key = groupByFn!(items[i]!);\n const current = sections[sections.length - 1];\n if (!current || current.key !== key) {\n sections.push({ key, startIndex: i, endIndex: i + 1 });\n } else {\n current.endIndex = i + 1;\n }\n }\n return sections;\n }\n\n function createHeadingRoot(\n key: string,\n sectionItems: GalleryItem[],\n ): { root: HTMLElement; structured: HeadingEntry['structured'] } {\n const rendered = renderHeadingFn ? renderHeadingFn(key, sectionItems) : key;\n let root: HTMLElement;\n let structured: HeadingEntry['structured'] = null;\n if (typeof rendered === 'string') {\n root = document.createElement('h2');\n root.textContent = rendered;\n } else if (rendered instanceof HTMLElement) {\n root = rendered;\n } else {\n root = document.createElement('h2');\n const titleEl = document.createElement('span');\n titleEl.className = 'shoji-layout-heading-title';\n titleEl.textContent = rendered.title;\n root.appendChild(titleEl);\n let subtitleEl: HTMLElement | null = null;\n if (rendered.subtitle) {\n subtitleEl = document.createElement('span');\n subtitleEl.className = 'shoji-layout-heading-subtitle';\n subtitleEl.textContent = rendered.subtitle;\n root.appendChild(subtitleEl);\n }\n structured = { titleEl, subtitleEl };\n }\n root.classList.add('shoji-layout-heading');\n if (stickyHeadings) root.classList.add('shoji-layout-heading--sticky');\n return { root, structured };\n }\n\n /** Segment boundaries derived from `headings` — `end` is the next heading's `tileStart`, or `tiles.length` for the last one. */\n function headingSegments(): Array<{ heading: HeadingEntry; start: number; end: number }> {\n return headings.map((heading, i) => ({\n heading,\n start: heading.tileStart,\n end: i + 1 < headings.length ? headings[i + 1]!.tileStart : tiles.length,\n }));\n }\n\n /** `undefined` for a video item with neither `thumb` nor `poster` — its tile `<img>` ends up pointed at the unplayable video file itself, which can never load as an image, so there's nothing `autoMeasure` could learn from it either. */\n function measurableSrc(item: GalleryItem): string | undefined {\n return item.thumb ?? item.poster ?? (item.video ? undefined : item.src);\n }\n\n function aspectOf(item: GalleryItem | undefined): MasonryTile {\n if (item?.width && item.height) return { width: item.width, height: item.height };\n const selfCorrecting = autoMeasureEnabled && !!item && !!measurableSrc(item);\n if (!warnedMissingDims && !selfCorrecting) {\n warnedMissingDims = true;\n console.warn(\n autoMeasureEnabled\n ? 'Shoji: layout plugin — item(s) missing width/height with no measurable thumb/poster/src to auto-measure from either; falling back to a 4:3 placeholder ratio permanently for those.'\n : 'Shoji: layout plugin — item(s) missing width/height and autoMeasure is disabled. Masonry/justified positions are computed from aspect ratios up front, never by measuring loaded images (DESIGN.md §5.2 — that causes layout-jump), so items without dimensions fall back to a 4:3 placeholder ratio instead.',\n );\n }\n return DEFAULT_ASPECT;\n }\n\n function createTile(item: GalleryItem, index: number): Tile {\n const root = document.createElement('a');\n root.className = 'shoji-layout-tile';\n if (item.id) root.dataset.shojiId = item.id;\n // Always set, unlike shojiId above (item.id is optional) — the\n // reliable element→item lookup for a `layoutRender` listener that\n // only has the element itself later (e.g. a click handler on content\n // it injected into the tile), not this render pass's own event\n // payload. `gallery.items[Number(el.dataset.shojiIndex)]` always works.\n root.dataset.shojiIndex = String(index);\n\n // item.src is the video *file* for a video item (unplayable as a tile\n // thumbnail) — item.poster is the actual image to show there, checked\n // before falling back to item.src, which is only ever correct for a\n // plain image item.\n const img = document.createElement('img');\n img.src = item.thumb ?? item.poster ?? item.src;\n img.loading = 'lazy';\n img.alt = item.alt ?? '';\n root.appendChild(img);\n\n const tile: Tile = { index, root, img };\n root.addEventListener('click', (event) => {\n event.preventDefault();\n gallery.open(tile.index);\n });\n return tile;\n }\n\n function applyGrid(target: readonly Tile[]): void {\n // Grid otherwise never reads its own size (native CSS grid reflows\n // itself) — only breakpoint resolution needs it read here.\n const containerWidth = container.getBoundingClientRect().width;\n const config = resolveConfig(containerWidth);\n container.style.setProperty('--shoji-layout-column-width', `${config.columnWidth}px`);\n // CSS gap shorthand is \"row-gap column-gap\" — y before x.\n container.style.setProperty(\n '--shoji-layout-gutter',\n `${config.gutter.y}px ${config.gutter.x}px`,\n );\n for (const tile of target) {\n tile.root.style.setProperty('--shoji-layout-tile-aspect', `${aspectRatio}`);\n }\n }\n\n /** `appendOnly`: reposition just these (new) tiles, continuing from the saved `columnHeights`; omit to relayout every tile from scratch (resize, or a non-append items change). Never called with `appendOnly` set when grouping is active — grouped changes always route through `fullRender` (see the `groupBy` option's doc comment). */\n function applyMasonry(appendOnly: readonly Tile[] | null): void {\n const containerWidth = container.getBoundingClientRect().width;\n if (containerWidth <= 0) return; // not painted yet (e.g. display:none) — the resize observer's initial callback will retry once it is\n\n const config = resolveConfig(containerWidth);\n const masonryOptions = {\n containerWidth,\n gutterX: config.gutter.x,\n gutterY: config.gutter.y,\n columns: config.columns,\n columnWidth: config.columnWidth,\n minColumnWidth: config.minColumnWidth,\n maxColumnWidth: config.maxColumnWidth,\n fill: config.fill,\n };\n\n if (groupingEnabled && headings.length > 0) {\n const segments = headingSegments();\n // Batch-read every heading's height up front, before writing any\n // tile styles below — interleaving reads (which force a layout\n // flush) with the transform writes in the same pass measurably\n // delayed the very first paint under animate: true (each flush\n // gives the browser a \"before\" style to transition away from that\n // it otherwise wouldn't have for freshly-inserted elements).\n const headingHeights = segments.map((s) => s.heading.root.getBoundingClientRect().height);\n\n let y = 0;\n segments.forEach(({ heading, start, end }, i) => {\n const segTiles = tiles.slice(start, end);\n heading.root.style.transform = `translate(0px, ${y}px)`;\n y += headingHeights[i]! + config.gutter.y;\n const segResult = layoutMasonry(\n segTiles.map((t) => aspectOf(gallery.items[t.index])),\n masonryOptions,\n );\n segTiles.forEach((tile, j) => {\n const pos = segResult.positions[j]!;\n tile.root.style.width = `${pos.width}px`;\n tile.root.style.height = `${pos.height}px`;\n tile.root.style.visibility = 'visible';\n tile.root.style.transform = `translate(${pos.x}px, ${y + pos.y}px)`;\n });\n y += segResult.containerHeight + config.gutter.y;\n });\n columnHeights = undefined; // no cross-segment continuation once grouped\n container.style.height = `${Math.max(0, y - config.gutter.y)}px`;\n return;\n }\n\n const target = appendOnly ?? tiles;\n const masonryTiles = target.map((t) => aspectOf(gallery.items[t.index]));\n const startHeights = appendOnly ? columnHeights : undefined;\n const result = layoutMasonry(masonryTiles, masonryOptions, startHeights);\n\n target.forEach((tile, i) => {\n const pos = result.positions[i]!;\n tile.root.style.width = `${pos.width}px`;\n tile.root.style.height = `${pos.height}px`;\n tile.root.style.visibility = 'visible';\n tile.root.style.transform = `translate(${pos.x}px, ${pos.y}px)`;\n });\n\n columnHeights = result.columnHeights;\n container.style.height = `${result.containerHeight}px`;\n }\n\n /**\n * The masonry-family transpose of applyMasonry: fixed-height rows pack\n * tiles left-to-right, wrapping to a new row rather than stretching —\n * see layoutMasonryHorizontal's doc comment (masonry.ts) for the full\n * algorithm. Like applyJustified, this always relays out every tile\n * from scratch (no `appendOnly`/incremental-continuation param) — a\n * row isn't \"committed\" until enough tiles arrive to overflow it, so an\n * appended tile can extend what was the previous render's trailing row,\n * exactly the same reason applyJustified can't do incremental append.\n */\n function applyMasonryHorizontal(): void {\n const containerWidth = container.getBoundingClientRect().width;\n if (containerWidth <= 0) return; // not painted yet (e.g. display:none) — the resize observer's initial callback will retry once it is\n\n const config = resolveConfig(containerWidth);\n const masonryTiles = tiles.map((t) => aspectOf(gallery.items[t.index]));\n const result = layoutMasonryHorizontal(masonryTiles, {\n containerWidth,\n gutterX: config.gutter.x,\n gutterY: config.gutter.y,\n rowHeight: config.rowHeight,\n });\n\n tiles.forEach((tile, i) => {\n const pos = result.positions[i]!;\n tile.root.style.width = `${pos.width}px`;\n tile.root.style.height = `${pos.height}px`;\n tile.root.style.visibility = 'visible';\n tile.root.style.transform = `translate(${pos.x}px, ${pos.y}px)`;\n });\n\n container.style.height = `${result.containerHeight}px`;\n }\n\n /**\n * The `groupBy` + `justified` combination — see that option's doc\n * comment for the full behavioral spec (sections don't force a row\n * break by default; a heading is a compact inline label, not a\n * full-width blocking element).\n *\n * `headingOverflow: 'show'` (default) and `'wrap'`: one continuous\n * `layoutJustified` pass packs every tile regardless of section — the\n * simple case, a single \"segment\" covering everything (computeSegments()\n * below only ever splits into multiple segments for `'fit'`). `'wrap'`\n * additionally constrains each label's own box afterward (see\n * applyWrapDecisions) — that's a content-level decision within a fixed\n * layout, not a row-composition one, so it doesn't need its own segment\n * handling here.\n *\n * `headingOverflow: 'fit'`: still prefers continuous packing, but a\n * section whose label (title+subtitle, or title alone once the\n * subtitle's dropped) wouldn't fit the gap to the next label sharing\n * its row gets pushed to start a fresh row instead — the label is\n * never wrapped. This means row *composition itself* has to respond to\n * label width, not just label content — computeSegments() below finds\n * the largest run of sections that can share rows without any label\n * needing to break, by growing a trial `layoutJustified` call one\n * section at a time and backing off the moment a fit check fails; each\n * final segment becomes its own independent `layoutJustified` call\n * (same mechanism as masonry's hard per-section blocks, except the\n * unit here is a dynamically-sized run of sections, not always\n * exactly one).\n */\n function applyJustifiedGrouped(\n justifiedOptions: JustifiedOptions,\n config: ResolvedConfig,\n containerWidth: number,\n ): void {\n // Batch-measure every structured heading's natural (unconstrained,\n // single-line) width up front — both with and without its subtitle —\n // before any segmentation trial runs. Needed by computeSegments()'s\n // fit checks below, and again by the final per-segment content\n // decision pass; measuring once here avoids re-measuring per trial.\n const fullWidths = new Map<number, number>();\n const titleOnlyWidths = new Map<number, number>();\n if (headingOverflow === 'fit') {\n for (const heading of headings) {\n if (!heading.structured) continue;\n const { subtitleEl } = heading.structured;\n heading.root.style.maxWidth = 'none';\n heading.root.style.whiteSpace = 'nowrap';\n if (subtitleEl) subtitleEl.hidden = false;\n fullWidths.set(heading.tileStart, heading.root.getBoundingClientRect().width);\n if (subtitleEl) {\n subtitleEl.hidden = true;\n titleOnlyWidths.set(heading.tileStart, heading.root.getBoundingClientRect().width);\n subtitleEl.hidden = false; // restored — the real decision happens per final segment, below\n } else {\n titleOnlyWidths.set(heading.tileStart, fullWidths.get(heading.tileStart)!);\n }\n }\n }\n\n /** [startSection, endSection) into `headings`, sized `tiles[]` range, and that range's own layoutJustified result. */\n type Segment = {\n startSection: number;\n endSection: number;\n tileStart: number;\n tileEnd: number;\n result: JustifiedResult;\n };\n\n function tileAspects(tileStart: number, tileEnd: number) {\n return tiles.slice(tileStart, tileEnd).map((t) => aspectOf(gallery.items[t.index]));\n }\n\n /** Runs layoutJustified on sections [startSection, endSection) and checks every label in it fits (full or title-only) its row's available width — null if any doesn't. */\n function tryFit(startSection: number, endSection: number): JustifiedResult | null {\n const tileStart = headings[startSection]!.tileStart;\n const tileEnd =\n endSection < headings.length ? headings[endSection]!.tileStart : tiles.length;\n const result = layoutJustified(tileAspects(tileStart, tileEnd), justifiedOptions);\n for (let k = startSection; k < endSection; k++) {\n const heading = headings[k]!;\n if (!heading.structured) continue; // raw renderHeading content never triggers a break\n const pos = result.positions[heading.tileStart - tileStart];\n if (!pos) continue; // lastRow: 'hide' trailing range within this trial\n const next = k + 1 < endSection ? headings[k + 1] : undefined;\n const nextPos = next ? result.positions[next.tileStart - tileStart] : null;\n const sameRow = !!nextPos && Math.abs(nextPos.y - pos.y) < 0.5;\n const available = (sameRow ? nextPos!.x : containerWidth) - pos.x - config.gutter.x;\n const bestWidth = Math.min(\n fullWidths.get(heading.tileStart) ?? Infinity,\n titleOnlyWidths.get(heading.tileStart) ?? Infinity,\n );\n if (bestWidth > available) return null;\n }\n return result;\n }\n\n function computeSegments(): Segment[] {\n if (headingOverflow !== 'fit') {\n return [\n {\n startSection: 0,\n endSection: headings.length,\n tileStart: 0,\n tileEnd: tiles.length,\n result: layoutJustified(tileAspects(0, tiles.length), justifiedOptions),\n },\n ];\n }\n const segments: Segment[] = [];\n let segStart = 0;\n while (segStart < headings.length) {\n const base = tryFit(segStart, segStart + 1);\n const tileStart = headings[segStart]!.tileStart;\n if (base === null) {\n // Doesn't fit even alone in its own row — the unavoidable case\n // (a single label wider than the container itself). Nothing\n // narrower to fall back to; take it as its own 1-section\n // segment anyway and let the content-decision pass below\n // ellipsis-truncate it rather than ever wrapping or\n // overflowing.\n const tileEnd =\n segStart + 1 < headings.length ? headings[segStart + 1]!.tileStart : tiles.length;\n segments.push({\n startSection: segStart,\n endSection: segStart + 1,\n tileStart,\n tileEnd,\n result: layoutJustified(tileAspects(tileStart, tileEnd), justifiedOptions),\n });\n segStart += 1;\n continue;\n }\n let end = segStart + 1;\n let best = base;\n while (end < headings.length) {\n const trial = tryFit(segStart, end + 1);\n if (trial === null) break;\n end += 1;\n best = trial;\n }\n const tileEnd = end < headings.length ? headings[end]!.tileStart : tiles.length;\n segments.push({\n startSection: segStart,\n endSection: end,\n tileStart,\n tileEnd,\n result: best,\n });\n segStart = end;\n }\n return segments;\n }\n\n /** Applies the real drop-subtitle/ellipsis decision for every structured heading in one finalized segment, using its actual computed positions (not a trial). */\n function applyContentDecisions(segment: Segment): void {\n for (let k = segment.startSection; k < segment.endSection; k++) {\n const heading = headings[k]!;\n if (!heading.structured) continue;\n const pos = segment.result.positions[heading.tileStart - segment.tileStart];\n if (!pos) continue;\n const next = k + 1 < segment.endSection ? headings[k + 1] : undefined;\n const nextPos = next\n ? segment.result.positions[next.tileStart - segment.tileStart]\n : null;\n const sameRow = !!nextPos && Math.abs(nextPos.y - pos.y) < 0.5;\n const available = (sameRow ? nextPos!.x : containerWidth) - pos.x - config.gutter.x;\n\n const { subtitleEl } = heading.structured;\n heading.root.style.whiteSpace = 'nowrap';\n heading.root.style.maxWidth = 'none';\n heading.root.style.overflow = '';\n heading.root.style.textOverflow = '';\n if (subtitleEl) subtitleEl.hidden = false;\n\n const full = fullWidths.get(heading.tileStart) ?? 0;\n if (full <= available) continue; // fits with subtitle, as-is\n\n if (subtitleEl) {\n subtitleEl.hidden = true;\n const titleOnly = titleOnlyWidths.get(heading.tileStart) ?? 0;\n if (titleOnly <= available) continue; // fits with subtitle dropped\n }\n\n // Still too wide title-only — only reachable for a 1-section\n // segment that couldn't be narrowed any further (computeSegments\n // already gave every other case its own row's worth of room).\n // Never wrap: ellipsis-truncate instead, still one line.\n heading.root.style.maxWidth = `${Math.max(0, available)}px`;\n heading.root.style.overflow = 'hidden';\n heading.root.style.textOverflow = 'ellipsis';\n }\n }\n\n /**\n * `headingOverflow: 'wrap'`'s content decision — unlike\n * applyContentDecisions above, this never drops the subtitle or\n * ellipsis-truncates a single line; it lets the label wrap onto\n * additional lines within its available width, then caps *how many*\n * lines via `-webkit-line-clamp` sized from `maxRowHeight` — a label\n * short enough to fit within that many lines is unaffected (nothing\n * for the clamp to do), a longer one ellipsis-truncates its last\n * visible line rather than growing the row past the configured cap.\n * `-webkit-line-clamp` is broadly supported cross-browser despite the\n * prefix (Chrome/Safari/Firefox all implement it) — there's no\n * unprefixed equivalent with comparable support yet.\n */\n function applyWrapDecisions(segment: Segment): void {\n for (let k = segment.startSection; k < segment.endSection; k++) {\n const heading = headings[k]!;\n if (!heading.structured) continue;\n const pos = segment.result.positions[heading.tileStart - segment.tileStart];\n if (!pos) continue;\n const next = k + 1 < segment.endSection ? headings[k + 1] : undefined;\n const nextPos = next\n ? segment.result.positions[next.tileStart - segment.tileStart]\n : null;\n const sameRow = !!nextPos && Math.abs(nextPos.y - pos.y) < 0.5;\n const available = (sameRow ? nextPos!.x : containerWidth) - pos.x - config.gutter.x;\n\n heading.root.style.whiteSpace = 'normal';\n heading.root.style.maxWidth = `${Math.max(0, available)}px`;\n\n const style = getComputedStyle(heading.root);\n const parsedLineHeight = parseFloat(style.lineHeight);\n const lineHeight = Number.isFinite(parsedLineHeight)\n ? parsedLineHeight\n : parseFloat(style.fontSize) * 1.2;\n const maxLines = Math.max(1, Math.floor(config.maxRowHeight / lineHeight));\n\n heading.root.style.display = '-webkit-box';\n heading.root.style.overflow = 'hidden';\n heading.root.style.setProperty('-webkit-box-orient', 'vertical');\n heading.root.style.setProperty('-webkit-line-clamp', `${maxLines}`);\n }\n }\n\n const segments = computeSegments();\n let cumulativeExtra = 0;\n let headingPtr = 0;\n\n for (const segment of segments) {\n if (headingOverflow === 'fit') applyContentDecisions(segment);\n else if (headingOverflow === 'wrap') applyWrapDecisions(segment);\n\n // Batch-read this segment's heading heights up front, after content\n // decisions are final — see applyMasonry's identical pattern/comment\n // for why reads and writes shouldn't interleave in the same pass.\n const headingHeights = new Map<number, number>();\n for (let k = segment.startSection; k < segment.endSection; k++) {\n const heading = headings[k]!;\n headingHeights.set(heading.tileStart, heading.root.getBoundingClientRect().height);\n }\n\n for (const row of segment.result.rows) {\n const globalStart = row.start + segment.tileStart;\n const globalEnd = row.end + segment.tileStart;\n\n let bandHeight = 0;\n while (headingPtr < segment.endSection && headings[headingPtr]!.tileStart < globalEnd) {\n const heading = headings[headingPtr]!;\n const pos = segment.result.positions[heading.tileStart - segment.tileStart];\n if (pos) {\n heading.root.hidden = false;\n heading.root.style.transform = `translate(${pos.x}px, ${row.y + cumulativeExtra}px)`;\n bandHeight = Math.max(bandHeight, headingHeights.get(heading.tileStart) ?? 0);\n }\n headingPtr++;\n }\n if (bandHeight > 0) cumulativeExtra += bandHeight + config.gutter.y;\n\n for (let i = globalStart; i < globalEnd; i++) {\n const pos = segment.result.positions[i - segment.tileStart];\n const tile = tiles[i]!;\n tile.root.hidden = pos === null;\n if (!pos) continue;\n tile.root.style.width = `${pos.width}px`;\n tile.root.style.height = `${pos.height}px`;\n tile.root.style.visibility = 'visible';\n tile.root.style.transform = `translate(${pos.x}px, ${pos.y + cumulativeExtra}px)`;\n }\n }\n\n // Tiles beyond this segment's last finalized row (a lastRow: 'hide'\n // trailing range) never got touched above — hide them explicitly.\n const segLastCovered =\n segment.result.rows.length > 0\n ? segment.result.rows[segment.result.rows.length - 1]!.end + segment.tileStart\n : segment.tileStart;\n for (let i = segLastCovered; i < segment.tileEnd; i++) {\n tiles[i]!.root.hidden = true;\n }\n\n // Advance past this whole segment's own content height, so the\n // next segment's rows (a fresh layoutJustified call, local y\n // starting back at 0) stack directly beneath it.\n cumulativeExtra += segment.result.containerHeight + config.gutter.y;\n }\n\n // Any heading whose section starts in the very last segment's hidden\n // trailing range never got positioned above — hide it rather than\n // leaving it at its CSS default top:0/left:0.\n while (headingPtr < headings.length) {\n headings[headingPtr]!.root.hidden = true;\n headingPtr++;\n }\n\n container.style.height = `${Math.max(0, cumulativeExtra - config.gutter.y)}px`;\n }\n\n /**\n * Unlike masonry's `columnHeights`, a justified row isn't \"locked in\"\n * until enough tiles arrive to fill it — an appended tile can extend or\n * complete what was the *previous* render's trailing row. Rather than\n * build a second continuation scheme for that, justified always relays\n * out every tile on any items change (append included); see §5.4.\n */\n function applyJustified(): void {\n const containerWidth = container.getBoundingClientRect().width;\n if (containerWidth <= 0) return; // not painted yet — resize observer's initial callback retries once it is\n\n const config = resolveConfig(containerWidth);\n const justifiedOptions = {\n containerWidth,\n gutterX: config.gutter.x,\n gutterY: config.gutter.y,\n rowHeight: config.rowHeight,\n minRowHeight: config.minRowHeight,\n maxRowHeight: config.maxRowHeight,\n lastRow: config.lastRow,\n };\n\n if (groupingEnabled && headings.length > 0) {\n applyJustifiedGrouped(justifiedOptions, config, containerWidth);\n return;\n }\n\n const justifiedTiles = tiles.map((t) => aspectOf(gallery.items[t.index]));\n const result = layoutJustified(justifiedTiles, justifiedOptions);\n\n tiles.forEach((tile, i) => {\n const pos = result.positions[i];\n tile.root.hidden = pos === null; // lastRow: 'hide' on this pass's trailing tiles\n if (!pos) return;\n tile.root.style.width = `${pos.width}px`;\n tile.root.style.height = `${pos.height}px`;\n tile.root.style.visibility = 'visible';\n tile.root.style.transform = `translate(${pos.x}px, ${pos.y}px)`;\n });\n\n container.style.height = `${result.containerHeight}px`;\n }\n\n function relayoutAll(): void {\n if (horizontal) applyMasonryHorizontal();\n else if (type === 'masonry') applyMasonry(null);\n else if (type === 'justified') applyJustified();\n else applyGrid(tiles);\n }\n\n let destroyed = false;\n let measureFrame: number | null = null;\n\n /**\n * Coalesces every `autoMeasure` correction landing in the same tick (or\n * the same animation frame — several cached images resolving\n * synchronously back-to-back is the common case) into one relayout,\n * never one per image. `groupingEnabled` needs a real `fullRender()`,\n * not just a reposition pass: `groupBy` was evaluated once already\n * during the first render, using whatever `item.width`/etc. was known\n * *then* — if that's exactly the field a correction just filled in,\n * the section boundaries themselves were computed wrong and only a\n * fresh `computeSections()` call (inside `fullRender`) fixes them, not\n * `relayoutAll()`, which only ever repositions the existing tiles/headings.\n */\n function scheduleMeasureRelayout(): void {\n if (measureFrame !== null || destroyed) return;\n measureFrame = requestAnimationFrame(() => {\n measureFrame = null;\n if (destroyed) return;\n if (groupingEnabled) fullRender(gallery.items);\n else relayoutAll();\n });\n }\n\n /**\n * DESIGN.md §5's `autoMeasure` option — never blocks or delays the\n * *first* layout pass (already painted with the 4:3 placeholder by the\n * time this runs); only ever corrects it afterward, once a real\n * measurement lands. Reuses each tile's own `<img>` (already fetching\n * for display) rather than a second `new Image()`, so nothing is\n * fetched twice. A `loading=\"lazy\"` tile far off-screen may not\n * resolve for a long time (or ever, if never scrolled to) — harmless,\n * since nothing visible is waiting on it; it simply keeps its 4:3\n * placeholder until/unless it does.\n */\n function autoMeasureTiles(newTiles: readonly Tile[]): void {\n if (!autoMeasureEnabled) return;\n for (const tile of newTiles) {\n const item = gallery.items[tile.index];\n if (!item || (item.width && item.height) || !measurableSrc(item)) continue;\n\n const onLoad = (): void => {\n if (destroyed) return;\n if (tile.img.naturalWidth > 0 && tile.img.naturalHeight > 0) {\n item.width = tile.img.naturalWidth;\n item.height = tile.img.naturalHeight;\n scheduleMeasureRelayout();\n }\n };\n if (tile.img.complete) onLoad();\n else tile.img.addEventListener('load', onLoad, { once: true });\n // No 'error' listener needed — aspectOf()'s 4:3 fallback already\n // covers an image that never loads; there's nothing to update.\n }\n }\n\n /** DESIGN.md §5 — fires once a render pass's tile DOM is built and appended, so a host can inject its own content into `element` (a badge, a selection checkbox, anything not itself part of layout) without re-deriving which tiles are newly built this pass. */\n function emitLayoutRender(rendered: readonly Tile[]): void {\n ctx.emit('layoutRender', {\n tiles: rendered.map((tile) => ({ index: tile.index, element: tile.root })),\n });\n }\n\n function fullRender(items: readonly GalleryItem[]): void {\n container.replaceChildren();\n tiles = items.map((item, i) => createTile(item, i));\n headings = [];\n if (groupingEnabled) {\n for (const section of computeSections(items)) {\n const sectionItems = items.slice(section.startIndex, section.endIndex);\n const { root: headingRoot, structured } = createHeadingRoot(section.key, sectionItems);\n container.appendChild(headingRoot);\n headings.push({ root: headingRoot, tileStart: section.startIndex, structured });\n for (let i = section.startIndex; i < section.endIndex; i++) {\n container.appendChild(tiles[i]!.root);\n }\n }\n } else {\n for (const tile of tiles) container.appendChild(tile.root);\n }\n columnHeights = undefined;\n relayoutAll();\n autoMeasureTiles(tiles);\n emitLayoutRender(tiles);\n }\n\n function appendRender(newItems: readonly GalleryItem[], startIndex: number): void {\n const newTiles = newItems.map((item, i) => createTile(item, startIndex + i));\n for (const tile of newTiles) container.appendChild(tile.root);\n tiles = [...tiles, ...newTiles];\n if (horizontal)\n applyMasonryHorizontal(); // full relayout — see applyMasonryHorizontal's doc comment\n else if (type === 'masonry') applyMasonry(newTiles);\n else if (type === 'justified')\n applyJustified(); // full relayout — see applyJustified's doc comment\n else applyGrid(newTiles);\n autoMeasureTiles(newTiles);\n emitLayoutRender(newTiles);\n }\n\n fullRender(gallery.items);\n previousItems = gallery.items;\n\n const offItemsUpdated = ctx.on('itemsUpdated', ({ items }) => {\n if (groupingEnabled) {\n // grouped changes always fully re-render — see the `groupBy` option's doc comment.\n fullRender(items);\n } else if (isPureAppend(previousItems, items)) {\n appendRender(items.slice(previousItems.length), previousItems.length);\n } else {\n fullRender(items);\n }\n previousItems = items;\n });\n\n // Native CSS grid already reflows its own tile positions responsively —\n // it only needs a resize-driven recompute at all when breakpoints are\n // configured (to re-check whether the container crossed a threshold).\n // Masonry/justified always need one, since they're JS-computed.\n let resizeFrame: number | null = null;\n let resizeObserver: ResizeObserver | undefined;\n if (type === 'masonry' || type === 'justified' || sortedBreakpoints.length > 0) {\n resizeObserver = new ResizeObserver(() => {\n if (resizeFrame !== null) return;\n resizeFrame = requestAnimationFrame(() => {\n resizeFrame = null;\n relayoutAll();\n });\n });\n resizeObserver.observe(container);\n }\n\n return () => {\n destroyed = true;\n if (measureFrame !== null) cancelAnimationFrame(measureFrame);\n resizeObserver?.disconnect();\n if (resizeFrame !== null) cancelAnimationFrame(resizeFrame);\n offItemsUpdated();\n container.classList.remove('shoji-layout', `shoji-layout--${type}`, 'shoji-layout--animate');\n container.style.removeProperty('height');\n container.style.removeProperty('width');\n container.replaceChildren();\n };\n },\n};\n"],"names":["segments","tileEnd"],"mappings":"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"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DESIGN.md §5.2 — Flickr/Google-Photos-style row filler: tiles are grouped
|
|
3
|
+
* into rows, each row scaled as a whole (every tile in it keeps its own
|
|
4
|
+
* aspect ratio, only the shared row height changes) until the row's total
|
|
5
|
+
* width exactly matches the container — the opposite visual signature from
|
|
6
|
+
* masonry: row *right edges* line up flush, row *heights* vary instead of
|
|
7
|
+
* column bottoms varying. Pure, no DOM — same shape/testing pattern as
|
|
8
|
+
* masonry.ts.
|
|
9
|
+
*/
|
|
10
|
+
export interface JustifiedTile {
|
|
11
|
+
width: number;
|
|
12
|
+
height: number;
|
|
13
|
+
}
|
|
14
|
+
export interface JustifiedPosition {
|
|
15
|
+
x: number;
|
|
16
|
+
y: number;
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
}
|
|
20
|
+
export interface JustifiedOptions {
|
|
21
|
+
containerWidth: number;
|
|
22
|
+
/** Gap between tiles within a row (horizontal). */
|
|
23
|
+
gutterX: number;
|
|
24
|
+
/** Gap *between* rows (vertical). */
|
|
25
|
+
gutterY: number;
|
|
26
|
+
/** Target row height — rows are greedily filled until adding the next tile would exceed containerWidth at this height, then the whole row is rescaled to fit exactly (so actual row heights end up *near*, not always exactly at, this value). */
|
|
27
|
+
rowHeight: number;
|
|
28
|
+
/**
|
|
29
|
+
* Bounds on a row's *scaled* (fill-the-container) height — an unusual mix
|
|
30
|
+
* of aspect ratios (very few tiles, or very narrow/wide ones, e.g. a lone
|
|
31
|
+
* portrait tile stretched to fill the container width under
|
|
32
|
+
* `lastRow: 'justify'`) can otherwise scale a row to an extreme height.
|
|
33
|
+
* `maxRowHeight` clamping means that row no longer exactly fills
|
|
34
|
+
* `containerWidth` at its capped height — it falls short of the right
|
|
35
|
+
* edge instead, the same tradeoff masonry's `maxColumnWidth` already
|
|
36
|
+
* makes for the analogous reason. `minRowHeight` never causes that kind
|
|
37
|
+
* of overflow, though, unlike a naive clamp would: a row that would need
|
|
38
|
+
* to go below `minRowHeight` to fit exactly instead sheds tiles off its
|
|
39
|
+
* end (rolled into the next row) until what's left *does* fit at
|
|
40
|
+
* `minRowHeight` — rows never extend past `containerWidth`. The one
|
|
41
|
+
* unavoidable exception is a single tile so wide/panoramic that even
|
|
42
|
+
* alone it can't satisfy both bounds at once (`minRowHeight` at its own
|
|
43
|
+
* aspect ratio is inherently wider than the container) — there,
|
|
44
|
+
* `minRowHeight` itself yields rather than the row overflowing. Neither
|
|
45
|
+
* bound applies to `lastRow: 'left'`, which already uses `rowHeight` as
|
|
46
|
+
* a fixed, unscaled height rather than deriving one (and, being the
|
|
47
|
+
* left-over *under*-full case by definition, never overflows either).
|
|
48
|
+
*/
|
|
49
|
+
minRowHeight: number;
|
|
50
|
+
maxRowHeight: number;
|
|
51
|
+
/** How to handle the trailing row when there aren't enough tiles left to naturally reach containerWidth: 'justify' stretches it like any other row (Flickr-style — the default); 'left' keeps it at natural rowHeight-scaled size, left-aligned, empty space on the right; 'hide' excludes it from `positions` (returned as `null`) entirely. */
|
|
52
|
+
lastRow: 'justify' | 'left' | 'hide';
|
|
53
|
+
}
|
|
54
|
+
export interface JustifiedRow {
|
|
55
|
+
/** Tile index range finalized into this row, `[start, end)`. */
|
|
56
|
+
start: number;
|
|
57
|
+
end: number;
|
|
58
|
+
y: number;
|
|
59
|
+
height: number;
|
|
60
|
+
}
|
|
61
|
+
export interface JustifiedResult {
|
|
62
|
+
/** One entry per input tile, same order — `null` only for a `lastRow: 'hide'`-excluded trailing tile. */
|
|
63
|
+
positions: Array<JustifiedPosition | null>;
|
|
64
|
+
containerHeight: number;
|
|
65
|
+
/** One entry per finalized row, in order — lets a caller find which tiles landed on the same row (e.g. to decide where a compact section label belongs) without re-deriving it from `positions[i].y` equality. */
|
|
66
|
+
rows: JustifiedRow[];
|
|
67
|
+
}
|
|
68
|
+
export declare function layoutJustified(tiles: readonly JustifiedTile[], options: JustifiedOptions): JustifiedResult;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DESIGN.md §5.2 — deterministic, measure-free layout: positions come purely
|
|
3
|
+
* from each tile's aspect ratio, never from measuring a loaded image (that's
|
|
4
|
+
* exactly what causes the layout-jump problem most masonry libraries have).
|
|
5
|
+
* Pure functions, no DOM — testable in isolation from rendering.
|
|
6
|
+
*/
|
|
7
|
+
export interface MasonryTile {
|
|
8
|
+
/** Natural aspect ratio only — any unit works as long as width/height share one. */
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
}
|
|
12
|
+
export interface MasonryPosition {
|
|
13
|
+
x: number;
|
|
14
|
+
y: number;
|
|
15
|
+
width: number;
|
|
16
|
+
height: number;
|
|
17
|
+
}
|
|
18
|
+
export interface MasonryOptions {
|
|
19
|
+
containerWidth: number;
|
|
20
|
+
/** Gap *between* columns (horizontal). */
|
|
21
|
+
gutterX: number;
|
|
22
|
+
/** Gap *between* tiles stacked within a column (vertical). */
|
|
23
|
+
gutterY: number;
|
|
24
|
+
columns: number | 'auto';
|
|
25
|
+
columnWidth: number;
|
|
26
|
+
minColumnWidth: number;
|
|
27
|
+
maxColumnWidth: number;
|
|
28
|
+
fill: 'shortest' | 'ordered';
|
|
29
|
+
}
|
|
30
|
+
export interface MasonryResult {
|
|
31
|
+
positions: MasonryPosition[];
|
|
32
|
+
columnCount: number;
|
|
33
|
+
columnWidth: number;
|
|
34
|
+
/** Per-column running height, incl. trailing gutterY — feed back in as `startHeights` to continue packing (e.g. an appended page) without relaying out earlier tiles. */
|
|
35
|
+
columnHeights: number[];
|
|
36
|
+
containerHeight: number;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* `'auto'` column count is `containerWidth / columnWidth`, floored — at odd
|
|
40
|
+
* container widths that division can drift the *effective* per-column width
|
|
41
|
+
* well past `columnWidth` before the count itself changes (e.g. 601px at a
|
|
42
|
+
* 200px target floors to 3 columns of ~200px, but 999px floors to 4 columns
|
|
43
|
+
* of ~250px). `minColumnWidth`/`maxColumnWidth` cap how far that drift goes
|
|
44
|
+
* before the column count itself adjusts, so columns never go stringy-narrow
|
|
45
|
+
* or stretch too wide at awkward container sizes. Only `gutterX` (the axis
|
|
46
|
+
* columns are laid out along) factors into this — `gutterY` never affects
|
|
47
|
+
* column count/width.
|
|
48
|
+
*/
|
|
49
|
+
export declare function computeColumnCount(options: MasonryOptions): number;
|
|
50
|
+
export declare function computeColumnWidth(containerWidth: number, gutterX: number, columnCount: number): number;
|
|
51
|
+
/**
|
|
52
|
+
* Packs `tiles` into columns. `startHeights` (one entry per column, from a
|
|
53
|
+
* previous `MasonryResult.columnHeights`) continues packing after already-
|
|
54
|
+
* placed tiles instead of relaying out from scratch — this is what makes an
|
|
55
|
+
* infinite-scroll append O(new items): earlier tiles' positions never change.
|
|
56
|
+
*/
|
|
57
|
+
export declare function layoutMasonry(tiles: readonly MasonryTile[], options: MasonryOptions, startHeights?: readonly number[]): MasonryResult;
|
|
58
|
+
export interface MasonryHorizontalOptions {
|
|
59
|
+
containerWidth: number;
|
|
60
|
+
/** Gap between tiles packed rightward within a row. */
|
|
61
|
+
gutterX: number;
|
|
62
|
+
/** Gap *between* rows (vertical). */
|
|
63
|
+
gutterY: number;
|
|
64
|
+
/** Every row's exact, fixed height — never scaled/solved-for (that's what distinguishes this from `justified`, whose row heights are the free variable it adjusts to make each row's width land exactly on `containerWidth`; here, height is the fixed input and width is left wherever a row's tiles naturally end). */
|
|
65
|
+
rowHeight: number;
|
|
66
|
+
}
|
|
67
|
+
export interface MasonryHorizontalResult {
|
|
68
|
+
positions: MasonryPosition[];
|
|
69
|
+
rowCount: number;
|
|
70
|
+
containerHeight: number;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* DESIGN.md §5.1 — packs tiles left-to-right into rows of `rowHeight` (each
|
|
74
|
+
* tile scaled to that exact height at its own true aspect ratio, never
|
|
75
|
+
* stretched/cropped), wrapping to a new row the moment the next tile would
|
|
76
|
+
* push the current one past `containerWidth` — never stretching the
|
|
77
|
+
* finished row to close the remaining gap. The result: every row is
|
|
78
|
+
* *exactly* `rowHeight` tall (the fixed constraint), and every row's right
|
|
79
|
+
* edge lands wherever its tiles' natural widths summed to (the free,
|
|
80
|
+
* "ragged" one) — the direct masonry-family transpose of vertical masonry,
|
|
81
|
+
* whose *columns* share a fixed width and whose ragged edge is the bottom.
|
|
82
|
+
* `justified` (`justified.ts`) is the inverse tradeoff: width is the fixed
|
|
83
|
+
* constraint (every row forced flush to `containerWidth`) and height is the
|
|
84
|
+
* free variable solved per row to make that work.
|
|
85
|
+
*
|
|
86
|
+
* A single tile whose natural width at `rowHeight` alone already exceeds
|
|
87
|
+
* `containerWidth` (a wide panorama) is still placed on its own row rather
|
|
88
|
+
* than shrunk — nothing narrower to fall back to without violating the
|
|
89
|
+
* fixed-height constraint, the same tradeoff `justified`'s `minRowHeight`
|
|
90
|
+
* makes in its own analogous single-tile edge case.
|
|
91
|
+
*/
|
|
92
|
+
export declare function layoutMasonryHorizontal(tiles: readonly MasonryTile[], options: MasonryHorizontalOptions): MasonryHorizontalResult;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** DESIGN.md §9 — inline SVG, stroke = currentColor, matches src/core/icons.ts's convention. */
|
|
2
|
+
export declare const ROTATE_LEFT_ICON = "<svg viewBox=\"0 0 24 24\" width=\"24\" height=\"24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9 14 4 9l5-5\"/><path d=\"M4 9h10a6 6 0 1 1 0 12H9\"/></svg>";
|
|
3
|
+
export declare const ROTATE_RIGHT_ICON = "<svg viewBox=\"0 0 24 24\" width=\"24\" height=\"24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M15 14 20 9l-5-5\"/><path d=\"M20 9H10a6 6 0 1 0 0 12h5\"/></svg>";
|
|
4
|
+
export declare const FLIP_H_ICON = "<svg viewBox=\"0 0 24 24\" width=\"24\" height=\"24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 3v18\"/><path d=\"M17 7l3 5-3 5\"/><path d=\"M7 7l-3 5 3 5\"/></svg>";
|
|
5
|
+
export declare const FLIP_V_ICON = "<svg viewBox=\"0 0 24 24\" width=\"24\" height=\"24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 12h18\"/><path d=\"M7 17l5 3 5-3\"/><path d=\"M7 7l5-3 5 3\"/></svg>";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ShojiPlugin } from '../../core/plugin';
|
|
2
|
+
/**
|
|
3
|
+
* DESIGN.md §4 — rotate ±90°/flip H/V of the *view*: a CSS transform on the
|
|
4
|
+
* active `.shoji-slide-media`, non-destructive, resets to neutral on every
|
|
5
|
+
* `afterOpen`/`afterSlide` (per-slide, not per-gallery — DESIGN.md's own
|
|
6
|
+
* wording). Emits `rotateFlipChange` on every change so a host that wants
|
|
7
|
+
* this to persist can store it themselves and re-apply later (e.g. by
|
|
8
|
+
* feeding a starting orientation back in some other way) — this plugin
|
|
9
|
+
* itself has no persistence of its own, matching "resets per slide".
|
|
10
|
+
*
|
|
11
|
+
* Distinct from the (unbuilt) Editor plugin's own rotate/flip (§8), which
|
|
12
|
+
* is destructive/persisted server-side via `item.edits`. Both share the
|
|
13
|
+
* same composition math (`normalizeRotateFlip`, `src/core/`) rather than
|
|
14
|
+
* each re-deriving flip+rotate's non-commutative composition — a real bug
|
|
15
|
+
* class CLAUDE.md calls out by name.
|
|
16
|
+
*/
|
|
17
|
+
export declare const RotateFlip: ShojiPlugin;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { w as waitForTransitionEnd } from "../../zoomTransition-bbKHpVpA.js";
|
|
2
|
+
function normalizeRotateFlip(state) {
|
|
3
|
+
const rotation = (state.rotation % 360 + 360) % 360;
|
|
4
|
+
if (state.flipH && state.flipV) {
|
|
5
|
+
return { flipH: false, flipV: false, rotation: (rotation + 180) % 360 };
|
|
6
|
+
}
|
|
7
|
+
return { flipH: state.flipH, flipV: state.flipV, rotation };
|
|
8
|
+
}
|
|
9
|
+
const ROTATE_LEFT_ICON = '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 14 4 9l5-5"/><path d="M4 9h10a6 6 0 1 1 0 12H9"/></svg>';
|
|
10
|
+
const ROTATE_RIGHT_ICON = '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 14 20 9l-5-5"/><path d="M20 9H10a6 6 0 1 0 0 12h5"/></svg>';
|
|
11
|
+
const FLIP_H_ICON = '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><path d="M17 7l3 5-3 5"/><path d="M7 7l-3 5 3 5"/></svg>';
|
|
12
|
+
const FLIP_V_ICON = '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h18"/><path d="M7 17l5 3 5-3"/><path d="M7 7l5-3 5 3"/></svg>';
|
|
13
|
+
const NEUTRAL = { flipH: false, flipV: false, rotation: 0 };
|
|
14
|
+
function transformFor(flipH, flipV, rotationDeg, fitScale) {
|
|
15
|
+
if (!flipH && !flipV && rotationDeg === 0 && fitScale === 1) return "none";
|
|
16
|
+
const scaleX = (flipH ? -1 : 1) * fitScale;
|
|
17
|
+
const scaleY = (flipV ? -1 : 1) * fitScale;
|
|
18
|
+
return `scaleX(${scaleX}) scaleY(${scaleY}) rotate(${rotationDeg}deg)`;
|
|
19
|
+
}
|
|
20
|
+
function fitScaleFor(mediaWidth, mediaHeight, rotationDeg, naturalWidth, naturalHeight) {
|
|
21
|
+
if (!mediaWidth || !mediaHeight || !naturalWidth || !naturalHeight) return 1;
|
|
22
|
+
if (rotationDeg / 90 % 2 === 0) return 1;
|
|
23
|
+
const scaleAt0 = Math.min(1, mediaWidth / naturalWidth, mediaHeight / naturalHeight);
|
|
24
|
+
const scaleAt90 = Math.min(1, mediaWidth / naturalHeight, mediaHeight / naturalWidth);
|
|
25
|
+
return scaleAt90 / scaleAt0;
|
|
26
|
+
}
|
|
27
|
+
const RotateFlip = {
|
|
28
|
+
name: "rotateFlip",
|
|
29
|
+
init(ctx) {
|
|
30
|
+
const { gallery } = ctx;
|
|
31
|
+
const locale = gallery.options.locale ?? {};
|
|
32
|
+
const rotateLeftLabel = locale.rotateLeft ?? "Rotate left";
|
|
33
|
+
const rotateRightLabel = locale.rotateRight ?? "Rotate right";
|
|
34
|
+
const flipHLabel = locale.flipHorizontal ?? "Flip horizontal";
|
|
35
|
+
const flipVLabel = locale.flipVertical ?? "Flip vertical";
|
|
36
|
+
let state = { ...NEUTRAL };
|
|
37
|
+
let visualRotation = 0;
|
|
38
|
+
let visualFlipH = false;
|
|
39
|
+
let visualFlipV = false;
|
|
40
|
+
function resolveNaturalSize(media) {
|
|
41
|
+
const item = gallery.items[gallery.currentIndex];
|
|
42
|
+
if ((item == null ? void 0 : item.width) && item.height) return { width: item.width, height: item.height };
|
|
43
|
+
const img = media.querySelector("img");
|
|
44
|
+
if ((img == null ? void 0 : img.naturalWidth) && img.naturalHeight) {
|
|
45
|
+
return { width: img.naturalWidth, height: img.naturalHeight };
|
|
46
|
+
}
|
|
47
|
+
return void 0;
|
|
48
|
+
}
|
|
49
|
+
function apply(animate) {
|
|
50
|
+
const media = gallery.getActiveMedia();
|
|
51
|
+
if (!media) return;
|
|
52
|
+
const natural = resolveNaturalSize(media);
|
|
53
|
+
const fitScale = fitScaleFor(
|
|
54
|
+
media.clientWidth,
|
|
55
|
+
media.clientHeight,
|
|
56
|
+
visualRotation,
|
|
57
|
+
natural == null ? void 0 : natural.width,
|
|
58
|
+
natural == null ? void 0 : natural.height
|
|
59
|
+
);
|
|
60
|
+
const transform = transformFor(visualFlipH, visualFlipV, visualRotation, fitScale);
|
|
61
|
+
if (!animate) {
|
|
62
|
+
media.style.transform = transform;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
media.style.transition = "transform var(--shoji-duration) var(--shoji-easing)";
|
|
66
|
+
media.style.transform = transform;
|
|
67
|
+
waitForTransitionEnd(media, () => {
|
|
68
|
+
media.style.transition = "";
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
function buildButton(icon, label) {
|
|
72
|
+
const button = document.createElement("button");
|
|
73
|
+
button.type = "button";
|
|
74
|
+
button.className = "shoji-toolbar-button";
|
|
75
|
+
button.innerHTML = icon;
|
|
76
|
+
button.setAttribute("aria-label", label);
|
|
77
|
+
button.title = label;
|
|
78
|
+
return button;
|
|
79
|
+
}
|
|
80
|
+
const rotateLeftBtn = buildButton(ROTATE_LEFT_ICON, rotateLeftLabel);
|
|
81
|
+
const rotateRightBtn = buildButton(ROTATE_RIGHT_ICON, rotateRightLabel);
|
|
82
|
+
const flipHBtn = buildButton(FLIP_H_ICON, flipHLabel);
|
|
83
|
+
const flipVBtn = buildButton(FLIP_V_ICON, flipVLabel);
|
|
84
|
+
flipHBtn.setAttribute("aria-pressed", "false");
|
|
85
|
+
flipVBtn.setAttribute("aria-pressed", "false");
|
|
86
|
+
function update(patch, visualRotationDelta) {
|
|
87
|
+
state = normalizeRotateFlip({ ...state, ...patch });
|
|
88
|
+
if (visualRotationDelta !== void 0) visualRotation += visualRotationDelta;
|
|
89
|
+
apply(true);
|
|
90
|
+
flipHBtn.setAttribute("aria-pressed", String(state.flipH));
|
|
91
|
+
flipVBtn.setAttribute("aria-pressed", String(state.flipV));
|
|
92
|
+
ctx.emit("rotateFlipChange", { index: gallery.currentIndex, ...state });
|
|
93
|
+
}
|
|
94
|
+
function rotateDelta(clockwise) {
|
|
95
|
+
const flippedOnOneAxis = visualFlipH !== visualFlipV;
|
|
96
|
+
return clockwise !== flippedOnOneAxis ? 90 : -90;
|
|
97
|
+
}
|
|
98
|
+
rotateLeftBtn.addEventListener("click", () => {
|
|
99
|
+
const delta = rotateDelta(false);
|
|
100
|
+
update({ rotation: state.rotation + delta }, delta);
|
|
101
|
+
});
|
|
102
|
+
rotateRightBtn.addEventListener("click", () => {
|
|
103
|
+
const delta = rotateDelta(true);
|
|
104
|
+
update({ rotation: state.rotation + delta }, delta);
|
|
105
|
+
});
|
|
106
|
+
flipHBtn.addEventListener("click", () => {
|
|
107
|
+
visualFlipH = !visualFlipH;
|
|
108
|
+
update({ flipH: !state.flipH });
|
|
109
|
+
});
|
|
110
|
+
flipVBtn.addEventListener("click", () => {
|
|
111
|
+
visualFlipV = !visualFlipV;
|
|
112
|
+
update({ flipV: !state.flipV });
|
|
113
|
+
});
|
|
114
|
+
function reset() {
|
|
115
|
+
state = { ...NEUTRAL };
|
|
116
|
+
visualRotation = 0;
|
|
117
|
+
visualFlipH = false;
|
|
118
|
+
visualFlipV = false;
|
|
119
|
+
apply(false);
|
|
120
|
+
flipHBtn.setAttribute("aria-pressed", "false");
|
|
121
|
+
flipVBtn.setAttribute("aria-pressed", "false");
|
|
122
|
+
}
|
|
123
|
+
const removeButtons = [rotateLeftBtn, rotateRightBtn, flipHBtn, flipVBtn].map(
|
|
124
|
+
(button) => ctx.ui.toolbar("right", button)
|
|
125
|
+
);
|
|
126
|
+
const offOpen = ctx.on("afterOpen", reset);
|
|
127
|
+
const offSlide = ctx.on("afterSlide", reset);
|
|
128
|
+
return () => {
|
|
129
|
+
for (const remove of removeButtons) remove();
|
|
130
|
+
offOpen();
|
|
131
|
+
offSlide();
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
export {
|
|
136
|
+
RotateFlip
|
|
137
|
+
};
|
|
138
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sources":["../../../../src/core/rotateFlipNormalize.ts","../../../../src/plugins/rotateFlip/icons.ts","../../../../src/plugins/rotateFlip/index.ts"],"sourcesContent":["export interface RotateFlipState {\n flipH: boolean;\n flipV: boolean;\n /** Degrees; normalized to one of 0/90/180/270 by `normalizeRotateFlip`. */\n rotation: number;\n}\n\n/**\n * DESIGN.md §8.1's flip/rotation canonicalization table, verbatim — CLAUDE.md:\n * \"Flip + rotate compose non-commutatively. Use the normalization table...\n * don't re-derive it.\" `flipH && flipV` is always visually equivalent to a\n * 180°-rotated state with neither flip set; this collapses any\n * `flipH`/`flipV`/`rotation` combination down to that canonical form, so two\n * states reached via different sequences of clicks compare equal and never\n * accumulate redundant flip+flip-again or unbounded rotation values.\n * Shared between the standalone rotate/flip *view* plugin (§4) and the\n * future Editor plugin (§8), which both need the identical composition —\n * not duplicated per-plugin.\n */\nexport function normalizeRotateFlip(state: RotateFlipState): RotateFlipState {\n const rotation = ((state.rotation % 360) + 360) % 360;\n if (state.flipH && state.flipV) {\n return { flipH: false, flipV: false, rotation: (rotation + 180) % 360 };\n }\n return { flipH: state.flipH, flipV: state.flipV, rotation };\n}\n","/** DESIGN.md §9 — inline SVG, stroke = currentColor, matches src/core/icons.ts's convention. */\nexport const ROTATE_LEFT_ICON =\n '<svg viewBox=\"0 0 24 24\" width=\"24\" height=\"24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9 14 4 9l5-5\"/><path d=\"M4 9h10a6 6 0 1 1 0 12H9\"/></svg>';\n\nexport const ROTATE_RIGHT_ICON =\n '<svg viewBox=\"0 0 24 24\" width=\"24\" height=\"24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M15 14 20 9l-5-5\"/><path d=\"M20 9H10a6 6 0 1 0 0 12h5\"/></svg>';\n\nexport const FLIP_H_ICON =\n '<svg viewBox=\"0 0 24 24\" width=\"24\" height=\"24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 3v18\"/><path d=\"M17 7l3 5-3 5\"/><path d=\"M7 7l-3 5 3 5\"/></svg>';\n\nexport const FLIP_V_ICON =\n '<svg viewBox=\"0 0 24 24\" width=\"24\" height=\"24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 12h18\"/><path d=\"M7 17l5 3 5-3\"/><path d=\"M7 7l5-3 5 3\"/></svg>';\n","import type { PluginContext, ShojiPlugin } from '../../core/plugin';\nimport { normalizeRotateFlip, type RotateFlipState } from '../../core/rotateFlipNormalize';\nimport { waitForTransitionEnd } from '../../core/zoomTransition';\nimport { FLIP_H_ICON, FLIP_V_ICON, ROTATE_LEFT_ICON, ROTATE_RIGHT_ICON } from './icons';\n\nconst NEUTRAL: RotateFlipState = { flipH: false, flipV: false, rotation: 0 };\n\n/**\n * Flip axes apply to the *currently visible* (already-rotated) orientation,\n * not the original unrotated image — `scaleX`/`scaleY` listed before\n * `rotate()` in the transform string is what makes that true: CSS transform\n * functions apply right-to-left, so `rotate()` (rightmost) affects the\n * content first, and the flip (leftmost) acts on that already-rotated\n * result.\n *\n * `flipH`/`flipV`/`rotationDeg` here are the *raw, uncanonicalized* visual\n * values (see `visualFlipH`/`visualFlipV`/`visualRotation` below) — never\n * `state`'s own normalized ones. Two real bugs came from feeding the\n * normalized state directly into this animated transform instead:\n *\n * 1. `rotationDeg` must be unbounded, not wrapped to `[0, 360)`: animating\n * straight to a wrapped value (e.g. 270° → 0° after a fourth\n * rotate-right click, instead of continuing on to 360°) makes the\n * browser interpolate a 270° *decrease*, spinning backward almost a\n * full turn instead of continuing the same 90° forward step being\n * clicked through.\n * 2. `flipH`/`flipV` must stay two independent, literal booleans, never\n * collapsed the way `normalizeRotateFlip` collapses `flipH && flipV`\n * into `rotation + 180`: flipping horizontal then vertical while\n * already flipped horizontal would otherwise animate `scaleX` back to\n * 1 *and* `rotate` up to 180° simultaneously (both values are in the\n * transform-function list, so the browser interpolates each\n * independently) — a compound squish-and-spin instead of the plain\n * vertical-flip motion \"Flip vertical\" implies. Keeping the raw\n * booleans means only `scaleY` ever changes for that click, since\n * flipping both axes is algebraically identical to a 180° rotation\n * regardless of what rotation is already applied — the *end* look\n * always matches the canonical state either way, only the *animated\n * path* differs.\n *\n * `rotationDeg === 0` is still a safe, unambiguous \"truly neutral\" check\n * for the shortcut below — every rotate click adds ±90, so it only lands\n * back on exactly `0` at genuine reset/initial state, never a masked\n * multiple of 360.\n *\n * `fitScale` (see `fitScaleFor` below) is folded directly into `scaleX`/\n * `scaleY` — a single uniform factor commutes with everything else already\n * in this transform (rotate, and flip's own -1), so multiplying it in here\n * is exactly equivalent to a separate trailing `scale()` function, without\n * adding a fourth transform function for the browser to interpolate.\n */\nfunction transformFor(\n flipH: boolean,\n flipV: boolean,\n rotationDeg: number,\n fitScale: number,\n): string {\n if (!flipH && !flipV && rotationDeg === 0 && fitScale === 1) return 'none';\n const scaleX = (flipH ? -1 : 1) * fitScale;\n const scaleY = (flipV ? -1 : 1) * fitScale;\n return `scaleX(${scaleX}) scaleY(${scaleY}) rotate(${rotationDeg}deg)`;\n}\n\n/**\n * DESIGN.md §4.5 — shrinks the *visible photo* exactly as much as needed to\n * keep it from getting its edges clipped away when rotated, and grows it\n * back up to fill newly-available space on rotation, but never past its\n * own native pixel resolution.\n *\n * **A real bug in the previous version of this fix, caught from real usage\n * on the docs site itself: it assumed `.shoji-slide-img` always scales to\n * *touch* the container on at least one axis (`object-fit: contain`'s\n * usual behavior), which is wrong.** The actual CSS is `max-width: 100%;\n * max-height: 100%` — a *cap*, not a forced fill (documented in\n * `shoji.css`'s own comment: real photos are assumed bigger than the\n * slide area, so never growing past natural size is the correct default).\n * A small placeholder photo, comfortably smaller than the dialog, simply\n * renders at its own native size — untouching every edge, nothing scaled.\n * The previous formula didn't know this: it computed an imagined\n * \"as if `object-fit: contain` always scales to fill\" pre-rotation size\n * (e.g. an 800×600 photo hypothetically stretched to ~1267×950 in a\n * 1920×950 window), then shrank *from that invented size* — a real,\n * visible shrink relative to what was actually on screen a moment\n * earlier, even though nothing should have changed at all. Confirmed\n * directly: measuring the real `<img>` on the real deployed docs page\n * showed it rendered at exactly its 800×600 native size, not the\n * \"contain-fit\" size the old formula assumed.\n *\n * **The fix: compute the photo's real render scale at each orientation\n * the same way the browser's own CSS does, then compare the two —\n * instead of computing an idealized target size and separately capping\n * it.** `scaleAt0`/`scaleAt90` are each `Math.min(1, mediaWidth /\n * relevantNaturalWidth, mediaHeight / relevantNaturalHeight)` — exactly\n * mirroring `max-width/max-height: 100%`'s own \"shrink to fit, never grow\n * past native size\" rule, once for the current (unrotated) orientation\n * and once for the rotated one (natural width/height swapped). The result\n * is simply their ratio: how much *more* (or less) of its own native\n * resolution the rotated orientation can use compared to what's already\n * on screen. This single ratio does everything the old two-step\n * idealFit-then-cap formula tried to do, correctly and for free: neither\n * `scaleAt0` nor `scaleAt90` can ever exceed `1` (native resolution is\n * never exceeded, in *either* orientation, not just relative to a\n * possibly-wrong assumed starting point), and whichever one is more\n * constrained by the container — rather than by native resolution —\n * still shrinks or grows the ratio exactly as far as that constraint\n * requires.\n *\n * `1` (a no-op) for 0°/180° — a rectangle's own bounding box is unchanged\n * by a half-turn, nothing to re-fit — and whenever either media dimension\n * isn't known yet (not yet laid out) or the photo's own natural dimensions\n * aren't known at all (video, or an image that hasn't decoded) — skipped\n * entirely rather than guessing.\n */\nfunction fitScaleFor(\n mediaWidth: number,\n mediaHeight: number,\n rotationDeg: number,\n naturalWidth: number | undefined,\n naturalHeight: number | undefined,\n): number {\n if (!mediaWidth || !mediaHeight || !naturalWidth || !naturalHeight) return 1;\n if ((rotationDeg / 90) % 2 === 0) return 1;\n const scaleAt0 = Math.min(1, mediaWidth / naturalWidth, mediaHeight / naturalHeight);\n const scaleAt90 = Math.min(1, mediaWidth / naturalHeight, mediaHeight / naturalWidth);\n return scaleAt90 / scaleAt0;\n}\n\n/**\n * DESIGN.md §4 — rotate ±90°/flip H/V of the *view*: a CSS transform on the\n * active `.shoji-slide-media`, non-destructive, resets to neutral on every\n * `afterOpen`/`afterSlide` (per-slide, not per-gallery — DESIGN.md's own\n * wording). Emits `rotateFlipChange` on every change so a host that wants\n * this to persist can store it themselves and re-apply later (e.g. by\n * feeding a starting orientation back in some other way) — this plugin\n * itself has no persistence of its own, matching \"resets per slide\".\n *\n * Distinct from the (unbuilt) Editor plugin's own rotate/flip (§8), which\n * is destructive/persisted server-side via `item.edits`. Both share the\n * same composition math (`normalizeRotateFlip`, `src/core/`) rather than\n * each re-deriving flip+rotate's non-commutative composition — a real bug\n * class CLAUDE.md calls out by name.\n */\nexport const RotateFlip: ShojiPlugin = {\n name: 'rotateFlip',\n\n init(ctx: PluginContext): () => void {\n const { gallery } = ctx;\n const locale = (gallery.options.locale ?? {}) as Record<string, string>;\n const rotateLeftLabel = locale.rotateLeft ?? 'Rotate left';\n const rotateRightLabel = locale.rotateRight ?? 'Rotate right';\n const flipHLabel = locale.flipHorizontal ?? 'Flip horizontal';\n const flipVLabel = locale.flipVertical ?? 'Flip vertical';\n\n let state: RotateFlipState = { ...NEUTRAL };\n /** Unbounded — never wrapped like `state.rotation` is. Drives only the CSS transform's rotate() degrees, so every rotate click continues smoothly in the same direction (90, 180, 270, 360, 450, ...) instead of snapping backward whenever the normalized state wraps past 0/360. See `transformFor`'s own doc comment for the full reasoning. */\n let visualRotation = 0;\n /** Raw, independent toggles — never collapsed into a rotation the way `state.flipH`/`state.flipV` are. See `transformFor`'s own doc comment. */\n let visualFlipH = false;\n let visualFlipV = false;\n\n /**\n * `animate` mirrors the Zoom plugin's own \"discrete jumps animate\"\n * pattern (`zoom/index.ts`'s `withTransition`) — a button click is a\n * one-shot state change, not a continuous gesture, so it eases instead\n * of snapping. `reset()` stays unanimated: the viewer never rotated the\n * new slide, so there's nothing to visibly animate *from*. The\n * transition is cleared once it ends so it doesn't linger onto the\n * open/close zoom transition's own later use of this same element's\n * `transform` (`zoomTransition.ts`, `.shoji-slide-media`).\n */\n /** `item.width`/`height` when known, else the active image's own natural dimensions — same fallback order the zoom transition (§2.3b) uses for its own aspect ratio, just sourced from the slide itself rather than the origin thumbnail, and kept as a real width/height pair (not collapsed to a ratio) since `fitScaleFor` needs the actual pixel counts for its resolution ceiling, not just their proportion. `undefined` for anything else (video, an image not yet decoded) — `fitScaleFor` treats that as nothing to fit, not a guess. */\n function resolveNaturalSize(media: HTMLElement): { width: number; height: number } | undefined {\n const item = gallery.items[gallery.currentIndex];\n if (item?.width && item.height) return { width: item.width, height: item.height };\n const img = media.querySelector('img');\n if (img?.naturalWidth && img.naturalHeight) {\n return { width: img.naturalWidth, height: img.naturalHeight };\n }\n return undefined;\n }\n\n function apply(animate: boolean): void {\n const media = gallery.getActiveMedia();\n if (!media) return;\n const natural = resolveNaturalSize(media);\n const fitScale = fitScaleFor(\n media.clientWidth,\n media.clientHeight,\n visualRotation,\n natural?.width,\n natural?.height,\n );\n const transform = transformFor(visualFlipH, visualFlipV, visualRotation, fitScale);\n if (!animate) {\n media.style.transform = transform;\n return;\n }\n media.style.transition = 'transform var(--shoji-duration) var(--shoji-easing)';\n media.style.transform = transform;\n waitForTransitionEnd(media, () => {\n media.style.transition = '';\n });\n }\n\n function buildButton(icon: string, label: string): HTMLButtonElement {\n const button = document.createElement('button');\n button.type = 'button';\n button.className = 'shoji-toolbar-button';\n button.innerHTML = icon;\n button.setAttribute('aria-label', label);\n button.title = label;\n return button;\n }\n\n const rotateLeftBtn = buildButton(ROTATE_LEFT_ICON, rotateLeftLabel);\n const rotateRightBtn = buildButton(ROTATE_RIGHT_ICON, rotateRightLabel);\n const flipHBtn = buildButton(FLIP_H_ICON, flipHLabel);\n const flipVBtn = buildButton(FLIP_V_ICON, flipVLabel);\n flipHBtn.setAttribute('aria-pressed', 'false');\n flipVBtn.setAttribute('aria-pressed', 'false');\n\n /**\n * `visualRotationDelta`, when given, is a rotate click's own known\n * direction (±90) — applied as-is to the unbounded `visualRotation`.\n * Flip clicks omit it entirely: `visualFlipH`/`visualFlipV` are toggled\n * directly at each call site instead, completely independent of\n * `state`'s own canonicalized rotation/flip collapse (`transformFor`'s\n * doc comment has the full reasoning for why the two must stay\n * decoupled).\n */\n function update(patch: Partial<RotateFlipState>, visualRotationDelta?: number): void {\n state = normalizeRotateFlip({ ...state, ...patch });\n if (visualRotationDelta !== undefined) visualRotation += visualRotationDelta;\n apply(true);\n flipHBtn.setAttribute('aria-pressed', String(state.flipH));\n flipVBtn.setAttribute('aria-pressed', String(state.flipV));\n ctx.emit('rotateFlipChange', { index: gallery.currentIndex, ...state });\n }\n\n /**\n * A real bug, reported from real usage: with exactly one flip axis\n * active, \"Rotate right\" visually spun the image counter-clockwise\n * instead — a `+90` raw `rotate()` delta composed with a single\n * `scaleX(-1)`/`scaleY(-1)` mirror reverses the rotation's visual\n * handedness (a mirror is a reflection — determinant -1 — so a\n * clockwise turn *inside* it reads as counter-clockwise once mirrored\n * back onto the screen). Flipping *both* axes doesn't have this problem\n * — two reflections compose back into a rotation (determinant +1, same\n * as no flip at all), which is exactly why `normalizeRotateFlip`\n * already collapses that combination into a plain 180° rotation.\n * Inverting the raw delta whenever `visualFlipH !== visualFlipV` (XOR —\n * \"exactly one axis flipped\") makes the buttons always spin the image\n * the way they're visually labeled, regardless of flip state; `state`\n * (the canonicalized, emitted value) gets the same inverted delta, so\n * it stays an accurate description of what's actually on screen.\n */\n function rotateDelta(clockwise: boolean): number {\n const flippedOnOneAxis = visualFlipH !== visualFlipV;\n return clockwise !== flippedOnOneAxis ? 90 : -90;\n }\n\n rotateLeftBtn.addEventListener('click', () => {\n const delta = rotateDelta(false);\n update({ rotation: state.rotation + delta }, delta);\n });\n rotateRightBtn.addEventListener('click', () => {\n const delta = rotateDelta(true);\n update({ rotation: state.rotation + delta }, delta);\n });\n flipHBtn.addEventListener('click', () => {\n visualFlipH = !visualFlipH;\n update({ flipH: !state.flipH });\n });\n flipVBtn.addEventListener('click', () => {\n visualFlipV = !visualFlipV;\n update({ flipV: !state.flipV });\n });\n\n function reset(): void {\n state = { ...NEUTRAL };\n visualRotation = 0;\n visualFlipH = false;\n visualFlipV = false;\n apply(false);\n flipHBtn.setAttribute('aria-pressed', 'false');\n flipVBtn.setAttribute('aria-pressed', 'false');\n }\n\n // 'right' — registered in this order, so they cluster left-to-right as\n // rotateLeft, rotateRight, flipH, flipV, then whatever later plugin (or\n // the close button) follows (DESIGN.md §3.1).\n const removeButtons = [rotateLeftBtn, rotateRightBtn, flipHBtn, flipVBtn].map((button) =>\n ctx.ui.toolbar('right', button),\n );\n\n const offOpen = ctx.on('afterOpen', reset);\n const offSlide = ctx.on('afterSlide', reset);\n\n return () => {\n for (const remove of removeButtons) remove();\n offOpen();\n offSlide();\n };\n },\n};\n"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { ShojiPlugin } from '../../core/plugin';
|
|
2
|
+
/**
|
|
3
|
+
* DESIGN.md §4-video — renders `{ video: { provider: 'youtube', id } }`
|
|
4
|
+
* items (produced by `scan.ts`'s `data-shoji-video` host detection, or
|
|
5
|
+
* authored directly in dynamic mode) as a real YouTube embed instead of
|
|
6
|
+
* `SlideManager`'s native-`<video>` fallback, which can't play a YouTube
|
|
7
|
+
* URL at all. Purely a renderer: no options, no toolbar button, no poster/
|
|
8
|
+
* thumbnail handling — the slide shows nothing until the embed itself is
|
|
9
|
+
* ready, same as every other slide type. Autoplay (§4-autoplay) picks up
|
|
10
|
+
* real play/pause/ended sync automatically; see `youtube.ts`'s
|
|
11
|
+
* `wirePlayableContract`.
|
|
12
|
+
*/
|
|
13
|
+
export declare const Video: ShojiPlugin;
|