From 40d9d2642860ba857e6b46165315853ca6809923 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Mon, 23 Mar 2026 23:16:33 -0700 Subject: [PATCH] feat: image grid 2.0 resolves #77 progress for #90 --- package-lock.json | 7 +- src/tags/clover-media.marko | 48 ++++++++++++ src/tags/photo-grid.css | 33 ++++----- src/tags/photo-grid.marko | 107 +++++++++++++++++++++++++++ src/tags/photo-grid.tsx | 142 ------------------------------------ 5 files changed, 176 insertions(+), 161 deletions(-) create mode 100644 src/tags/clover-media.marko create mode 100644 src/tags/photo-grid.marko delete mode 100644 src/tags/photo-grid.tsx diff --git a/package-lock.json b/package-lock.json index e667cbd62e3f4b29b15e846123ac626421b09651..2727c61bb9ac9a81ea533a2b97ce2d52d8b5fc36 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,11 @@ } }, "lib": { - "name": "@clo/lib" + "name": "@clo/lib", + "dependencies": { + "@clo/lib": "file:.", + "@types/node": "^24.0.0" + } }, "node_modules/@clo/lib": { "resolved": "lib", @@ -976,7 +980,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } diff --git a/src/tags/clover-media.marko b/src/tags/clover-media.marko new file mode 100644 index 0000000000000000000000000000000000000000..041c200fcd1ada7c95a6c8990d94116ed19c8949 --- /dev/null +++ b/src/tags/clover-media.marko @@ -0,0 +1,48 @@ +export interface Input { + src: MediaFile | string; + alt: string; + displayWidthEstimate?: number | string; + align?: string; +} + + + + + +
ERROR: No file in database: ${src}
+ + + + + + + + w < width) /> + + + + `${base}:/${s}.${ext} ${s}w`) + ...{ sizes } + /> + + + + + +import * as transcodeRules from "#src/file-viewer/transcode-rules.ts"; +import { extsVideo } from "#src/file-viewer/rules.ts"; +import { MediaFile } from "#src/file-viewer/models/MediaFile.ts"; +import { UNWRAP } from "@clo/lib/assert"; +import { escapeUri } from "#src/file-viewer/format.ts"; diff --git a/src/tags/photo-grid.css b/src/tags/photo-grid.css index b85c5e8f86952a0c3501dc8ca30d758d89f5fc56..83e1e18d4afcfeabd584256ea0fe8a28cfdba0f0 100644 --- a/src/tags/photo-grid.css +++ b/src/tags/photo-grid.css @@ -2,24 +2,23 @@ photo-grid { display: grid; gap: 11px; } -photo-grid > div { +photo-grid-item { + display: block; position: relative; - width: 100%; - height: 100%; border: 2px solid black; outline: 4px solid var(--primary, #000); -} -photo-grid img { - position: absolute; - width: 100%; - height: 100%; - object-fit: cover; -} -photo-grid video { - position: absolute; - top: 0; - left: 0; - width: 100% !important; - height: 100% !important; - object-fit: cover; + img { + position: absolute; + width: 100%; + height: 100%; + object-fit: cover; + } + video { + position: absolute; + top: 0; + left: 0; + width: 100% !important; + height: 100% !important; + object-fit: cover; + } } diff --git a/src/tags/photo-grid.marko b/src/tags/photo-grid.marko new file mode 100644 index 0000000000000000000000000000000000000000..010f948fc52449f781bd68030ae0f55296f66440 --- /dev/null +++ b/src/tags/photo-grid.marko @@ -0,0 +1,107 @@ +import './photo-grid.css'; + +static const gap = 11; + +export interface Input { + base?: string; + cols: number[]; // weights + rows?: number[]; // pixel heights, based on a 768px container + style?: Marko.AttrStyle; + // images are placed left to right, top to bottom + img: Marko.AttrTag<{ + src: string; + w?: number; // column span + h?: number; // row span + s?: number; // skipped columns before placement + small?: unknown; + mutedLoop?: boolean; + align?: "top" | "center" | "bottom" | (string & {}); + }>; +} + +static interface Positioned { + x: number; + y: number; + w: number; + h: number; + align?: string; + src: string; +} + +static const alignments = { + bottom: "50% 80%", + "bottom-end": "50% 100%", + top: "50% 30%", + "top-end": "50% 0%", + left: "30% 50%", + "left-end": "0% 50%", + right: "80% 50%", + "right-end": "100% 50%", +}; + + + + + a + b, 0) + gap * (rows.length - 1) /> + + { + let occupied: boolean[][] = []; + const row = (y: number) => (occupied[y] ??= new Array(colCount).fill(false)); + const anyCollide = (x: number, y: number, w: number, h: number) => { + for (let yy = 0; yy < h; yy++) { + const r = row(y + yy); + for (let xx = 0; xx < w; xx++) if (r[x + xx]) return true; + } + return false; + }; + + let x = 0, y = 0; + let result: Positioned[] = []; + for (const img of input.img) { + const w = img.w ?? 1; + const h = img.h ?? 1; + const s = img.s ?? 0; + if (w > colCount) throw new Error(`image ${img.src} is wider than the grid`); + + x += s; + while (x > colCount - w || anyCollide(x, y, w, h)) { + x += 1; + if (x > colCount - w) x = 0, y += 1; + } + + result.push({ + x, + y, + w, + h, + src: base ? base + '/' + img.src : img.src, + align: alignments[img.align] ?? img.align, + }); + + for (let yy = 0; yy < h; yy++) { + const r = row(y + yy); + for (let xx = 0; xx < w; xx++) r[x + xx] = true; + } + + x += w; + if (x >= colCount) x = 0, y += 1; + } + return result; +})() /> + +<${'photo-grid'} + style={ + 'grid-template-columns': input.cols.map(x => x + 'fr').join(' '), + 'grid-template-rows': rows.map(x => (100 * x / rowSum).toFixed(1) + '%').join(' '), + 'aspect-ratio': `768/${rowSum}`, + ...style, + } +> + + <${'photo-grid-item'} style={ + 'grid-area': `${y + 1} / ${x + 1} / ${1 + y + h} / ${1 + x + w}`, + }> + + + + diff --git a/src/tags/photo-grid.tsx b/src/tags/photo-grid.tsx deleted file mode 100644 index 447ef74fb12ee5878b85fff63c3e941080b9ab12..0000000000000000000000000000000000000000 --- a/src/tags/photo-grid.tsx +++ /dev/null @@ -1,142 +0,0 @@ -const { isArray } = Array; - -export interface PhotoGridProps { - style: any; - base: string; - width: number; - widths?: string[]; - heights?: number[]; - items: Item[]; -} - -type Item = [img: string, options?: ItemOptions]; - -interface ItemOptions { - s?: number; - w?: number; - h?: number; - align?: keyof typeof alignments; -} - -const alignments = { - bottom: "50% 80%", - "bottom-end": "50% 100%", - top: "50% 30%", - "top-end": "50% 0%", - left: "30% 50%", - "left-end": "0% 50%", - right: "80% 50%", - "right-end": "100% 50%", -}; - -const typicalPageWidth = 768; - -export function PhotoGrid({ - style, - base, - width, - widths, - heights, - items, -}: PhotoGridProps) { - if (!base.endsWith("/")) base += "/"; - let rows: boolean[][] = []; - const row = (y: number) => (rows[y] ??= new Array(width).fill(false)); - const anyCollide = (x: number, y: number, w: number, h: number) => { - for (let yy = 0; yy < h; yy++) { - const r = row(y + yy); - for (let xx = 0; xx < w; xx++) if (r[x + xx]) return true; - } - return false; - }; - - const firstRowHeight = heights?.[0] ?? 325; - - let x = 0; - let y = 0; - let contents = []; - for (const item of items) { - const [ - img, - { w = 1, h = 1, align, s } = {}, - ] = isArray(item) ? item : [item]; - if (w > width) throw new Error(`Item ${img} too wide (${w} > ${width})`); - - x += s ?? 0; - - while (anyCollide(x, y, w, h)) { - x += 1; - if (x > width - w) x = 0, y += 1; - } - for (let yy = 0; yy < h; yy++) { - const r = row(y + yy); - for (let xx = 0; xx < w; xx++) r[x + xx] = true; - } - - const info = fileInfo(base, img); - contents.push( -
- )[align] ?? align - : "50% 50%", - }} - /> -
, - ); - } - - const height = rows.length; - return ( - (h / firstRowHeight) + "fr") - .concat(new Array(height - heights.length).fill("1fr")) - .join(" ") - : `repeat(${height}, 1fr)`, - }} - > - {contents} - - ); -} - -function fileInfo(base: string, img: string) { - const filePath = path.join(base, img); - const file = MediaFile.getByPath(filePath); - const dimensions = file?.parseDimensions(); - if (!dimensions) { - throw new Error(`File does not exist or has no dimensions: ${filePath}`); - } - const alt = path.basename(img, path.extname(img)); - return { src: file, alt, width: dimensions.width, height: dimensions.height }; -} - -function mapSizes(sizes: Array) { - return sizes.map(mapSize); -} -function mapSize(size: string | number) { - return typeof size === "number" ? size + "px" : size; -} - -import "./photo-grid.css"; -import { MediaFile } from "#src/file-viewer/models/MediaFile.ts"; -import Image from "./image.tsx"; -import * as path from "node:path"; -import { ASSERT } from "@clo/lib/assert"; -- 2.54.0