| author | |
| committer | |
| log | 40d9d2642860ba857e6b46165315853ca6809923 |
| tree | 7922e4594aca726d3db5a4e9b2103b5226a80459 |
| parent | fd419f88c1068452779bcc11869d8e4aff6cee48 |
| signature | Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs |
resolves #77
progress for #905 files changed, 176 insertions(+), 161 deletions(-)
package-lock.json+5-2| ... | ... | @@ -27,7 +27,11 @@ |
| 27 | 27 | } |
| 28 | 28 | }, |
| 29 | 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 | 36 | "node_modules/@clo/lib": { |
| 33 | 37 | "resolved": "lib", |
| ... | ... | @@ -976,7 +980,6 @@ |
| 976 | 980 | "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", |
| 977 | 981 | "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", |
| 978 | 982 | "license": "MIT", |
| 979 | "peer": true, | |
| 980 | 983 | "dependencies": { |
| 981 | 984 | "undici-types": "~7.16.0" |
| 982 | 985 | } |
src/tags/clover-media.marko created+48| ... | ... | @@ -0,0 +1,48 @@ |
| 1 | export 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 | ||
| 44 | import * as transcodeRules from "#src/file-viewer/transcode-rules.ts"; | |
| 45 | import { extsVideo } from "#src/file-viewer/rules.ts"; | |
| 46 | import { MediaFile } from "#src/file-viewer/models/MediaFile.ts"; | |
| 47 | import { UNWRAP } from "@clo/lib/assert"; | |
| 48 | import { escapeUri } from "#src/file-viewer/format.ts"; |
src/tags/photo-grid.css+16-17| ... | ... | @@ -2,24 +2,23 @@ photo-grid { |
| 2 | 2 | display: grid; |
| 3 | 3 | gap: 11px; |
| 4 | 4 | } |
| 5 | photo-grid > div { | |
| 5 | photo-grid-item { | |
| 6 | display: block; | |
| 6 | 7 | position: relative; |
| 7 | width: 100%; | |
| 8 | height: 100%; | |
| 9 | 8 | border: 2px solid black; |
| 10 | 9 | outline: 4px solid var(--primary, #000); |
| 11 | } | |
| 12 | photo-grid img { | |
| 13 | position: absolute; | |
| 14 | width: 100%; | |
| 15 | height: 100%; | |
| 16 | object-fit: cover; | |
| 17 | } | |
| 18 | photo-grid video { | |
| 19 | position: absolute; | |
| 20 | top: 0; | |
| 21 | left: 0; | |
| 22 | width: 100% !important; | |
| 23 | height: 100% !important; | |
| 24 | object-fit: cover; | |
| 10 | img { | |
| 11 | position: absolute; | |
| 12 | width: 100%; | |
| 13 | height: 100%; | |
| 14 | object-fit: cover; | |
| 15 | } | |
| 16 | video { | |
| 17 | position: absolute; | |
| 18 | top: 0; | |
| 19 | left: 0; | |
| 20 | width: 100% !important; | |
| 21 | height: 100% !important; | |
| 22 | object-fit: cover; | |
| 23 | } | |
| 25 | 24 | } |
src/tags/photo-grid.marko created+107| ... | ... | @@ -0,0 +1,107 @@ |
| 1 | import './photo-grid.css'; | |
| 2 | ||
| 3 | static const gap = 11; | |
| 4 | ||
| 5 | export 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 | ||
| 22 | static interface Positioned { | |
| 23 | x: number; | |
| 24 | y: number; | |
| 25 | w: number; | |
| 26 | h: number; | |
| 27 | align?: string; | |
| 28 | src: string; | |
| 29 | } | |
| 30 | ||
| 31 | static 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 @@ |
| 1 | const { isArray } = Array; | |
| 2 | ||
| 3 | export interface PhotoGridProps { | |
| 4 | style: any; | |
| 5 | base: string; | |
| 6 | width: number; | |
| 7 | widths?: string[]; | |
| 8 | heights?: number[]; | |
| 9 | items: Item[]; | |
| 10 | } | |
| 11 | ||
| 12 | type Item = [img: string, options?: ItemOptions]; | |
| 13 | ||
| 14 | interface ItemOptions { | |
| 15 | s?: number; | |
| 16 | w?: number; | |
| 17 | h?: number; | |
| 18 | align?: keyof typeof alignments; | |
| 19 | } | |
| 20 | ||
| 21 | const 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 | ||
| 32 | const typicalPageWidth = 768; | |
| 33 | ||
| 34 | export 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 | ||
| 120 | function 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 | ||
| 131 | function mapSizes(sizes: Array<string | number>) { | |
| 132 | return sizes.map(mapSize); | |
| 133 | } | |
| 134 | function mapSize(size: string | number) { | |
| 135 | return typeof size === "number" ? size + "px" : size; | |
| 136 | } | |
| 137 | ||
| 138 | import "./photo-grid.css"; | |
| 139 | import { MediaFile } from "#src/file-viewer/models/MediaFile.ts"; | |
| 140 | import Image from "./image.tsx"; | |
| 141 | import * as path from "node:path"; | |
| 142 | import { ASSERT } from "@clo/lib/assert"; |