// AspectRatioUtils.js - Shared utilities for aspect ratio calculations and rendering
/**
* Utility class for aspect ratio calculations and icon generation
* Used by AspectRatioSelector and PresetManagerDialog
*/
export class AspectRatioUtils {
/**
* Calculate aspect ratio from width and height
* @param {number} width - Width in pixels
* @param {number} height - Height in pixels
* @returns {string} Aspect ratio string (e.g., "16:9")
*/
static calculateAspectRatio(width, height) {
const gcd = (a, b) => b === 0 ? a : gcd(b, a % b);
const divisor = gcd(width, height);
const w = width / divisor;
const h = height / divisor;
// Round to common aspect ratios
const ratio = w / h;
const commonRatios = {
'21:9': 21/9,
'16:9': 16/9,
'3:2': 3/2,
'4:3': 4/3,
'5:4': 5/4,
'1:1': 1,
'4:5': 4/5,
'3:4': 3/4,
'2:3': 2/3,
'9:16': 9/16,
'9:21': 9/21
};
// Find closest common ratio (within 2% tolerance)
let closestRatio = `${w}:${h}`;
let minDiff = Infinity;
for (const [name, value] of Object.entries(commonRatios)) {
const diff = Math.abs(ratio - value);
if (diff < minDiff && diff < 0.02) {
minDiff = diff;
closestRatio = name;
}
}
return closestRatio;
}
/**
* Dynamically generate SVG icon for aspect ratio
* @param {number} width - Width in pixels
* @param {number} height - Height in pixels
* @returns {string} SVG markup as string
*/
static getAspectRatioIcon(width, height) {
const iconSize = 20;
const padding = 2;
const maxDim = iconSize - padding * 2;
const aspect = width / height;
let rectWidth, rectHeight;
if (aspect > 1) {
rectWidth = maxDim;
rectHeight = maxDim / aspect;
} else {
rectHeight = maxDim;
rectWidth = maxDim * aspect;
}
const x = (iconSize - rectWidth) / 2;
const y = (iconSize - rectHeight) / 2;
const radius = Math.min(2, rectWidth / 4, rectHeight / 4);
return ``;
}
/**
* Group presets by aspect ratio
* @param {Object} presets - Object with preset names as keys and {width, height} as values
* @returns {Object} Grouped presets by aspect ratio, sorted
*/
static groupPresetsByAspectRatio(presets) {
const grouped = {};
for (const [name, dimensions] of Object.entries(presets)) {
const ratio = this.calculateAspectRatio(dimensions.width, dimensions.height);
if (!grouped[ratio]) {
grouped[ratio] = [];
}
grouped[ratio].push({
name,
width: dimensions.width,
height: dimensions.height,
pixels: dimensions.width * dimensions.height,
isCustom: dimensions.isCustom || false,
isHidden: dimensions.isHidden || false
});
}
// Sort presets within each group by pixel count (descending)
for (const ratio in grouped) {
grouped[ratio].sort((a, b) => b.pixels - a.pixels);
}
// Sort aspect ratios by their numeric value (landscape to portrait)
const sortedGrouped = {};
const sortedRatios = Object.keys(grouped).sort((a, b) => {
const [aw, ah] = a.split(':').map(Number);
const [bw, bh] = b.split(':').map(Number);
return (bw / bh) - (aw / ah);
});
for (const ratio of sortedRatios) {
sortedGrouped[ratio] = grouped[ratio];
}
return sortedGrouped;
}
/**
* Creates a preset column with list of presets for a given aspect ratio
* Unified method used by both AspectRatioSelector and PresetAddViewRenderer
* @param {string} ratio - Aspect ratio string (e.g., "16:9")
* @param {Array} presetList - Array of preset objects
* @param {Object} options - Configuration options
* @param {Function} options.renderPresetItem - Function to render individual preset item
* @returns {HTMLElement} Column element
*/
static createPresetColumn(ratio, presetList, options = {}) {
const column = document.createElement('div');
column.className = 'resolution-master-aspect-ratio-column';
// Icon at the top
const firstPreset = presetList[0];
const iconContainer = document.createElement('div');
iconContainer.className = 'resolution-master-aspect-ratio-column-icon';
iconContainer.innerHTML = this.getAspectRatioIcon(firstPreset.width, firstPreset.height);
column.appendChild(iconContainer);
// Ratio text below icon
const ratioText = document.createElement('div');
ratioText.className = 'resolution-master-aspect-ratio-column-title';
ratioText.textContent = ratio;
column.appendChild(ratioText);
// Preset list (vertical scrollable)
const presetListContainer = document.createElement('div');
presetListContainer.className = 'resolution-master-aspect-ratio-column-list';
// Render each preset using provided function
presetList.forEach(preset => {
const presetItem = options.renderPresetItem
? options.renderPresetItem(preset)
: this.createDefaultPresetItem(preset, options);
presetListContainer.appendChild(presetItem);
});
column.appendChild(presetListContainer);
return column;
}
/**
* Creates a default preset item (used by AspectRatioSelector)
* @param {Object} preset - Preset object
* @param {Object} options - Configuration options
* @returns {HTMLElement} Preset item element
*/
static createDefaultPresetItem(preset, options = {}) {
const { selectedPreset, customPresetIcon, onPresetClick } = options;
const isSelected = selectedPreset === preset.name;
const presetItem = document.createElement('div');
presetItem.className = 'resolution-master-aspect-ratio-preset-item' + (isSelected ? ' selected' : '');
presetItem.setAttribute('data-preset-item', 'true');
presetItem.setAttribute('data-preset-name', preset.name);
presetItem.setAttribute('data-preset-dimensions', `${preset.width}×${preset.height}`);
// Preset name with custom icon if applicable
const nameDiv = document.createElement('div');
nameDiv.className = 'resolution-master-aspect-ratio-preset-item-name';
const customIcon = preset.isCustom && customPresetIcon ?
`
` : '';
nameDiv.innerHTML = `${preset.name}${customIcon}`;
// Dimensions below name
const dimensionsDiv = document.createElement('div');
dimensionsDiv.className = 'resolution-master-aspect-ratio-preset-item-dims';
dimensionsDiv.textContent = `${preset.width}×${preset.height}`;
presetItem.appendChild(nameDiv);
presetItem.appendChild(dimensionsDiv);
// Click handler
if (onPresetClick) {
presetItem.addEventListener('click', () => onPresetClick(preset.name));
}
return presetItem;
}
/**
* Updates scroll indicators for columns (adds "↓ Scroll for more" if needed)
* @param {Array} columns - Array of column elements
*/
static updateColumnScrollIndicators(columns) {
columns.forEach(column => {
// Preset list container is the 3rd child (after icon and ratio text)
const presetListContainer = column.children[2];
let scrollIndicator = column.children[3];
const needsIndicator = presetListContainer &&
presetListContainer.scrollHeight > presetListContainer.clientHeight;
if (needsIndicator && !scrollIndicator) {
scrollIndicator = document.createElement('div');
scrollIndicator.className = 'resolution-master-aspect-ratio-column-scroll-indicator';
scrollIndicator.textContent = '↓ Scroll for more';
column.appendChild(scrollIndicator);
} else if (!needsIndicator && scrollIndicator) {
column.removeChild(scrollIndicator);
}
});
}
/**
* Creates and manages horizontal scroll indicator ("→ Scroll right for more")
* @param {HTMLElement} scrollWrapper - The scrollable wrapper element
* @param {HTMLElement} container - The parent container to append indicator to
* @param {Object} state - Object to store indicator reference {indicator: null}
* @returns {Function} Update function to call when checking if indicator is needed
*/
static createHorizontalScrollManager(scrollWrapper, container, state) {
const updateIndicator = () => {
const needsIndicator = scrollWrapper.scrollWidth > scrollWrapper.clientWidth;
if (needsIndicator && !state.indicator) {
// Create and add indicator
state.indicator = document.createElement('div');
state.indicator.className = 'resolution-master-aspect-ratio-horizontal-scroll-indicator';
state.indicator.textContent = '→ Scroll right for more';
// Enable horizontal scrolling with mouse wheel over the indicator
state.indicator.addEventListener('wheel', (e) => {
e.preventDefault();
scrollWrapper.scrollLeft += e.deltaY;
}, { passive: false });
container.appendChild(state.indicator);
} else if (!needsIndicator && state.indicator) {
// Remove indicator (with safety check to prevent errors)
if (state.indicator.parentNode === container) {
container.removeChild(state.indicator);
}
state.indicator = null;
}
};
return updateIndicator;
}
}