| 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,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 @@ | ||
| 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,24 +2,23 @@ photo-grid { |
| 2 | display: grid; | 2 | display: grid; |
| 3 | gap: 11px; | 3 | gap: 11px; |
| 4 | } | 4 | } |
| 5 | photo-grid > div { | 5 | photo-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 { |
| 12 | photo-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 { |
| 18 | photo-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 @@ | ||
| 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"; | ||