Add Google Photos-style justified row layout as a mode within masonry: - Add masonryMode setting: 'columns' (Pinterest) or 'rows' (Google Photos) - Create justifiedLayoutCalculator utility for row-based layouts - Extract and store image dimensions on upload for layout calculations - Include width/height in gallery API response - Add row height and last row behavior controls to theme customizer - Support responsive container width detection with ResizeObserver Photos in rows mode maintain their aspect ratios while filling horizontal rows at a consistent height. The number of photos per row is automatically calculated based on target row height and photo dimensions. Closes #146
328 lines
8.6 KiB
TypeScript
328 lines
8.6 KiB
TypeScript
/**
|
|
* Justified/row-based gallery layout algorithm (similar to Google Photos or Flickr)
|
|
*
|
|
* This algorithm arranges photos in rows where each row has the same height,
|
|
* and photos are scaled to fit the container width exactly.
|
|
*/
|
|
|
|
export interface JustifiedPhoto {
|
|
id: number;
|
|
width: number;
|
|
height: number;
|
|
aspectRatio: number;
|
|
}
|
|
|
|
export interface JustifiedLayoutItem {
|
|
photoId: number;
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
rowIndex: number;
|
|
}
|
|
|
|
export interface JustifiedLayoutOptions {
|
|
containerWidth: number;
|
|
targetRowHeight: number;
|
|
spacing: number;
|
|
maxRowHeight?: number; // Maximum row height (for last row)
|
|
lastRowBehavior?: 'justify' | 'left' | 'center'; // How to handle the last row
|
|
}
|
|
|
|
export interface JustifiedLayoutResult {
|
|
items: JustifiedLayoutItem[];
|
|
containerHeight: number;
|
|
rowCount: number;
|
|
}
|
|
|
|
/**
|
|
* Get the aspect ratio for a photo, defaulting to 1:1 if dimensions are missing
|
|
*/
|
|
function getAspectRatio(photo: JustifiedPhoto): number {
|
|
// If aspectRatio is provided and valid, use it
|
|
if (photo.aspectRatio && photo.aspectRatio > 0 && isFinite(photo.aspectRatio)) {
|
|
return photo.aspectRatio;
|
|
}
|
|
|
|
// Calculate from width/height if both are valid
|
|
if (photo.width && photo.height && photo.width > 0 && photo.height > 0) {
|
|
return photo.width / photo.height;
|
|
}
|
|
|
|
// Default to square (1:1) if no valid dimensions
|
|
return 1;
|
|
}
|
|
|
|
/**
|
|
* Calculate the width a photo would have at a given height
|
|
*/
|
|
function getPhotoWidthAtHeight(photo: JustifiedPhoto, height: number): number {
|
|
return height * getAspectRatio(photo);
|
|
}
|
|
|
|
/**
|
|
* Calculate the total width of photos in a row at a given height, including spacing
|
|
*/
|
|
function calculateRowWidth(
|
|
photos: JustifiedPhoto[],
|
|
height: number,
|
|
spacing: number
|
|
): number {
|
|
if (photos.length === 0) return 0;
|
|
|
|
const photosWidth = photos.reduce(
|
|
(sum, photo) => sum + getPhotoWidthAtHeight(photo, height),
|
|
0
|
|
);
|
|
const spacingWidth = (photos.length - 1) * spacing;
|
|
|
|
return photosWidth + spacingWidth;
|
|
}
|
|
|
|
/**
|
|
* Calculate the exact height needed for a row to fit the container width
|
|
*/
|
|
function calculateRowHeight(
|
|
photos: JustifiedPhoto[],
|
|
containerWidth: number,
|
|
spacing: number
|
|
): number {
|
|
if (photos.length === 0) return 0;
|
|
|
|
// Total spacing between photos
|
|
const totalSpacing = (photos.length - 1) * spacing;
|
|
|
|
// Available width for actual photo content
|
|
const availableWidth = containerWidth - totalSpacing;
|
|
|
|
// Sum of aspect ratios determines how width is distributed
|
|
const totalAspectRatio = photos.reduce(
|
|
(sum, photo) => sum + getAspectRatio(photo),
|
|
0
|
|
);
|
|
|
|
// Height = available width / sum of aspect ratios
|
|
// This ensures all photos at this height exactly fill the available width
|
|
return availableWidth / totalAspectRatio;
|
|
}
|
|
|
|
/**
|
|
* Position photos in a row with calculated dimensions
|
|
*/
|
|
function positionRowPhotos(
|
|
photos: JustifiedPhoto[],
|
|
rowHeight: number,
|
|
startY: number,
|
|
rowIndex: number,
|
|
spacing: number,
|
|
containerWidth: number,
|
|
alignment: 'justify' | 'left' | 'center' = 'justify'
|
|
): JustifiedLayoutItem[] {
|
|
if (photos.length === 0) return [];
|
|
|
|
const items: JustifiedLayoutItem[] = [];
|
|
|
|
// Calculate actual widths at this row height
|
|
const photoWidths = photos.map(photo => getPhotoWidthAtHeight(photo, rowHeight));
|
|
const totalPhotoWidth = photoWidths.reduce((sum, w) => sum + w, 0);
|
|
const totalSpacing = (photos.length - 1) * spacing;
|
|
const totalRowWidth = totalPhotoWidth + totalSpacing;
|
|
|
|
// Calculate starting X position based on alignment
|
|
let startX = 0;
|
|
if (alignment === 'center') {
|
|
startX = (containerWidth - totalRowWidth) / 2;
|
|
} else if (alignment === 'left') {
|
|
startX = 0;
|
|
}
|
|
// For 'justify', startX is 0 and we'll adjust spacing below
|
|
|
|
let currentX = startX;
|
|
|
|
// For justified alignment, we might need to adjust spacing to fill the row exactly
|
|
let actualSpacing = spacing;
|
|
if (alignment === 'justify' && photos.length > 1) {
|
|
// Calculate the spacing needed to fill the container exactly
|
|
const widthDifference = containerWidth - totalRowWidth;
|
|
actualSpacing = spacing + widthDifference / (photos.length - 1);
|
|
}
|
|
|
|
for (let i = 0; i < photos.length; i++) {
|
|
const photo = photos[i];
|
|
const width = photoWidths[i];
|
|
|
|
items.push({
|
|
photoId: photo.id,
|
|
x: currentX,
|
|
y: startY,
|
|
width: width,
|
|
height: rowHeight,
|
|
rowIndex: rowIndex,
|
|
});
|
|
|
|
currentX += width + (i < photos.length - 1 ? actualSpacing : 0);
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
/**
|
|
* Main function to calculate the justified layout
|
|
*/
|
|
export function calculateJustifiedLayout(
|
|
photos: JustifiedPhoto[],
|
|
options: JustifiedLayoutOptions
|
|
): JustifiedLayoutResult {
|
|
const {
|
|
containerWidth,
|
|
targetRowHeight,
|
|
spacing,
|
|
maxRowHeight = targetRowHeight * 1.5,
|
|
lastRowBehavior = 'left',
|
|
} = options;
|
|
|
|
// Handle edge cases
|
|
if (photos.length === 0) {
|
|
return {
|
|
items: [],
|
|
containerHeight: 0,
|
|
rowCount: 0,
|
|
};
|
|
}
|
|
|
|
if (containerWidth <= 0) {
|
|
return {
|
|
items: [],
|
|
containerHeight: 0,
|
|
rowCount: 0,
|
|
};
|
|
}
|
|
|
|
const items: JustifiedLayoutItem[] = [];
|
|
const rows: JustifiedPhoto[][] = [];
|
|
let currentRow: JustifiedPhoto[] = [];
|
|
|
|
// Step 1: Assign photos to rows
|
|
for (const photo of photos) {
|
|
// Try adding this photo to the current row
|
|
const testRow = [...currentRow, photo];
|
|
const rowWidthAtTarget = calculateRowWidth(testRow, targetRowHeight, spacing);
|
|
|
|
if (rowWidthAtTarget <= containerWidth) {
|
|
// Photo fits in current row at target height
|
|
currentRow.push(photo);
|
|
} else if (currentRow.length === 0) {
|
|
// Single photo that's wider than container - it gets its own row
|
|
currentRow.push(photo);
|
|
rows.push(currentRow);
|
|
currentRow = [];
|
|
} else {
|
|
// Adding this photo would exceed container width
|
|
// Finalize current row and start new one
|
|
rows.push(currentRow);
|
|
currentRow = [photo];
|
|
}
|
|
}
|
|
|
|
// Don't forget the last row
|
|
if (currentRow.length > 0) {
|
|
rows.push(currentRow);
|
|
}
|
|
|
|
// Step 2: Calculate positions for each row
|
|
let currentY = 0;
|
|
|
|
for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
|
|
const row = rows[rowIndex];
|
|
const isLastRow = rowIndex === rows.length - 1;
|
|
|
|
// Calculate the height needed to justify this row
|
|
let rowHeight = calculateRowHeight(row, containerWidth, spacing);
|
|
|
|
// Determine alignment and height constraints for last row
|
|
let alignment: 'justify' | 'left' | 'center' = 'justify';
|
|
|
|
if (isLastRow) {
|
|
// For the last row, we might not want to stretch photos too much
|
|
if (lastRowBehavior === 'left' || lastRowBehavior === 'center') {
|
|
// Use target height for last row (or max height if calculated is larger)
|
|
if (rowHeight > maxRowHeight) {
|
|
rowHeight = maxRowHeight;
|
|
} else if (rowHeight > targetRowHeight * 1.2) {
|
|
// If photos would be stretched too much, cap at a reasonable height
|
|
rowHeight = targetRowHeight;
|
|
}
|
|
alignment = lastRowBehavior;
|
|
} else {
|
|
// Justify last row, but cap at max height
|
|
if (rowHeight > maxRowHeight) {
|
|
rowHeight = maxRowHeight;
|
|
alignment = 'left'; // Fall back to left align if we can't justify within max height
|
|
}
|
|
}
|
|
} else {
|
|
// For non-last rows, always justify (fit exactly to container)
|
|
// The calculated height should fit perfectly
|
|
}
|
|
|
|
// Position photos in this row
|
|
const rowItems = positionRowPhotos(
|
|
row,
|
|
rowHeight,
|
|
currentY,
|
|
rowIndex,
|
|
spacing,
|
|
containerWidth,
|
|
alignment
|
|
);
|
|
|
|
items.push(...rowItems);
|
|
currentY += rowHeight + spacing;
|
|
}
|
|
|
|
// Remove the last spacing (no spacing after the last row)
|
|
const containerHeight = currentY > 0 ? currentY - spacing : 0;
|
|
|
|
return {
|
|
items,
|
|
containerHeight,
|
|
rowCount: rows.length,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Helper function to create a JustifiedPhoto from raw photo data
|
|
* Handles missing or invalid dimensions gracefully
|
|
*/
|
|
export function createJustifiedPhoto(
|
|
id: number,
|
|
width?: number | null,
|
|
height?: number | null
|
|
): JustifiedPhoto {
|
|
const w = width && width > 0 ? width : 0;
|
|
const h = height && height > 0 ? height : 0;
|
|
|
|
let aspectRatio: number;
|
|
if (w > 0 && h > 0) {
|
|
aspectRatio = w / h;
|
|
} else {
|
|
aspectRatio = 1; // Default to square
|
|
}
|
|
|
|
return {
|
|
id,
|
|
width: w || 1,
|
|
height: h || 1,
|
|
aspectRatio,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Batch convert photo data to JustifiedPhoto array
|
|
*/
|
|
export function createJustifiedPhotos(
|
|
photos: Array<{ id: number; width?: number | null; height?: number | null }>
|
|
): JustifiedPhoto[] {
|
|
return photos.map(photo => createJustifiedPhoto(photo.id, photo.width, photo.height));
|
|
}
|