Every Images and Library-embedded gallery card shows the concept's Tag after Info. Tag opens the concept's Tag image dialog (image-tagging.mjs over the C1 controller) in the shared modal shell with the sticky tagging-modal footer: capture preview, manual tags, the four legacy engines with real defaults, explicit Generate, removable suggestion chips (D83), legacy model labels (D84), a Suggestions count (D91) and Retry where the controller allows it. The capture is released on close, route change and pagehide. The Tags page gains the concept's Auto-tag images for the image selected in Images (aria-disabled without one, D115) and real Images counts that follow every save. Retires D9, #245 D2 and #245 D3. The toast now uses the mock's copy verbatim ("tags" even for one), C1's D60/D61/D68 markers are renumbered to D83/D84/D91, and at extreme zoom a card's info row stacks (and wraps Info and Tag) when its row is narrower than the narrowest bounded card, list rows included (D31).
72 lines
4.0 KiB
JavaScript
72 lines
4.0 KiB
JavaScript
// The concept card (the concept stylesheet's `.media-card`): a 1px border around `aspect-ratio: 1.33` media and the measured
|
|
// `.media-info` row; a list row is the concept's 95px image plus that border.
|
|
const MEDIA_RATIO = 1.33, BORDER = 2, FOOTER_HEIGHT = 54, LIST_HEIGHT = 97;
|
|
// Bounded rendering keeps a card readable when zoom shrinks the grid below the concept's columns (D3, D31).
|
|
export const MINIMUM_CARD_WIDTH = 120;
|
|
const AUTOMATIC_CARD_WIDTH = 240;
|
|
// A list row's art: the concept's 160px, yielding to 45% of the row at extreme zoom (the overlay's D31).
|
|
const LIST_ART_WIDTH = 160, LIST_ART_SHARE = 0.45;
|
|
|
|
/**
|
|
* Whether a card's info row stacks its text above Info and Tag (D31): the row would be narrower than the narrowest
|
|
* bounded card, which only extreme zoom reaches. A list row's info is what its art leaves of the row.
|
|
*/
|
|
export function stacksCardInfo(width, mode) {
|
|
const info = mode === 'list' ? width - Math.min(LIST_ART_WIDTH, width * LIST_ART_SHARE) : width;
|
|
return info < MINIMUM_CARD_WIDTH;
|
|
}
|
|
|
|
/** The concept grid's own columns and gap, read from its computed style as the concept's mountMasonry does (D3). */
|
|
export function gridMetrics(grid) {
|
|
const style = getComputedStyle(grid);
|
|
const tracks = String(style.gridTemplateColumns || '').trim();
|
|
const gap = Number.parseFloat(style.columnGap);
|
|
return {columns: tracks && tracks !== 'none' ? tracks.split(/\s+/).length : 1, gap: Number.isFinite(gap) ? gap : 0};
|
|
}
|
|
|
|
export function layoutImages(images, width, mode, metrics = {}) {
|
|
const {gap = 13, footerHeight = FOOTER_HEIGHT, listHeight = LIST_HEIGHT} = metrics;
|
|
const fitting = Math.max(1, Math.floor((width + gap) / (MINIMUM_CARD_WIDTH + gap)));
|
|
const asked = metrics.columns ?? Math.max(1, Math.floor((width + gap) / (AUTOMATIC_CARD_WIDTH + gap)));
|
|
const columns = mode === 'list' ? 1 : Math.min(asked, fitting);
|
|
const cardWidth = Math.max(1, (width - gap * (columns - 1)) / columns);
|
|
const bottoms = Array(columns).fill(0);
|
|
let maxHeight = 0;
|
|
const positions = images.map((image, index) => {
|
|
const column = mode === 'masonry' ? bottoms.indexOf(Math.min(...bottoms)) : index % columns;
|
|
const ratio = Number.isFinite(image.width) && Number.isFinite(image.height) && image.width > 0 && image.height > 0 ? image.width / image.height : 4 / 3;
|
|
const mediaWidth = Math.max(1, cardWidth - BORDER);
|
|
const mediaHeight = mode === 'masonry' ? mediaWidth / ratio : mediaWidth / MEDIA_RATIO;
|
|
const height = mode === 'list' ? listHeight : (Number.isFinite(mediaHeight) ? mediaHeight : mediaWidth / MEDIA_RATIO) + footerHeight + BORDER;
|
|
maxHeight = Math.max(maxHeight, height);
|
|
const y = mode === 'grid' ? Math.floor(index / columns) * (height + gap) : bottoms[column];
|
|
bottoms[column] = y + height + gap;
|
|
return { x: column * (cardWidth + gap), y, width: cardWidth, height };
|
|
});
|
|
return { positions, columns, height: Math.max(0, ...bottoms) - (images.length ? gap : 0), maxHeight };
|
|
}
|
|
|
|
export function visibleImages(positions, top, viewportHeight, focusedIndex = -1, maxHeight = 1024) {
|
|
const buffer = Math.max(600, viewportHeight);
|
|
const firstTop = top - buffer - maxHeight;
|
|
const lastTop = top + viewportHeight + buffer;
|
|
let low = 0, high = positions.length;
|
|
// Every layout emits monotonically increasing tops, including shortest-column masonry.
|
|
while (low < high) {
|
|
const middle = Math.floor((low + high) / 2);
|
|
if (positions[middle].y < firstTop) low = middle + 1;
|
|
else high = middle;
|
|
}
|
|
const indexes = [];
|
|
for (let index = low; index < positions.length; index++) {
|
|
const position = positions[index];
|
|
if (position.y > lastTop) break;
|
|
if (index === focusedIndex || position.y + position.height >= top - buffer) indexes.push(index);
|
|
}
|
|
if (focusedIndex >= 0 && focusedIndex < positions.length && !indexes.includes(focusedIndex)) {
|
|
indexes.push(focusedIndex);
|
|
indexes.sort((a, b) => a - b);
|
|
}
|
|
return indexes;
|
|
}
|