authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-03-23 23:16:33-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-03-26 22:32:27-07:00
log40d9d2642860ba857e6b46165315853ca6809923
tree7922e4594aca726d3db5a4e9b2103b5226a80459
parentfd419f88c1068452779bcc11869d8e4aff6cee48
signature Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs

feat: image grid 2.0

resolves #77 progress for #90

5 files changed, 176 insertions(+), 161 deletions(-)

package-lock.json+5-2
...@@ -27,7 +27,11 @@...@@ -27,7 +27,11 @@
27 }27 }
28 },28 },
29 "lib": {29 "lib": {
30 "name": "@clo/lib"30 "name": "@clo/lib",
31 "dependencies": {
32 "@clo/lib": "file:.",
33 "@types/node": "^24.0.0"
34 }
31 },35 },
32 "node_modules/@clo/lib": {36 "node_modules/@clo/lib": {
33 "resolved": "lib",37 "resolved": "lib",
...@@ -976,7 +980,6 @@...@@ -976,7 +980,6 @@
976 "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz",980 "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz",
977 "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",981 "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
978 "license": "MIT",982 "license": "MIT",
979 "peer": true,
980 "dependencies": {983 "dependencies": {
981 "undici-types": "~7.16.0"984 "undici-types": "~7.16.0"
982 }985 }
src/tags/clover-media.marko created+48
...@@ -0,0 +1,48 @@
1export interface Input {
2 src: MediaFile | string;
3 alt: string;
4 displayWidthEstimate?: number | string;
5 align?: string;
6}
7
8<const/{ src, alt = "", displayWidthEstimate, align, ...rest } = input />
9<const/file = typeof src === 'object' ? src : MediaFile.getByPath(src) />
10
11<if=!file>
12 <div style={ color: 'red' }>ERROR: No file in database: ${src}</div>
13</><else if=extsVideo.has(file.extension.toLowerCase())>
14 <clover-video noFigure ...{ file } />
15</><else if=file.extension === '.gif'>
16 <img src=`/file${escapeUri(file.path)}` ...rest />
17</><else>
18 <const/base = `/file${escapeUri(file.path)}` />
19 <const/{ width } = UNWRAP(file.parseDimensions()) />
20 <const/targetSizes = transcodeRules.imageSizes.filter((w) => w < width) />
21 <const/sizes =
22 displayWidthEstimate
23 ? typeof displayWidthEstimate === "number"
24 ? `${displayWidthEstimate}px`
25 : displayWidthEstimate
26 : `100vw`
27 />
28 <picture>
29 <for|ext| of=[ "jxl", "avif", "webp" ]>
30 <source
31 type=`image/${ext}`
32 srcset=targetSizes.map((s) => `${base}:/${s}.${ext} ${s}w`)
33 ...{ sizes }
34 />
35 </>
36 <img
37 src=base
38 style=align ? { 'object-position': align, width: '100%', height: '100%' } : undefined
39 ...rest
40 />
41 </picture>
42</>
43
44import * as transcodeRules from "#src/file-viewer/transcode-rules.ts";
45import { extsVideo } from "#src/file-viewer/rules.ts";
46import { MediaFile } from "#src/file-viewer/models/MediaFile.ts";
47import { UNWRAP } from "@clo/lib/assert";
48import { escapeUri } from "#src/file-viewer/format.ts";
src/tags/photo-grid.css+16-17
...@@ -2,24 +2,23 @@ photo-grid {...@@ -2,24 +2,23 @@ photo-grid {
2 display: grid;2 display: grid;
3 gap: 11px;3 gap: 11px;
4}4}
5photo-grid > div {5photo-grid-item {
6 display: block;
6 position: relative;7 position: relative;
7 width: 100%;
8 height: 100%;
9 border: 2px solid black;8 border: 2px solid black;
10 outline: 4px solid var(--primary, #000);9 outline: 4px solid var(--primary, #000);
11}10 img {
12photo-grid img {11 position: absolute;
13 position: absolute;12 width: 100%;
14 width: 100%;13 height: 100%;
15 height: 100%;14 object-fit: cover;
16 object-fit: cover;15 }
17}16 video {
18photo-grid video {17 position: absolute;
19 position: absolute;18 top: 0;
20 top: 0;19 left: 0;
21 left: 0;20 width: 100% !important;
22 width: 100% !important;21 height: 100% !important;
23 height: 100% !important;22 object-fit: cover;
24 object-fit: cover;23 }
25}24}
src/tags/photo-grid.marko created+107
...@@ -0,0 +1,107 @@
1import './photo-grid.css';
2
3static const gap = 11;
4
5export interface Input {
6 base?: string;
7 cols: number[]; // weights
8 rows?: number[]; // pixel heights, based on a 768px container
9 style?: Marko.AttrStyle;
10 // images are placed left to right, top to bottom
11 img: Marko.AttrTag<{
12 src: string;
13 w?: number; // column span
14 h?: number; // row span
15 s?: number; // skipped columns before placement
16 small?: unknown;
17 mutedLoop?: boolean;
18 align?: "top" | "center" | "bottom" | (string & {});
19 }>;
20}
21
22static interface Positioned {
23 x: number;
24 y: number;
25 w: number;
26 h: number;
27 align?: string;
28 src: string;
29}
30
31static const alignments = {
32 bottom: "50% 80%",
33 "bottom-end": "50% 100%",
34 top: "50% 30%",
35 "top-end": "50% 0%",
36 left: "30% 50%",
37 "left-end": "0% 50%",
38 right: "80% 50%",
39 "right-end": "100% 50%",
40};
41
42<const/{ base, cols, style } = input />
43<const/rows = input.rows ?? [325] />
44<const/colCount = input.cols.length />
45<const/rowSum = rows.reduce((a, b) => a + b, 0) + gap * (rows.length - 1) />
46
47<const/items = (() => {
48 let occupied: boolean[][] = [];
49 const row = (y: number) => (occupied[y] ??= new Array(colCount).fill(false));
50 const anyCollide = (x: number, y: number, w: number, h: number) => {
51 for (let yy = 0; yy < h; yy++) {
52 const r = row(y + yy);
53 for (let xx = 0; xx < w; xx++) if (r[x + xx]) return true;
54 }
55 return false;
56 };
57
58 let x = 0, y = 0;
59 let result: Positioned[] = [];
60 for (const img of input.img) {
61 const w = img.w ?? 1;
62 const h = img.h ?? 1;
63 const s = img.s ?? 0;
64 if (w > colCount) throw new Error(`image ${img.src} is wider than the grid`);
65
66 x += s;
67 while (x > colCount - w || anyCollide(x, y, w, h)) {
68 x += 1;
69 if (x > colCount - w) x = 0, y += 1;
70 }
71
72 result.push({
73 x,
74 y,
75 w,
76 h,
77 src: base ? base + '/' + img.src : img.src,
78 align: alignments[img.align] ?? img.align,
79 });
80
81 for (let yy = 0; yy < h; yy++) {
82 const r = row(y + yy);
83 for (let xx = 0; xx < w; xx++) r[x + xx] = true;
84 }
85
86 x += w;
87 if (x >= colCount) x = 0, y += 1;
88 }
89 return result;
90})() />
91
92<${'photo-grid'}
93 style={
94 'grid-template-columns': input.cols.map(x => x + 'fr').join(' '),
95 'grid-template-rows': rows.map(x => (100 * x / rowSum).toFixed(1) + '%').join(' '),
96 'aspect-ratio': `768/${rowSum}`,
97 ...style,
98 }
99>
100 <for|{ x, y, w, h, src, align }| of=items>
101 <${'photo-grid-item'} style={
102 'grid-area': `${y + 1} / ${x + 1} / ${1 + y + h} / ${1 + x + w}`,
103 }>
104 <clover-media ...{ src, align } />
105 </>
106 </for>
107</>
src/tags/photo-grid.tsx deleted-142
...@@ -1,142 +0,0 @@
1const { isArray } = Array;
2
3export interface PhotoGridProps {
4 style: any;
5 base: string;
6 width: number;
7 widths?: string[];
8 heights?: number[];
9 items: Item[];
10}
11
12type Item = [img: string, options?: ItemOptions];
13
14interface ItemOptions {
15 s?: number;
16 w?: number;
17 h?: number;
18 align?: keyof typeof alignments;
19}
20
21const alignments = {
22 bottom: "50% 80%",
23 "bottom-end": "50% 100%",
24 top: "50% 30%",
25 "top-end": "50% 0%",
26 left: "30% 50%",
27 "left-end": "0% 50%",
28 right: "80% 50%",
29 "right-end": "100% 50%",
30};
31
32const typicalPageWidth = 768;
33
34export function PhotoGrid({
35 style,
36 base,
37 width,
38 widths,
39 heights,
40 items,
41}: PhotoGridProps) {
42 if (!base.endsWith("/")) base += "/";
43 let rows: boolean[][] = [];
44 const row = (y: number) => (rows[y] ??= new Array(width).fill(false));
45 const anyCollide = (x: number, y: number, w: number, h: number) => {
46 for (let yy = 0; yy < h; yy++) {
47 const r = row(y + yy);
48 for (let xx = 0; xx < w; xx++) if (r[x + xx]) return true;
49 }
50 return false;
51 };
52
53 const firstRowHeight = heights?.[0] ?? 325;
54
55 let x = 0;
56 let y = 0;
57 let contents = [];
58 for (const item of items) {
59 const [
60 img,
61 { w = 1, h = 1, align, s } = {},
62 ] = isArray(item) ? item : [item];
63 if (w > width) throw new Error(`Item ${img} too wide (${w} > ${width})`);
64
65 x += s ?? 0;
66
67 while (anyCollide(x, y, w, h)) {
68 x += 1;
69 if (x > width - w) x = 0, y += 1;
70 }
71 for (let yy = 0; yy < h; yy++) {
72 const r = row(y + yy);
73 for (let xx = 0; xx < w; xx++) r[x + xx] = true;
74 }
75
76 const info = fileInfo(base, img);
77 contents.push(
78 <div
79 style={{
80 ...x === 0 && y === 0 &&
81 {
82 "aspect-ratio": ((w / width) * typicalPageWidth) / firstRowHeight,
83 },
84 "grid-area": `${y + 1} / ${x + 1} / ${1 + y + h} / ${1 + x + w}`,
85 }}
86 >
87 <Image
88 {...info}
89 displayWidthEstimate={Math.ceil((w / width) * 736)}
90 style={{
91 "object-position": align
92 ? (alignments as Record<string, string>)[align] ?? align
93 : "50% 50%",
94 }}
95 />
96 </div>,
97 );
98 }
99
100 const height = rows.length;
101 return (
102 <photo-grid
103 style={{
104 ...style,
105 "grid-template-columns": widths
106 ? mapSizes(widths).join(" ")
107 : `repeat(${width}, 1fr)`,
108 "grid-template-rows": heights
109 ? heights.map((h) => (h / firstRowHeight) + "fr")
110 .concat(new Array(height - heights.length).fill("1fr"))
111 .join(" ")
112 : `repeat(${height}, 1fr)`,
113 }}
114 >
115 {contents}
116 </photo-grid>
117 );
118}
119
120function fileInfo(base: string, img: string) {
121 const filePath = path.join(base, img);
122 const file = MediaFile.getByPath(filePath);
123 const dimensions = file?.parseDimensions();
124 if (!dimensions) {
125 throw new Error(`File does not exist or has no dimensions: ${filePath}`);
126 }
127 const alt = path.basename(img, path.extname(img));
128 return { src: file, alt, width: dimensions.width, height: dimensions.height };
129}
130
131function mapSizes(sizes: Array<string | number>) {
132 return sizes.map(mapSize);
133}
134function mapSize(size: string | number) {
135 return typeof size === "number" ? size + "px" : size;
136}
137
138import "./photo-grid.css";
139import { MediaFile } from "#src/file-viewer/models/MediaFile.ts";
140import Image from "./image.tsx";
141import * as path from "node:path";
142import { ASSERT } from "@clo/lib/assert";