collageiov4 / script.js
rththr's picture
Upload 3 files
447b3d3 verified
// MLBB Collage Maker - export + spacing + groups + robust selection
const STARTUP_TEMPLATE_KEY = 'mlbb_startup_template_json';
const ENDPOINT_ZIP_URL = 'https://huggingface.co/rththr/collage/resolve/main/mlbb%20stuffs.zip';
const ENDPOINT_TEMPLATE_URL = 'https://huggingface.co/rththr/collage/resolve/main/template.json';
const CACHE_DB_NAME = 'mlbb_cache_db';
const CACHE_STORE_NAME = 'assets';
const CACHE_ZIP_KEY = 'endpoint_zip_blob';
const CACHE_TEMPLATE_KEY = 'endpoint_template_json';
const CACHE_META_KEY = 'mlbb_endpoint_cache_meta';
const CACHE_PROCESSED_FILES_KEY = 'endpoint_processed_files_v1';
const CACHE_PROCESSED_VERSION = 2;
const GEMINI_API_KEY_KEY = 'mlbb_gemini_api_key';
const GEMINI_MODEL_KEY = 'mlbb_gemini_model';
const GEMINI_AUTO_APPLY_KEY = 'mlbb_gemini_auto_apply';
const GEMINI_DEFAULT_MODEL = 'gemini-3-flash-preview';
const GEMINI_UPLOAD_JPEG_QUALITY = 0.85;
const GEMINI_UPLOAD_MAX_DIM = 1400;
const COLLAGE_JPEG_QUALITY = 0.93;
const STITCH_JPEG_QUALITY = 0.95;
const STITCH_TILE_BORDER_WIDTH = 2;
const EMBED_MARKER = 'MLBB_PROJECT_V1::';
const UI_SIDEBAR_COLLAPSED_KEY = 'mlbb_sidebar_collapsed';
const UI_COMPACT_KEY = 'mlbb_ui_compact';
const UI_CONTRAST_KEY = 'mlbb_ui_contrast';
const UI_MARKDOWN_PREVIEW_KEY = 'mlbb_markdown_preview';
const state = {
appData: {
files: {},
skinWidth: 335,
skinHeight: 300,
cols: 5,
rows: 1,
gap: 0,
bgColor: '#ffffff'
},
uploadedImages: [],
selected: new Set(),
pendingPlacement: [],
multiMode: false,
history: [],
historyIndex: -1,
cropper: null,
cropMode: 'batch',
pendingImages: [],
previewUrl: null,
stitchedBlob: null,
stitchedPreviewUrl: null,
stitchedFilename: '',
panning: false,
panX: 0,
panY: 0,
startPanX: 0,
startPanY: 0,
panDownClientX: 0,
panDownClientY: 0,
panMoved: false,
panStartedOnSkin: false,
suppressSkinClickUntil: 0,
activePointers: new Map(),
pinchStartDistance: 0,
pinchStartScale: 0,
scale: 0.5,
theme: 'light',
viewIndices: [],
groupOrder: [],
templateSlots: [],
autoSuggestions: [],
selectedGroup: '',
groupDrag: null,
multiPlacementOrder: 'asc',
activeTab: 'collage',
ocrInputFiles: [],
ocrStackResults: [],
ocrProcessedBatchFiles: [],
ocrZipBlob: null,
ocrZipName: '',
ocrAllImageBlob: null,
ocrAllImageName: '',
geminiZipFile: null,
geminiResponseText: '',
geminiModelsMeta: [],
binItems: [],
binSelected: new Set(),
pendingBinPlacement: [],
searchQuery: '',
drawerOpen: false,
loadingProgress: 0,
sidebarCollapsed: false,
uiCompact: false,
uiContrast: false,
markdownPreview: false,
elements: {}
};
function el(id) {
return state.elements[id] || null;
}
async function init() {
disableBrowserDialogs();
cacheElements();
initTheme();
initUiPreferences();
bindEvents();
initGeminiSettings();
setActiveTab('collage');
const loadedCache = await loadFromCachedAssets({
reset: false,
silent: true,
showOverlay: true,
persistAndRender: false
});
if (!loadedCache) {
const loadedExternal = loadStartupTemplateFromStorage();
if (!loadedExternal) {
loadEmbeddedTemplate();
}
}
syncInputsFromState();
refreshGroupFilter();
saveState();
render();
if (window.matchMedia('(max-width: 980px)').matches) {
fitCollageToViewport();
}
}
function normalizeTemplateData(data) {
if (!data || typeof data !== 'object') throw new Error('Invalid template data');
const items = [];
if (data.files && typeof data.files === 'object') {
const list = Array.isArray(data.files) ? data.files : Object.values(data.files);
list.forEach((f) => {
if (!f) return;
const name = typeof f === 'string' ? f : (f.name || '');
if (!name) return;
items.push({
name,
group: (typeof f === 'object' && f.group) ? f.group : 'Uncategorized'
});
});
}
if (items.length === 0 && data.groups && typeof data.groups === 'object') {
Object.entries(data.groups).forEach(([groupName, groupItems]) => {
const list = Array.isArray(groupItems) ? groupItems : [];
list.forEach((item) => {
const name = typeof item === 'string' ? item : (item?.name || '');
if (!name) return;
items.push({
name,
group: (typeof item === 'object' && item.group) ? item.group : groupName
});
});
});
}
if (items.length === 0 && data.filesByGroup && typeof data.filesByGroup === 'object') {
Object.entries(data.filesByGroup).forEach(([groupName, groupItems]) => {
const list = Array.isArray(groupItems) ? groupItems : [];
list.forEach((item) => {
const name = typeof item === 'string' ? item : (item?.name || '');
if (!name) return;
items.push({
name,
group: (typeof item === 'object' && item.group) ? item.group : groupName
});
});
});
}
if (items.length === 0) throw new Error('Template contains no files');
const skinWidth = clampNum(parseInt(data.skinWidth, 10), 20, 3000, state.appData.skinWidth);
const skinHeight = clampNum(parseInt(data.skinHeight, 10), 20, 3000, state.appData.skinHeight);
const colsRaw = parseInt(data.cols, 10);
const rowsRaw = parseInt(data.rows, 10);
const gap = clampNum(parseInt(data.gap, 10), 0, 100, state.appData.gap);
const bgColor = normalizeHex(data.bgColor || state.appData.bgColor);
const groupOrder = Array.isArray(data.groupOrder) ? data.groupOrder.map((g) => String(g || '').trim()).filter(Boolean) : [];
return {
skinWidth,
skinHeight,
cols: Number.isFinite(colsRaw) && colsRaw > 0 ? colsRaw : null,
rows: Number.isFinite(rowsRaw) && rowsRaw > 0 ? rowsRaw : null,
gap,
bgColor,
groupOrder,
files: items
};
}
function applyNormalizedTemplate(template) {
state.uploadedImages.forEach((x) => {
if (x.url && x.url.startsWith('blob:')) URL.revokeObjectURL(x.url);
});
state.appData.skinWidth = template.skinWidth;
state.appData.skinHeight = template.skinHeight;
state.appData.gap = template.gap;
state.appData.bgColor = template.bgColor;
state.uploadedImages = template.files.map((f) => ({
name: f.name,
group: f.group || 'Uncategorized',
url: null,
file: null
}));
state.templateSlots = template.files.map((f) => ({
name: f.name,
group: f.group || 'Uncategorized'
}));
autoArrangeGrid(template.files.length);
state.groupOrder = Array.isArray(template.groupOrder) ? template.groupOrder.slice() : [];
state.selectedGroup = '';
state.groupDrag = null;
ensureGroupOrder();
state.selected.clear();
state.pendingPlacement = [];
state.pendingBinPlacement = [];
refreshGroupFilter();
}
function loadStartupTemplateFromStorage() {
try {
const raw = localStorage.getItem(STARTUP_TEMPLATE_KEY);
if (!raw) return false;
const data = JSON.parse(raw);
const normalized = normalizeTemplateData(data);
applyNormalizedTemplate(normalized);
return true;
} catch (err) {
console.error('Startup template load failed:', err);
return false;
}
}
function cacheElements() {
const ids = [
'collage-area', 'viewport', 'empty-state', 'ocr-workspace', 'ocrResults', 'profile-workspace', 'profileFrame',
'btnLoadImages', 'btnLoadZip', 'btnLoadProjectImage', 'btnSaveTemplate', 'btnLoadTemplate', 'btnDownload', 'btnStitchProfile', 'btnSetStartup', 'btnClearStartup', 'btnFetchEndpoint', 'btnResetFromCache',
'btnInsertImage',
'btnTabCollage', 'btnTabOcr', 'btnTabProfile', 'tabCollage', 'tabOcr', 'tabProfile',
'btnUndo', 'btnRedo', 'btnMulti', 'btnDelete', 'btnSwap', 'btnClearAll',
'btnFloatDelete', 'btnFloatDeselect', 'floating-panel', 'selection-status', 'activeSkinsBadge',
'imgLoader', 'zipLoader', 'insertImageLoader', 'projectImageLoader', 'templateLoader', 'startupLoader', 'ocrLoader', 'ocrBatchLoader',
'gridCols', 'gridRows', 'skinW', 'skinH', 'gapSize', 'gapValue',
'zoomLevel', 'zoomValue', 'bgColor',
'themeSelect', 'groupFilter', 'groupOrderList', 'groupPosSlider', 'groupPosLabel', 'btnGroupUp', 'btnGroupDown', 'multiOrder', 'autoOrderInput', 'btnApplyAutoOrder', 'btnImportOcr', 'btnClearAutoOrder', 'autoOrderMissing', 'autoSuggestionList',
'insertNameInput', 'insertPositionInput', 'insertGroupInput', 'btnInsertNameAtPos',
'image-processor-modal', 'image-processor-preview',
'cropW', 'cropH', 'btnApplyCrop', 'btnCancelCrop', 'btnConfirmCrop', 'btnCloseCrop', 'cropModalTitle', 'cropModalHint',
'stitchPreviewModal', 'stitchPreviewImage', 'btnCloseStitchPreview', 'btnCloseStitchPreviewSecondary', 'btnDownloadStitchPreview',
'insertMetaPanel', 'insertCropName', 'insertCropPosition', 'insertCropGroupSelect', 'insertCropNewGroupWrap', 'insertCropNewGroup',
'loading-overlay', 'loading-progress-bar', 'loading-progress-label', 'loading-progress-detail', 'btnQuickLoad', 'btnOcrLoadImages', 'ocrBatchSize', 'btnProcessOcrStack', 'btnDownloadOcrZip', 'ocrStatus',
'geminiApiKey', 'geminiModel', 'geminiPrompt', 'geminiAutoApplyCollage', 'btnFetchGeminiModels', 'geminiModelList', 'geminiModelInfo', 'geminiModelLimits', 'btnChooseGeminiZip', 'btnSendGemini', 'geminiZipLoader', 'geminiZipInfo', 'geminiResult', 'btnDownloadGeminiResult', 'btnToggleMarkdownPreview', 'geminiMarkdownPreview',
'btnFillEmpty', 'btnOpenDrawer', 'btnCloseDrawer', 'utilityDrawer', 'utilityScrim', 'drawerSearch', 'drawerSearchStatus', 'btnClearSearch',
'binCount', 'binList', 'btnPlaceBinSelected', 'btnRestoreBinAll', 'btnEmptyBin',
'btnToggleSidebar', 'btnToggleSidebarFab', 'toggleCompactUi', 'toggleStrongContrast',
'sidebarScrim', 'mobileQuickBar', 'btnMobileControls', 'btnMobileZoomOut', 'btnMobileZoomIn', 'btnMobileFit', 'btnMobileBin',
'btnOpenProfileStandalone'
];
ids.forEach((id) => {
state.elements[id] = document.getElementById(id);
});
}
function initTheme() {
const saved = localStorage.getItem('mlbb_theme');
if (saved) state.theme = saved;
applyTheme(state.theme);
}
function initUiPreferences() {
const savedSidebar = localStorage.getItem(UI_SIDEBAR_COLLAPSED_KEY);
state.sidebarCollapsed = savedSidebar === '1';
if (savedSidebar === null && window.matchMedia('(max-width: 980px)').matches) {
state.sidebarCollapsed = true;
}
state.uiCompact = localStorage.getItem(UI_COMPACT_KEY) === '1';
state.uiContrast = localStorage.getItem(UI_CONTRAST_KEY) === '1';
state.markdownPreview = localStorage.getItem(UI_MARKDOWN_PREVIEW_KEY) === '1';
applyUiPreferences();
}
function applyTheme(theme) {
state.theme = theme;
document.body.dataset.theme = theme;
localStorage.setItem('mlbb_theme', theme);
}
function applyUiPreferences() {
document.body.classList.toggle('sidebar-collapsed', !!state.sidebarCollapsed);
document.body.classList.toggle('ui-compact', !!state.uiCompact);
document.body.classList.toggle('ui-contrast', !!state.uiContrast);
if (el('toggleCompactUi')) el('toggleCompactUi').checked = !!state.uiCompact;
if (el('toggleStrongContrast')) el('toggleStrongContrast').checked = !!state.uiContrast;
const mdPreview = el('geminiMarkdownPreview');
const mdToggle = el('btnToggleMarkdownPreview');
if (mdPreview) mdPreview.classList.toggle('hidden', !state.markdownPreview);
if (mdToggle) mdToggle.classList.toggle('active', !!state.markdownPreview);
renderGeminiMarkdownPreview();
}
function setSidebarCollapsed(collapsed) {
state.sidebarCollapsed = !!collapsed;
localStorage.setItem(UI_SIDEBAR_COLLAPSED_KEY, state.sidebarCollapsed ? '1' : '0');
applyUiPreferences();
}
function bindEvents() {
el('btnLoadImages')?.addEventListener('click', () => el('imgLoader')?.click());
el('btnLoadZip')?.addEventListener('click', () => el('zipLoader')?.click());
el('btnLoadProjectImage')?.addEventListener('click', () => el('projectImageLoader')?.click());
el('btnInsertImage')?.addEventListener('click', () => el('insertImageLoader')?.click());
el('btnSaveTemplate')?.addEventListener('click', saveTemplate);
el('btnLoadTemplate')?.addEventListener('click', () => el('templateLoader')?.click());
el('btnDownload')?.addEventListener('click', downloadCollageJpeg);
el('btnStitchProfile')?.addEventListener('click', stitchProfileAndCollage);
el('btnDownloadStitchPreview')?.addEventListener('click', downloadStitchedPreviewImage);
el('btnCloseStitchPreview')?.addEventListener('click', closeStitchPreviewModal);
el('btnCloseStitchPreviewSecondary')?.addEventListener('click', closeStitchPreviewModal);
el('stitchPreviewModal')?.addEventListener('click', (event) => {
if (event.target === el('stitchPreviewModal')) closeStitchPreviewModal();
});
el('btnSetStartup')?.addEventListener('click', () => el('startupLoader')?.click());
el('btnClearStartup')?.addEventListener('click', clearStartupTemplate);
el('btnFetchEndpoint')?.addEventListener('click', fetchAndCacheEndpointAssets);
el('btnResetFromCache')?.addEventListener('click', () => loadFromCachedAssets({ reset: true }));
el('btnUndo')?.addEventListener('click', undo);
el('btnRedo')?.addEventListener('click', redo);
el('btnMulti')?.addEventListener('click', toggleMulti);
el('btnDelete')?.addEventListener('click', deleteSelected);
el('btnSwap')?.addEventListener('click', swapSelected);
el('btnFloatDelete')?.addEventListener('click', deleteSelected);
el('btnFloatDeselect')?.addEventListener('click', clearSelection);
el('btnClearAll')?.addEventListener('click', clearAll);
el('btnFillEmpty')?.addEventListener('click', fillSelectedIntoEmptySlots);
el('btnOpenDrawer')?.addEventListener('click', () => toggleUtilityDrawer(true));
el('btnCloseDrawer')?.addEventListener('click', () => toggleUtilityDrawer(false));
el('utilityScrim')?.addEventListener('click', () => toggleUtilityDrawer(false));
el('btnToggleSidebar')?.addEventListener('click', () => setSidebarCollapsed(!state.sidebarCollapsed));
el('btnToggleSidebarFab')?.addEventListener('click', () => setSidebarCollapsed(false));
el('sidebarScrim')?.addEventListener('click', () => setSidebarCollapsed(true));
el('btnMobileControls')?.addEventListener('click', () => setSidebarCollapsed(!state.sidebarCollapsed));
el('btnMobileZoomOut')?.addEventListener('click', () => adjustZoom(-0.1));
el('btnMobileZoomIn')?.addEventListener('click', () => adjustZoom(0.1));
el('btnMobileFit')?.addEventListener('click', fitCollageToViewport);
el('btnMobileBin')?.addEventListener('click', () => toggleUtilityDrawer(true));
el('toggleCompactUi')?.addEventListener('change', (e) => {
state.uiCompact = !!e.target.checked;
localStorage.setItem(UI_COMPACT_KEY, state.uiCompact ? '1' : '0');
applyUiPreferences();
});
el('toggleStrongContrast')?.addEventListener('change', (e) => {
state.uiContrast = !!e.target.checked;
localStorage.setItem(UI_CONTRAST_KEY, state.uiContrast ? '1' : '0');
applyUiPreferences();
});
el('btnToggleMarkdownPreview')?.addEventListener('click', () => {
state.markdownPreview = !state.markdownPreview;
localStorage.setItem(UI_MARKDOWN_PREVIEW_KEY, state.markdownPreview ? '1' : '0');
applyUiPreferences();
});
el('btnQuickLoad')?.addEventListener('click', loadSampleData);
el('btnTabCollage')?.addEventListener('click', () => setActiveTab('collage'));
el('btnTabOcr')?.addEventListener('click', () => setActiveTab('ocr'));
el('btnTabProfile')?.addEventListener('click', () => setActiveTab('profile'));
el('btnOpenProfileStandalone')?.addEventListener('click', () => {
const src = el('profileFrame')?.getAttribute('src') || '../profile/try.html';
window.open(src, '_blank', 'noopener');
});
el('btnOcrLoadImages')?.addEventListener('click', () => el('ocrBatchLoader')?.click());
el('ocrBatchLoader')?.addEventListener('change', handleOcrBatchSelect);
el('btnProcessOcrStack')?.addEventListener('click', processOcrStackBatches);
el('btnDownloadOcrZip')?.addEventListener('click', downloadOcrZip);
el('btnChooseGeminiZip')?.addEventListener('click', () => el('geminiZipLoader')?.click());
el('geminiZipLoader')?.addEventListener('change', handleGeminiZipSelect);
el('btnSendGemini')?.addEventListener('click', sendZipToGemini);
el('btnDownloadGeminiResult')?.addEventListener('click', downloadGeminiResult);
el('btnFetchGeminiModels')?.addEventListener('click', fetchGeminiModels);
el('geminiApiKey')?.addEventListener('input', persistGeminiSettings);
el('geminiModel')?.addEventListener('input', () => {
persistGeminiSettings();
updateSelectedGeminiModelInfo();
});
el('geminiAutoApplyCollage')?.addEventListener('change', persistGeminiSettings);
el('themeSelect')?.addEventListener('change', (e) => applyTheme(e.target.value));
el('groupFilter')?.addEventListener('change', () => {
state.selected.clear();
state.pendingPlacement = [];
render();
});
el('multiOrder')?.addEventListener('change', (e) => {
state.multiPlacementOrder = e.target.value === 'desc' ? 'desc' : 'asc';
updateSelectionUi();
});
el('btnApplyAutoOrder')?.addEventListener('click', applyAutoCollageOrder);
el('btnInsertNameAtPos')?.addEventListener('click', insertNameAtPosition);
['insertNameInput', 'insertPositionInput', 'insertGroupInput'].forEach((id) => {
el(id)?.addEventListener('keydown', (e) => {
if (e.key !== 'Enter') return;
e.preventDefault();
insertNameAtPosition();
});
});
el('btnImportOcr')?.addEventListener('click', () => el('ocrLoader')?.click());
el('ocrLoader')?.addEventListener('change', handleOcrImport);
el('btnClearAutoOrder')?.addEventListener('click', () => {
if (el('autoOrderInput')) el('autoOrderInput').value = '';
if (el('autoOrderMissing')) el('autoOrderMissing').value = '';
state.autoSuggestions = [];
renderAutoSuggestions();
});
el('drawerSearch')?.addEventListener('input', (e) => setSearchQuery(e.target.value));
el('btnClearSearch')?.addEventListener('click', () => {
if (el('drawerSearch')) el('drawerSearch').value = '';
setSearchQuery('');
});
el('btnRestoreBinAll')?.addEventListener('click', restoreAllFromBin);
el('btnPlaceBinSelected')?.addEventListener('click', placeSelectedBinItemsToGrid);
el('btnEmptyBin')?.addEventListener('click', emptyBin);
el('binList')?.addEventListener('click', (e) => {
const btn = e.target.closest('[data-bin-restore]');
if (btn) {
const idx = parseInt(btn.dataset.binRestore, 10);
if (!Number.isFinite(idx)) return;
restoreBinItem(idx);
return;
}
const row = e.target.closest('[data-bin-index]');
if (!row) return;
const idx = parseInt(row.dataset.binIndex, 10);
if (!Number.isFinite(idx)) return;
toggleBinSelection(idx);
});
el('groupOrderList')?.addEventListener('click', (e) => {
const item = e.target.closest('.group-order-item');
if (!item) return;
selectGroupForOrder(item.dataset.group || '');
});
el('autoSuggestionList')?.addEventListener('click', (e) => {
const btn = e.target.closest('.suggest-chip');
if (!btn) return;
const req = btn.dataset.req || '';
const suggestion = btn.dataset.suggestion || '';
applySuggestedReplacement(req, suggestion);
});
el('groupOrderList')?.addEventListener('pointerdown', startGroupDrag);
document.addEventListener('pointermove', onGroupDragMove);
document.addEventListener('pointerup', endGroupDrag);
el('groupPosSlider')?.addEventListener('input', (e) => {
const next = clampNum(parseInt(e.target.value, 10) - 1, 0, Math.max(0, state.groupOrder.length - 1), 0);
moveSelectedGroupTo(next);
});
el('btnGroupUp')?.addEventListener('click', () => {
const idx = state.groupOrder.indexOf(state.selectedGroup);
if (idx < 0) return;
moveSelectedGroupTo(idx - 1);
});
el('btnGroupDown')?.addEventListener('click', () => {
const idx = state.groupOrder.indexOf(state.selectedGroup);
if (idx < 0) return;
moveSelectedGroupTo(idx + 1);
});
el('imgLoader')?.addEventListener('change', handleFiles);
el('zipLoader')?.addEventListener('change', handleFiles);
el('insertImageLoader')?.addEventListener('change', handleInsertImageFile);
el('projectImageLoader')?.addEventListener('change', handleProjectImageLoad);
el('templateLoader')?.addEventListener('change', loadTemplate);
el('startupLoader')?.addEventListener('change', setStartupTemplate);
el('insertCropGroupSelect')?.addEventListener('change', onInsertCropGroupChange);
el('btnApplyCrop')?.addEventListener('click', applyCustomCropSize);
el('btnCancelCrop')?.addEventListener('click', closeCropModal);
el('btnCloseCrop')?.addEventListener('click', closeCropModal);
el('btnConfirmCrop')?.addEventListener('click', cropAndMapImages);
el('image-processor-modal')?.addEventListener('click', (e) => {
if (e.target === e.currentTarget) closeCropModal();
});
['gridCols', 'gridRows', 'skinW', 'skinH', 'zoomLevel', 'gapSize', 'bgColor'].forEach((id) => {
el(id)?.addEventListener('input', debounce(() => {
syncStateFromInputs();
saveState();
render();
}, 90));
});
const viewport = el('viewport');
viewport?.addEventListener('pointerdown', onViewportPointerDown);
viewport?.addEventListener('pointermove', onViewportPointerMove);
viewport?.addEventListener('pointerup', onViewportPointerUp);
viewport?.addEventListener('pointercancel', onViewportPointerUp);
viewport?.addEventListener('wheel', onWheelZoom, { passive: false });
document.addEventListener('keydown', handleKeydown);
window.addEventListener('resize', debounce(() => {
if (window.matchMedia('(max-width: 980px)').matches) fitCollageToViewport();
}, 140));
}
function loadEmbeddedTemplate() {
const raw = document.getElementById('embedded-collage-data')?.textContent;
if (!raw) return;
try {
const parsed = JSON.parse(raw);
const normalized = normalizeTemplateData(parsed);
applyNormalizedTemplate(normalized);
} catch (err) {
console.error('Embedded template parse failed:', err);
}
}
function syncInputsFromState() {
if (el('gridCols')) el('gridCols').value = state.appData.cols;
if (el('gridRows')) el('gridRows').value = state.appData.rows;
if (el('skinW')) el('skinW').value = state.appData.skinWidth;
if (el('skinH')) el('skinH').value = state.appData.skinHeight;
if (el('gapSize')) el('gapSize').value = state.appData.gap;
if (el('bgColor')) el('bgColor').value = state.appData.bgColor;
if (el('zoomLevel')) el('zoomLevel').value = state.scale;
if (el('themeSelect')) el('themeSelect').value = state.theme;
if (el('multiOrder')) el('multiOrder').value = state.multiPlacementOrder;
updateZoomText();
updateGapText();
updateInsertPositionBounds();
}
function syncStateFromInputs() {
state.appData.cols = clampNum(parseInt(el('gridCols')?.value, 10), 1, 200, state.appData.cols || 5);
state.appData.rows = clampNum(parseInt(el('gridRows')?.value, 10), 1, 5000, state.appData.rows || 1);
state.appData.skinWidth = clampNum(parseInt(el('skinW')?.value, 10), 20, 3000, 335);
state.appData.skinHeight = clampNum(parseInt(el('skinH')?.value, 10), 20, 3000, 300);
state.appData.gap = clampNum(parseInt(el('gapSize')?.value, 10), 0, 100, state.appData.gap || 0);
state.appData.bgColor = normalizeHex(el('bgColor')?.value || '#ffffff');
state.scale = clampNum(parseFloat(el('zoomLevel')?.value), 0.1, 2, 0.5);
updateZoomText();
updateGapText();
}
function updateInsertPositionBounds() {
const posInput = el('insertPositionInput');
const maxPos = Math.max(1, state.uploadedImages.length + 1);
if (posInput) {
const currentRaw = parseInt(posInput.value, 10);
posInput.min = '1';
posInput.max = String(maxPos);
posInput.value = String(clampNum(currentRaw, 1, maxPos, maxPos));
}
const cropPos = el('insertCropPosition');
if (cropPos) {
const currentRaw = parseInt(cropPos.value, 10);
cropPos.min = '1';
cropPos.max = String(maxPos);
cropPos.value = String(clampNum(currentRaw, 1, maxPos, maxPos));
}
}
function updateZoomText() {
const text = `${Math.round(state.scale * 100)}%`;
if (el('zoomValue')) el('zoomValue').textContent = text;
}
function updateGapText() {
if (el('gapValue')) el('gapValue').textContent = `${state.appData.gap}px`;
}
function getVisibleCount() {
return Math.max(1, state.appData.cols) * Math.max(1, state.appData.rows);
}
function getFilteredIndices() {
const group = el('groupFilter')?.value || 'all';
const all = [];
state.uploadedImages.forEach((item, i) => {
if (group === 'all' || (item.group || 'Uncategorized') === group) all.push(i);
});
return all;
}
function autoArrangeGrid(totalItems = state.uploadedImages.length) {
const total = clampNum(parseInt(totalItems, 10), 0, 999999, 0);
if (total <= 0) {
state.appData.cols = 1;
state.appData.rows = 1;
syncInputsFromState();
return;
}
const suggestedCols = Math.ceil(Math.sqrt(total));
state.appData.cols = clampNum(suggestedCols, 1, 200, 5);
state.appData.rows = Math.ceil(total / state.appData.cols);
syncInputsFromState();
}
function autoFitGridToAllItems() {
autoArrangeGrid(state.uploadedImages.length);
}
function ensureTemplateSlotsMatchUploaded() {
if (!Array.isArray(state.templateSlots)) state.templateSlots = [];
for (let i = state.templateSlots.length; i < state.uploadedImages.length; i += 1) {
const source = state.uploadedImages[i] || {};
state.templateSlots.push({
name: source.name || `Slot ${i + 1}`,
group: source.group || 'Uncategorized'
});
}
for (let i = state.uploadedImages.length; i < state.templateSlots.length; i += 1) {
state.uploadedImages.push(makePlaceholderAtIndex(i, state.templateSlots[i]));
}
}
function insertItemAtPosition(payload = {}) {
const name = String(payload.name || '').trim();
if (!name) throw new Error('Missing name');
ensureTemplateSlotsMatchUploaded();
const maxPos = Math.max(1, state.uploadedImages.length + 1);
const requested = parseInt(payload.position, 10);
const position = clampNum(requested, 1, maxPos, maxPos);
const index = position - 1;
let group = String(payload.group || '').trim();
if (!group) {
group = state.templateSlots[index]?.group
|| state.uploadedImages[index]?.group
|| state.selectedGroup
|| 'Uncategorized';
}
const url = payload.url || null;
const file = payload.file || null;
const slot = { name, group };
const item = { name, group, url, file };
state.templateSlots.splice(index, 0, slot);
state.uploadedImages.splice(index, 0, item);
const minRows = Math.ceil(state.uploadedImages.length / Math.max(1, state.appData.cols));
state.appData.rows = Math.max(state.appData.rows || 1, minRows);
state.selected.clear();
state.pendingPlacement = [];
state.pendingBinPlacement = [];
state.groupDrag = null;
ensureGroupOrder();
refreshGroupFilter();
syncInputsFromState();
saveState();
render();
return { position, index, group };
}
function insertNameAtPosition() {
const name = String(el('insertNameInput')?.value || '').trim();
if (!name) {
alert('Enter the new image name first.');
return;
}
const rawPos = parseInt(el('insertPositionInput')?.value, 10);
const group = String(el('insertGroupInput')?.value || '').trim();
const inserted = insertItemAtPosition({ name, position: rawPos, group });
if (el('insertNameInput')) el('insertNameInput').value = '';
if (el('insertPositionInput')) {
const nextPos = Math.min(inserted.position + 1, state.uploadedImages.length + 1);
el('insertPositionInput').value = String(nextPos);
}
notify(`Inserted "${name}" at position ${inserted.position}.`);
}
function ensureGroupOrder() {
const groups = Array.from(new Set(state.uploadedImages.map((x) => x.group || 'Uncategorized')));
state.groupOrder = state.groupOrder.filter((g) => groups.includes(g));
groups.forEach((g) => {
if (!state.groupOrder.includes(g)) state.groupOrder.push(g);
});
return state.groupOrder.slice();
}
function selectGroupForOrder(group) {
if (!group) return;
state.selectedGroup = group;
refreshGroupOrderControls();
}
function refreshGroupOrderControls() {
const list = el('groupOrderList');
const slider = el('groupPosSlider');
const label = el('groupPosLabel');
if (!list || !slider || !label) return;
const groups = ensureGroupOrder();
if (!groups.includes(state.selectedGroup)) state.selectedGroup = groups[0] || '';
list.innerHTML = '';
groups.forEach((g, i) => {
const item = document.createElement('div');
item.className = 'group-order-item';
if (g === state.selectedGroup) item.classList.add('selected');
if (state.groupDrag?.group === g) item.classList.add('dragging');
item.dataset.group = g;
item.dataset.index = String(i);
item.innerHTML = `<span class="group-order-handle" title="Drag to reorder">::</span><span class="group-order-rank">${i + 1}</span><span class="group-order-name">${g}</span>`;
list.appendChild(item);
});
const selectedIndex = groups.indexOf(state.selectedGroup);
const hasSelection = selectedIndex >= 0;
slider.disabled = !hasSelection;
slider.min = '1';
slider.max = String(Math.max(1, groups.length));
slider.value = hasSelection ? String(selectedIndex + 1) : '1';
if (!hasSelection) {
label.textContent = 'Position: -';
} else {
label.textContent = `Position: ${selectedIndex + 1}/${groups.length}`;
}
}
function reorderImagesByGroupOrder() {
const order = ensureGroupOrder();
const rank = new Map(order.map((g, i) => [g, i]));
state.uploadedImages = state.uploadedImages
.map((item, idx) => ({ item, idx }))
.sort((a, b) => {
const ga = a.item.group || 'Uncategorized';
const gb = b.item.group || 'Uncategorized';
const ra = rank.has(ga) ? rank.get(ga) : Number.MAX_SAFE_INTEGER;
const rb = rank.has(gb) ? rank.get(gb) : Number.MAX_SAFE_INTEGER;
if (ra !== rb) return ra - rb;
return a.idx - b.idx;
})
.map((x) => x.item);
}
function moveGroupTo(group, positionIndex) {
if (!group) return false;
const currentIndex = state.groupOrder.indexOf(group);
if (currentIndex < 0) return false;
const maxIndex = state.groupOrder.length - 1;
const nextIndex = clampNum(positionIndex, 0, Math.max(0, maxIndex), currentIndex);
if (nextIndex === currentIndex) return false;
state.groupOrder.splice(currentIndex, 1);
state.groupOrder.splice(nextIndex, 0, group);
state.selectedGroup = group;
return true;
}
function moveSelectedGroupTo(positionIndex) {
const group = state.selectedGroup;
if (!group) return;
if (!moveGroupTo(group, positionIndex)) {
refreshGroupOrderControls();
return;
}
reorderImagesByGroupOrder();
state.selected.clear();
state.pendingPlacement = [];
saveState();
render();
}
function startGroupDrag(e) {
const list = el('groupOrderList');
const item = e.target.closest('.group-order-item');
if (!list || !item || !list.contains(item)) return;
if (!e.target.closest('.group-order-handle')) return;
if (e.pointerType === 'mouse' && e.button !== 0) return;
const group = item.dataset.group || '';
const index = parseInt(item.dataset.index, 10);
if (!group || !Number.isFinite(index)) return;
list.setPointerCapture?.(e.pointerId);
state.selectedGroup = group;
state.groupDrag = {
pointerId: e.pointerId,
group,
lastIndex: index
};
item.classList.add('dragging');
refreshGroupOrderControls();
e.preventDefault();
}
function onGroupDragMove(e) {
const drag = state.groupDrag;
if (!drag || e.pointerId !== drag.pointerId) return;
const list = el('groupOrderList');
if (!list) return;
const items = Array.from(list.querySelectorAll('.group-order-item'));
if (!items.length) return;
let next = drag.lastIndex;
for (let i = 0; i < items.length; i += 1) {
const rect = items[i].getBoundingClientRect();
if (e.clientY < rect.top + (rect.height / 2)) {
next = i;
break;
}
next = i;
}
if (next !== drag.lastIndex && moveGroupTo(drag.group, next)) {
drag.lastIndex = next;
reorderImagesByGroupOrder();
state.selected.clear();
state.pendingPlacement = [];
render();
}
}
function endGroupDrag(e) {
const drag = state.groupDrag;
if (!drag || e.pointerId !== drag.pointerId) return;
el('groupOrderList')?.releasePointerCapture?.(e.pointerId);
state.groupDrag = null;
saveState();
render();
}
function normalizeNameKey(name) {
return String(name || '')
.trim()
.replace(/^\-\s*/, '')
.replace(/\s+/g, ' ')
.toLowerCase();
}
function normalizeGroupKey(group) {
return String(group || '').trim().toLowerCase();
}
function getNameTokens(name) {
const key = normalizeNameKey(name)
.replace(/[^a-z0-9\s]+/g, ' ');
return key.split(' ').map((t) => t.trim()).filter(Boolean);
}
function getNameVariants(name) {
const raw = String(name || '').trim();
if (!raw) return [];
const set = new Set();
const add = (v) => {
const k = normalizeNameKey(v);
if (k) set.add(k);
};
add(raw);
const noDash = raw.replace(/^\-\s*/, '');
add(noDash);
const noExt = noDash.replace(/\.[^.]+$/, '');
add(noExt);
return [...set];
}
function parseAutoOrderInput(text) {
const rawLines = String(text || '').split(/\r?\n/);
const entries = [];
let currentGroup = '';
const parseRankedName = (value) => {
const textValue = String(value || '').trim();
if (!textValue) return null;
const match = textValue.match(/^#?\s*(\d{1,6})\s*[\])}.:_\-]+\s*(.+)$/) || textValue.match(/^#?\s*(\d{1,6})\s+(.+)$/);
if (!match) return null;
const rank = parseInt(match[1], 10);
const cleanName = String(match[2] || '').trim();
if (!Number.isFinite(rank) || !cleanName) return null;
return { rank, cleanName };
};
rawLines.forEach((raw) => {
const line = String(raw || '').trim();
if (!line) return;
const bracketHeader = line.match(/^\[(.+?)\]$/);
if (bracketHeader) {
currentGroup = bracketHeader[1].trim();
return;
}
const groupHeader = line.match(/^group\s*[:\-]\s*(.+)$/i);
if (groupHeader) {
currentGroup = groupHeader[1].trim();
return;
}
if (/^[^|]{2,40}:$/.test(line)) {
currentGroup = line.slice(0, -1).trim();
return;
}
let name = line;
let explicitGroup = '';
let rank = null;
const pipeSplit = line.split('|');
if (pipeSplit.length >= 2) {
const left = pipeSplit[0].trim();
const right = pipeSplit.slice(1).join('|').trim();
if (/^\d{1,6}$/.test(left)) {
rank = parseInt(left, 10);
name = right;
} else {
explicitGroup = left;
name = right;
}
}
if (!rank) {
const ranked = parseRankedName(name);
if (ranked) {
rank = ranked.rank;
name = ranked.cleanName;
}
}
name = String(name || '').trim();
if (!name) return;
entries.push({
raw: line,
name,
rank: Number.isFinite(rank) ? rank : null,
group: explicitGroup || currentGroup || '',
groupKey: normalizeGroupKey(explicitGroup || currentGroup || '')
});
});
return entries;
}
function normalizeCompactName(name) {
return String(name || '').toLowerCase().replace(/[^a-z0-9]/g, '');
}
function levenshtein(a, b) {
const matrix = [];
for (let i = 0; i <= b.length; i += 1) matrix[i] = [i];
for (let j = 0; j <= a.length; j += 1) matrix[0][j] = j;
for (let i = 1; i <= b.length; i += 1) {
for (let j = 1; j <= a.length; j += 1) {
if (b.charAt(i - 1) === a.charAt(j - 1)) matrix[i][j] = matrix[i - 1][j - 1];
else matrix[i][j] = Math.min(matrix[i - 1][j - 1] + 1, Math.min(matrix[i][j - 1] + 1, matrix[i - 1][j] + 1));
}
}
return matrix[b.length][a.length];
}
function buildCandidateMap(pool, req) {
const map = new Map();
pool.forEach((entry) => {
if (entry.used) return;
if (req.groupKey && req.groupKey !== entry.groupKey) return;
const key = normalizeCompactName(entry.item.name);
if (!key) return;
if (!map.has(key)) map.set(key, entry);
});
return map;
}
function findBestFuzzyMatch(pool, req) {
const cleanTarget = normalizeCompactName(req.name);
if (!cleanTarget) return null;
const candidateMap = buildCandidateMap(pool, req);
if (candidateMap.has(cleanTarget)) return candidateMap.get(cleanTarget);
for (const [key, entry] of candidateMap.entries()) {
if ((key.includes(cleanTarget) || cleanTarget.includes(key)) && Math.min(key.length, cleanTarget.length) > 3) {
return entry;
}
}
let bestEntry = null;
let bestDist = Infinity;
for (const [key, entry] of candidateMap.entries()) {
const dist = levenshtein(cleanTarget, key);
if (dist < bestDist && dist <= 5) {
bestDist = dist;
bestEntry = entry;
}
}
return bestEntry;
}
function getTopSuggestions(pool, req, limit = 3) {
const cleanTarget = normalizeCompactName(req.name);
if (!cleanTarget) return [];
const candidateMap = buildCandidateMap(pool, req);
const results = [];
if (candidateMap.has(cleanTarget)) {
results.push(candidateMap.get(cleanTarget).item.name);
}
for (const [key, entry] of candidateMap.entries()) {
if (results.includes(entry.item.name)) continue;
if ((key.includes(cleanTarget) || cleanTarget.includes(key)) && Math.min(key.length, cleanTarget.length) > 3) {
results.push(entry.item.name);
if (results.length >= limit) return results.slice(0, limit);
}
}
const byDist = [];
for (const [key, entry] of candidateMap.entries()) {
if (results.includes(entry.item.name)) continue;
const dist = levenshtein(cleanTarget, key);
if (dist <= 5) byDist.push({ name: entry.item.name, dist });
}
byDist.sort((a, b) => a.dist - b.dist);
byDist.forEach((x) => {
if (!results.includes(x.name)) results.push(x.name);
});
return results.slice(0, limit);
}
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function renderAutoSuggestions() {
const host = el('autoSuggestionList');
if (!host) return;
host.innerHTML = '';
const rows = Array.isArray(state.autoSuggestions) ? state.autoSuggestions : [];
if (!rows.length) return;
rows.forEach((row) => {
const wrap = document.createElement('div');
wrap.className = 'suggest-row';
const title = document.createElement('div');
title.className = 'suggest-title';
title.textContent = row.req;
wrap.appendChild(title);
const chips = document.createElement('div');
chips.className = 'suggest-chips';
if (row.suggestions && row.suggestions.length) {
row.suggestions.slice(0, 3).forEach((s) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'suggest-chip';
btn.dataset.req = row.req;
btn.dataset.suggestion = s;
btn.textContent = s;
chips.appendChild(btn);
});
} else {
const none = document.createElement('span');
none.className = 'suggest-title';
none.textContent = 'No close suggestion';
chips.appendChild(none);
}
wrap.appendChild(chips);
host.appendChild(wrap);
});
}
function applySuggestedReplacement(req, suggestion) {
const input = el('autoOrderInput');
if (!input || !req || !suggestion) return;
const lines = String(input.value || '').split(/\r?\n/);
const idx = lines.findIndex((line) => String(line || '').trim() === req.trim());
if (idx >= 0) lines[idx] = suggestion;
else lines.push(suggestion);
input.value = lines.join('\n');
applyAutoCollageOrder();
}
function findBestSlotIndex(templateSlots, usedSlots, item, req) {
if (req && Number.isFinite(req.rank)) {
const rankedIndex = req.rank - 1;
if (rankedIndex >= 0 && rankedIndex < templateSlots.length && !usedSlots.has(rankedIndex)) {
return rankedIndex;
}
}
const reqGroupKey = req ? normalizeGroupKey(req.group || '') : '';
const itemGroupKey = normalizeGroupKey(item?.group || '');
const preferredGroups = [reqGroupKey, itemGroupKey].filter(Boolean);
const itemVariants = new Set(getNameVariants(item?.name || ''));
const reqVariants = new Set(getNameVariants(req?.name || ''));
const itemCompact = normalizeCompactName(item?.name || '');
const reqCompact = normalizeCompactName(req?.name || '');
const findVariantMatch = (variants, enforceGroup) => {
if (!variants || variants.size === 0) return -1;
for (let i = 0; i < templateSlots.length; i += 1) {
if (usedSlots.has(i)) continue;
const slot = templateSlots[i];
if (enforceGroup && preferredGroups.length > 0) {
const slotGroupKey = normalizeGroupKey(slot.group || '');
if (!preferredGroups.includes(slotGroupKey)) continue;
}
const slotVariants = getNameVariants(slot.name);
if (slotVariants.some((v) => variants.has(v))) return i;
}
return -1;
};
const findCompactMatch = (compactValue, enforceGroup) => {
if (!compactValue) return -1;
for (let i = 0; i < templateSlots.length; i += 1) {
if (usedSlots.has(i)) continue;
const slot = templateSlots[i];
if (enforceGroup && preferredGroups.length > 0) {
const slotGroupKey = normalizeGroupKey(slot.group || '');
if (!preferredGroups.includes(slotGroupKey)) continue;
}
const slotCompact = normalizeCompactName(slot.name);
if ((slotCompact.includes(compactValue) || compactValue.includes(slotCompact)) && Math.min(slotCompact.length, compactValue.length) > 3) {
return i;
}
}
return -1;
};
let idx = findVariantMatch(itemVariants, true);
if (idx >= 0) return idx;
idx = findVariantMatch(reqVariants, true);
if (idx >= 0) return idx;
idx = findVariantMatch(itemVariants, false);
if (idx >= 0) return idx;
idx = findVariantMatch(reqVariants, false);
if (idx >= 0) return idx;
idx = findCompactMatch(itemCompact, true);
if (idx >= 0) return idx;
idx = findCompactMatch(reqCompact, true);
if (idx >= 0) return idx;
idx = findCompactMatch(itemCompact, false);
if (idx >= 0) return idx;
idx = findCompactMatch(reqCompact, false);
if (idx >= 0) return idx;
return -1;
}
function applyAutoCollageOrder() {
const input = el('autoOrderInput');
const missingOut = el('autoOrderMissing');
if (!input) return;
if (missingOut) missingOut.value = '';
state.autoSuggestions = [];
renderAutoSuggestions();
const requests = parseAutoOrderInput(input.value);
if (!requests.length) {
alert('Paste at least one image name (one per line).');
return;
}
const sourceItems = state.uploadedImages.filter((item) => item && (item.url || item.file));
if (!sourceItems.length) {
alert('No mapped images found. Load/map images first.');
return;
}
const pool = sourceItems.map((item, idx) => ({
idx,
item,
used: false,
groupKey: normalizeGroupKey(item.group || ''),
variants: new Set(getNameVariants(item.name)),
compact: normalizeCompactName(item.name)
}));
const templateSlots = Array.isArray(state.templateSlots) && state.templateSlots.length
? state.templateSlots.map((s) => ({ name: s.name, group: s.group || 'Uncategorized' }))
: sourceItems.map((s) => ({ name: s.name, group: s.group || 'Uncategorized' }));
const missingDetails = [];
const matched = [];
let fuzzyPlaced = 0;
requests.forEach((req) => {
const wanted = getNameVariants(req.name);
let found = null;
for (const entry of pool) {
if (entry.used) continue;
if (req.groupKey && req.groupKey !== entry.groupKey) continue;
if (wanted.some((w) => entry.variants.has(w))) {
found = entry;
break;
}
}
if (!found) {
found = findBestFuzzyMatch(pool, req);
if (found) fuzzyPlaced += 1;
}
if (!found) {
missingDetails.push({ req: req.raw, suggestions: getTopSuggestions(pool, req, 3) });
return;
}
found.used = true;
matched.push({
item: found.item,
req,
targetGroupKey: req.groupKey || found.groupKey || normalizeGroupKey(found.item.group || '')
});
});
if (!matched.length) {
if (missingOut) {
missingOut.value = missingDetails
.map((m) => m.suggestions.length ? `${m.req} -> ${m.suggestions.join(' | ')}` : `${m.req} -> (no close match)`)
.join('\n');
}
state.autoSuggestions = missingDetails;
renderAutoSuggestions();
alert('No matching image names found in loaded images.');
return;
}
const usedSlots = new Set();
const orderedMatches = matched
.map((m, inputIndex) => {
const slotIndex = findBestSlotIndex(templateSlots, usedSlots, m.item, m.req);
if (slotIndex >= 0) usedSlots.add(slotIndex);
return { ...m, slotIndex, inputIndex };
})
.sort((a, b) => {
const ar = a.slotIndex >= 0 ? a.slotIndex : Number.MAX_SAFE_INTEGER;
const br = b.slotIndex >= 0 ? b.slotIndex : Number.MAX_SAFE_INTEGER;
if (ar !== br) return ar - br;
return a.inputIndex - b.inputIndex;
});
const finalImages = orderedMatches.map((m) => {
if (m.slotIndex >= 0) {
const slot = templateSlots[m.slotIndex];
return {
...m.item,
group: slot.group || m.item.group || 'Uncategorized'
};
}
return {
...m.item,
group: m.item.group || 'Uncategorized'
};
});
const removed = pool.filter((x) => !x.used).map((x) => x.item);
if (removed.length) pushItemsToBin(removed, 'auto-collage-unmatched', true);
state.uploadedImages = finalImages;
const duplicateRemoved = removeDuplicateMappedImages('duplicate-auto-order');
state.groupDrag = null;
state.selected.clear();
state.pendingPlacement = [];
autoArrangeGrid(state.uploadedImages.length);
saveState();
render();
state.autoSuggestions = missingDetails;
renderAutoSuggestions();
if (missingOut) {
missingOut.value = missingDetails
.map((m) => m.suggestions.length ? `${m.req} -> ${m.suggestions.join(' | ')}` : `${m.req} -> (no close match)`)
.join('\n');
}
alert(`Auto collage aligned by template order (slot auto-detected from template JSON). Placed: ${finalImages.length}, Fuzzy: ${fuzzyPlaced}, Removed: ${removed.length}, Duplicates: ${duplicateRemoved}, Missing: ${missingDetails.length}.`);
}
function refreshGroupFilter() {
const select = el('groupFilter');
if (!select) return;
const current = select.value || 'all';
const groups = ensureGroupOrder();
select.innerHTML = '';
const allOpt = document.createElement('option');
allOpt.value = 'all';
allOpt.textContent = 'All Groups';
select.appendChild(allOpt);
groups.forEach((g) => {
const opt = document.createElement('option');
opt.value = g;
opt.textContent = g;
select.appendChild(opt);
});
if ([...select.options].some((o) => o.value === current)) select.value = current;
else select.value = 'all';
refreshGroupOrderControls();
}
function render() {
const area = el('collage-area');
if (!area) return;
syncStateFromInputs();
updateInsertPositionBounds();
area.innerHTML = '';
area.style.gridTemplateColumns = `repeat(${state.appData.cols}, ${state.appData.skinWidth}px)`;
area.style.gridAutoRows = `${state.appData.skinHeight}px`;
const isTight = state.appData.gap === 0;
area.style.gap = `${state.appData.gap}px`;
area.style.background = isTight ? 'transparent' : state.appData.bgColor;
area.style.padding = isTight ? '0px' : `${state.appData.gap}px`;
area.classList.toggle('tight', isTight);
applyCollageTransform();
if (state.uploadedImages.length === 0) el('empty-state')?.classList.remove('hidden');
else el('empty-state')?.classList.add('hidden');
refreshGroupFilter();
const filtered = getFilteredIndices();
const visible = filtered.slice(0, getVisibleCount());
state.viewIndices = visible;
state.selected.forEach((idx) => {
if (!visible.includes(idx)) state.selected.delete(idx);
});
const searchKey = normalizeCompactName(state.searchQuery || '');
let matchCount = 0;
visible.forEach((sourceIndex, visiblePos) => {
const img = state.uploadedImages[sourceIndex];
const card = document.createElement('div');
card.className = 'skin-item';
if (state.selected.has(sourceIndex)) card.classList.add('selected');
const isHit = searchKey && normalizeCompactName(img?.name || '').includes(searchKey);
if (isHit) {
card.classList.add('search-hit');
matchCount += 1;
const badge = document.createElement('span');
badge.className = 'search-badge';
badge.textContent = 'MATCH';
card.appendChild(badge);
}
if (img?.url) {
card.style.backgroundImage = `url("${img.url}")`;
} else {
card.classList.add('placeholder');
card.textContent = String(sourceIndex + 1);
}
card.dataset.gridPos = String(visiblePos + 1);
card.title = `Pos ${visiblePos + 1} - ${img?.name || 'Empty Slot'}${img?.group ? ` (${img.group})` : ''}`;
let hoverTimer = null;
const hidePosHint = () => {
card.classList.remove('show-pos-hint');
};
card.addEventListener('pointerenter', () => {
if (hoverTimer) clearTimeout(hoverTimer);
hoverTimer = setTimeout(() => {
card.classList.add('show-pos-hint');
}, 2000);
});
card.addEventListener('pointerleave', () => {
if (hoverTimer) clearTimeout(hoverTimer);
hoverTimer = null;
hidePosHint();
});
card.addEventListener('pointerdown', () => {
if (hoverTimer) clearTimeout(hoverTimer);
hoverTimer = null;
hidePosHint();
});
card.addEventListener('click', (e) => onSkinClick(e, sourceIndex));
area.appendChild(card);
});
updateSearchStatus(matchCount, searchKey);
renderBinList();
updateSelectionUi();
updateButtonStates();
updateActiveSkinsBadge();
}
function swapItems(a, b) {
if (a === b) return;
if (a < 0 || b < 0) return;
if (a >= state.uploadedImages.length || b >= state.uploadedImages.length) return;
[state.uploadedImages[a], state.uploadedImages[b]] = [state.uploadedImages[b], state.uploadedImages[a]];
}
function buildPlacementTargetIndices(targetSourceIndex, count) {
const visible = state.viewIndices && state.viewIndices.length
? state.viewIndices
: getFilteredIndices().slice(0, getVisibleCount());
if (!visible.length || count <= 0) return [];
const maxCount = Math.min(count, visible.length);
let startPos = visible.indexOf(targetSourceIndex);
if (startPos < 0) startPos = 0;
const maxStart = Math.max(0, visible.length - maxCount);
startPos = Math.min(startPos, maxStart);
return visible.slice(startPos, startPos + maxCount);
}
function applyPendingPlacement(targetSourceIndex) {
const visible = state.viewIndices && state.viewIndices.length
? state.viewIndices
: getFilteredIndices().slice(0, getVisibleCount());
if (!visible.length) return false;
const sourceSet = new Set(state.pendingPlacement);
const sourceIndices = visible.filter((idx) => sourceSet.has(idx));
if (state.multiPlacementOrder === 'desc') sourceIndices.reverse();
if (!sourceIndices.length) return false;
const targetIndices = buildPlacementTargetIndices(targetSourceIndex, sourceIndices.length);
if (!targetIndices.length || targetIndices.length !== sourceIndices.length) return false;
const overlap = targetIndices.some((idx) => sourceSet.has(idx));
if (overlap) return false;
const old = state.uploadedImages.slice();
const next = old.slice();
const count = sourceIndices.length;
for (let i = 0; i < count; i += 1) {
const s = sourceIndices[i];
const t = targetIndices[i];
next[t] = old[s];
}
for (let i = 0; i < count; i += 1) {
const s = sourceIndices[i];
const t = targetIndices[i];
next[s] = old[t];
}
state.uploadedImages = next;
state.pendingPlacement = [];
state.selected.clear();
return true;
}
function onSkinClick(event, sourceIndex) {
event.stopPropagation();
if (Date.now() < (state.suppressSkinClickUntil || 0)) return;
if (state.pendingBinPlacement.length > 0) {
if (applyPendingBinPlacementToSlot(sourceIndex)) {
saveState();
render();
}
return;
}
if (!state.multiMode && state.pendingPlacement.length > 0) {
if (applyPendingPlacement(sourceIndex)) {
saveState();
render();
return;
}
}
if (state.multiMode) {
if (state.selected.has(sourceIndex)) state.selected.delete(sourceIndex);
else state.selected.add(sourceIndex);
render();
return;
}
if (state.selected.size === 0) {
state.selected.add(sourceIndex);
render();
return;
}
if (state.selected.has(sourceIndex)) {
state.selected.clear();
state.pendingPlacement = [];
render();
return;
}
const first = Array.from(state.selected)[0];
swapItems(first, sourceIndex);
state.selected.clear();
state.pendingPlacement = [];
saveState();
render();
}
function swapSelected() {
const count = state.selected.size;
if (count <= 0) return;
if (count === 2) {
const [a, b] = Array.from(state.selected);
swapItems(a, b);
state.selected.clear();
state.pendingPlacement = [];
saveState();
render();
return;
}
const visible = state.viewIndices && state.viewIndices.length
? state.viewIndices
: getFilteredIndices().slice(0, getVisibleCount());
state.pendingPlacement = visible.filter((idx) => state.selected.has(idx));
state.multiMode = false;
render();
}
function updateSelectionUi() {
const count = state.selected.size;
const panel = el('floating-panel');
if (!panel) return;
if (state.pendingBinPlacement.length > 0) {
panel.classList.add('visible');
el('selection-status').textContent = `${state.pendingBinPlacement.length} bin item(s) queued, click target slot(s)`;
return;
}
if (!state.multiMode && state.pendingPlacement.length > 0) {
panel.classList.add('visible');
const mode = state.multiPlacementOrder === 'desc' ? 'DESC' : 'ASC';
el('selection-status').textContent = `${state.pendingPlacement.length} queued (${mode}), click target slot`;
return;
}
if (count > 0) {
panel.classList.add('visible');
const visible = state.viewIndices && state.viewIndices.length
? state.viewIndices
: getFilteredIndices().slice(0, getVisibleCount());
const positions = Array.from(state.selected)
.map((idx) => visible.indexOf(idx))
.filter((idx) => idx >= 0)
.map((idx) => idx + 1)
.sort((a, b) => a - b);
const posText = positions.length ? ` (Pos ${positions.join(', ')})` : '';
el('selection-status').textContent = `${count} selected${posText}`;
} else {
panel.classList.remove('visible');
el('selection-status').textContent = '';
}
}
function updateButtonStates() {
const hasSel = state.selected.size > 0;
const canSwap = state.selected.size > 0;
const hasEmpty = state.uploadedImages.some((x) => !hasImageData(x));
const hasSelectedMapped = Array.from(state.selected).some((idx) => hasImageData(state.uploadedImages[idx]));
if (el('btnDelete')) el('btnDelete').disabled = !hasSel;
if (el('btnFloatDelete')) el('btnFloatDelete').disabled = !hasSel;
if (el('btnSwap')) el('btnSwap').disabled = !canSwap;
if (el('btnFillEmpty')) el('btnFillEmpty').disabled = !(hasEmpty && hasSelectedMapped);
if (el('btnUndo')) el('btnUndo').disabled = state.historyIndex <= 0;
if (el('btnRedo')) el('btnRedo').disabled = state.historyIndex >= state.history.length - 1;
el('btnMulti')?.classList.toggle('active', state.multiMode);
}
function updateActiveSkinsBadge() {
const badge = el('activeSkinsBadge');
if (!badge) return;
if (state.activeTab !== 'collage') {
badge.classList.add('hidden');
return;
}
badge.classList.remove('hidden');
const visible = state.viewIndices && state.viewIndices.length
? state.viewIndices
: getFilteredIndices().slice(0, getVisibleCount());
const activeCount = visible.reduce((sum, idx) => sum + (hasImageData(state.uploadedImages[idx]) ? 1 : 0), 0);
badge.textContent = `Active Skins: ${activeCount}`;
}
function hasImageData(item) {
return Boolean(item && (item.url || item.file));
}
function getItemKey(item) {
if (!item) return '';
const base = String(item.name || '').replace(/^\-\s*/, '').replace(/\.[^.]+$/, '');
return normalizeCompactName(base);
}
function makePlaceholderAtIndex(index, fallbackItem) {
const slot = state.templateSlots[index] || {};
return {
name: slot.name || fallbackItem?.name || `Empty Slot ${index + 1}`,
group: slot.group || fallbackItem?.group || 'Uncategorized',
url: null,
file: null
};
}
function setSearchQuery(value) {
state.searchQuery = String(value || '').trim();
render();
}
function updateSearchStatus(matchCount, searchKey) {
const status = el('drawerSearchStatus');
if (!status) return;
const binMatches = getFilteredBinEntries(searchKey).length;
if (!searchKey) {
status.textContent = 'Search is hidden here. Matches are highlighted in blue.';
return;
}
status.textContent = `Grid matches: ${matchCount}. Bin matches: ${binMatches}.`;
}
function toggleUtilityDrawer(forceOpen) {
const drawer = el('utilityDrawer');
const scrim = el('utilityScrim');
if (!drawer || !scrim) return;
const shouldOpen = typeof forceOpen === 'boolean' ? forceOpen : !state.drawerOpen;
state.drawerOpen = shouldOpen;
drawer.classList.toggle('open', shouldOpen);
scrim.classList.toggle('open', shouldOpen);
if (shouldOpen) {
const input = el('drawerSearch');
if (input) {
input.value = state.searchQuery || '';
input.focus();
input.select();
}
}
}
function pushItemsToBin(items, reason = 'deleted', skipRender = false) {
const list = Array.isArray(items) ? items : [];
const stamped = [];
list.forEach((item) => {
if (!hasImageData(item)) return;
stamped.push({
...item,
deletedAt: Date.now(),
reason
});
});
if (!stamped.length) return;
state.binItems.unshift(...stamped);
state.binSelected.clear();
if (state.binItems.length > 800) {
const overflow = state.binItems.splice(800);
overflow.forEach((x) => {
if (x?.url && x.url.startsWith('blob:')) URL.revokeObjectURL(x.url);
});
}
if (!skipRender) renderBinList();
}
function toggleBinSelection(index) {
if (!Number.isFinite(index) || index < 0 || index >= state.binItems.length) return;
if (state.binSelected.has(index)) state.binSelected.delete(index);
else state.binSelected.add(index);
renderBinList();
}
function findTemplateSlotIndexForItem(item) {
if (!Array.isArray(state.templateSlots) || !state.templateSlots.length) return -1;
const target = normalizeCompactName(item?.name || '');
if (!target) return -1;
let idx = state.templateSlots.findIndex((slot) => normalizeCompactName(slot?.name || '') === target);
if (idx >= 0) return idx;
idx = state.templateSlots.findIndex((slot) => {
const key = normalizeCompactName(slot?.name || '');
return key && (key.includes(target) || target.includes(key));
});
return idx;
}
function reindexBinSelectionAfterRemove(removedIndex) {
const next = new Set();
state.binSelected.forEach((idx) => {
if (idx === removedIndex) return;
next.add(idx > removedIndex ? idx - 1 : idx);
});
state.binSelected = next;
}
function getFilteredBinEntries(searchKey) {
const key = normalizeCompactName(searchKey || '');
const entries = state.binItems.map((item, idx) => ({ item, idx }));
if (!key) return entries;
return entries.filter(({ item }) => {
const name = normalizeCompactName(item?.name || '');
const group = normalizeCompactName(item?.group || '');
const reason = normalizeCompactName(item?.reason || '');
return name.includes(key) || group.includes(key) || reason.includes(key);
});
}
function renderBinList() {
const host = el('binList');
const countEl = el('binCount');
if (countEl) countEl.textContent = String(state.binItems.length);
if (!host) return;
if (!state.binItems.length) {
host.innerHTML = '<div class="bin-empty">Bin is empty</div>';
return;
}
const filtered = getFilteredBinEntries(state.searchQuery || '');
if (!filtered.length) {
host.innerHTML = '<div class="bin-empty">No bin match for current search</div>';
return;
}
host.innerHTML = filtered
.map(({ item, idx }) => {
const name = escapeHtml(item?.name || 'Unknown');
const group = escapeHtml(item?.group || 'Uncategorized');
const reason = escapeHtml(item?.reason || 'deleted');
const preview = item?.url
? `<img class="bin-item-preview" src="${escapeHtml(item.url)}" alt="${name}">`
: `<div class="bin-item-preview placeholder">N/A</div>`;
const selectedClass = state.binSelected.has(idx) ? ' selected' : '';
return `<div class="bin-item${selectedClass}" data-bin-index="${idx}">${preview}<div class="bin-item-body"><div class="bin-item-name">${name}</div><div class="bin-item-meta">${group} - ${reason}</div></div><button type="button" class="btn" data-bin-restore="${idx}">Restore</button></div>`;
})
.join('');
}
function restoreBinItem(index, silent = false, opts = {}) {
if (!Number.isFinite(index) || index < 0 || index >= state.binItems.length) return false;
const item = state.binItems[index];
const key = getItemKey(item);
const existingMapped = new Set(state.uploadedImages.filter((x) => hasImageData(x)).map((x) => getItemKey(x)));
const preferTemplate = opts.preferTemplate !== false;
const allowDisplace = opts.allowDisplace !== false;
if (key && existingMapped.has(key)) {
if (!silent) alert('Restore skipped: duplicate skin already exists in collage.');
return false;
}
const emptyIndex = state.uploadedImages.findIndex((x) => !hasImageData(x));
const restored = {
name: item.name,
group: item.group || 'Uncategorized',
url: item.url || null,
file: item.file || null
};
const templateIndex = preferTemplate ? findTemplateSlotIndexForItem(restored) : -1;
if (templateIndex >= 0) {
const slot = state.templateSlots[templateIndex];
const current = state.uploadedImages[templateIndex];
const targetValue = {
...restored,
group: slot?.group || restored.group || 'Uncategorized'
};
if (hasImageData(current) && allowDisplace) {
const firstEmpty = state.uploadedImages.findIndex((x, i) => i !== templateIndex && !hasImageData(x));
if (firstEmpty >= 0) {
const emptySlot = state.templateSlots[firstEmpty];
state.uploadedImages[firstEmpty] = {
...current,
group: emptySlot?.group || current.group || 'Uncategorized'
};
} else {
state.uploadedImages.push({
...current,
group: current.group || 'Uncategorized'
});
}
}
state.uploadedImages[templateIndex] = targetValue;
} else if (emptyIndex >= 0) {
const slot = state.templateSlots[emptyIndex];
state.uploadedImages[emptyIndex] = {
...restored,
group: slot?.group || restored.group || 'Uncategorized'
};
} else {
state.uploadedImages.push(restored);
}
state.binItems.splice(index, 1);
reindexBinSelectionAfterRemove(index);
if (!silent) {
saveState();
render();
}
return true;
}
function placeSelectedBinItemsToGrid() {
if (!state.binSelected.size) {
notify('Select bin items first.');
return;
}
const selectedItems = Array.from(state.binSelected)
.sort((a, b) => a - b)
.map((idx) => ({ index: idx, item: state.binItems[idx] }))
.filter((row) => row.item);
state.pendingBinPlacement = selectedItems;
state.binSelected.clear();
notify(`Manual placement mode: ${selectedItems.length} item(s). Click grid slots to place.`);
render();
}
function applyPendingBinPlacementToSlot(targetIndex) {
if (!state.pendingBinPlacement.length) return false;
if (targetIndex < 0 || targetIndex >= state.uploadedImages.length) return false;
const row = state.pendingBinPlacement.shift();
if (!row || !row.item) return false;
const binIndex = state.binItems.indexOf(row.item);
if (binIndex < 0) return false;
const incoming = {
name: row.item.name,
group: row.item.group || 'Uncategorized',
url: row.item.url || null,
file: row.item.file || null
};
const current = state.uploadedImages[targetIndex];
const slot = state.templateSlots[targetIndex];
state.uploadedImages[targetIndex] = {
...incoming,
group: slot?.group || incoming.group
};
if (hasImageData(current)) {
pushItemsToBin([current], 'displaced-manual', true);
}
state.binItems.splice(binIndex, 1);
reindexBinSelectionAfterRemove(binIndex);
state.pendingBinPlacement = state.pendingBinPlacement
.map((x) => ({ index: x.index > binIndex ? x.index - 1 : x.index, item: x.item }))
.filter((x) => state.binItems.includes(x.item));
if (!state.pendingBinPlacement.length) {
notify('Manual bin placement complete.');
}
return true;
}
function restoreAllFromBin() {
if (!state.binItems.length) return;
let restoredCount = 0;
for (let i = state.binItems.length - 1; i >= 0; i -= 1) {
if (restoreBinItem(i, true)) restoredCount += 1;
}
if (!restoredCount) {
alert('No items restored. All would be duplicates.');
renderBinList();
return;
}
saveState();
render();
}
function emptyBin() {
if (!state.binItems.length) return;
if (!confirm('Permanently empty the bin?')) return;
state.binItems.forEach((x) => {
if (x?.url && x.url.startsWith('blob:')) URL.revokeObjectURL(x.url);
});
state.binItems = [];
renderBinList();
}
function removeDuplicateMappedImages(reason = 'duplicate') {
const seen = new Set();
const removed = [];
state.uploadedImages.forEach((item, idx) => {
if (!hasImageData(item)) return;
const key = getItemKey(item);
if (!key) return;
if (seen.has(key)) {
removed.push(item);
state.uploadedImages[idx] = makePlaceholderAtIndex(idx, item);
return;
}
seen.add(key);
});
if (removed.length) pushItemsToBin(removed, reason, true);
return removed.length;
}
function saveState() {
const snapshot = {
appData: { ...state.appData },
groupOrder: state.groupOrder.slice(),
templateSlots: state.templateSlots.map((x) => ({ name: x.name, group: x.group })),
uploadedImages: state.uploadedImages.map((x) => ({ name: x.name, group: x.group, url: x.url }))
};
if (state.historyIndex < state.history.length - 1) state.history = state.history.slice(0, state.historyIndex + 1);
state.history.push(snapshot);
state.historyIndex = state.history.length - 1;
if (state.history.length > 80) {
state.history.shift();
state.historyIndex -= 1;
}
updateButtonStates();
}
function restoreSnapshot(snap) {
state.uploadedImages.forEach((x) => {
if (x.url && x.url.startsWith('blob:')) URL.revokeObjectURL(x.url);
});
state.appData = { ...state.appData, ...snap.appData };
state.uploadedImages = snap.uploadedImages.map((x) => ({ ...x, file: null }));
state.groupOrder = Array.isArray(snap.groupOrder) ? snap.groupOrder.slice() : [];
state.templateSlots = Array.isArray(snap.templateSlots) ? snap.templateSlots.map((x) => ({ name: x.name, group: x.group || 'Uncategorized' })) : [];
state.groupDrag = null;
ensureGroupOrder();
state.selected.clear();
state.pendingPlacement = [];
syncInputsFromState();
render();
}
function undo() {
if (state.historyIndex <= 0) return;
state.historyIndex -= 1;
restoreSnapshot(state.history[state.historyIndex]);
}
function redo() {
if (state.historyIndex >= state.history.length - 1) return;
state.historyIndex += 1;
restoreSnapshot(state.history[state.historyIndex]);
}
function toggleMulti() {
state.multiMode = !state.multiMode;
if (!state.multiMode) {
const visible = state.viewIndices && state.viewIndices.length
? state.viewIndices
: getFilteredIndices().slice(0, getVisibleCount());
const sel = state.selected;
state.pendingPlacement = visible.filter((idx) => sel.has(idx));
} else {
state.pendingPlacement = [];
}
render();
}
function clearSelection() {
state.selected.clear();
state.pendingPlacement = [];
render();
}
function fillSelectedIntoEmptySlots() {
const selectedSources = Array.from(state.selected)
.sort((a, b) => a - b)
.filter((idx) => hasImageData(state.uploadedImages[idx]));
if (!selectedSources.length) {
alert('Select one or more mapped skins first.');
return;
}
const emptyTargets = state.uploadedImages
.map((item, idx) => (!hasImageData(item) ? idx : -1))
.filter((idx) => idx >= 0 && !state.selected.has(idx));
if (!emptyTargets.length) {
alert('No empty slots available.');
return;
}
const count = Math.min(selectedSources.length, emptyTargets.length);
const next = state.uploadedImages.slice();
for (let i = 0; i < count; i += 1) {
const from = selectedSources[i];
const to = emptyTargets[i];
[next[from], next[to]] = [next[to], next[from]];
}
state.uploadedImages = next;
state.selected.clear();
state.pendingPlacement = [];
saveState();
render();
}
function deleteSelected() {
if (!state.selected.size) return;
const ids = Array.from(state.selected).sort((a, b) => a - b);
const removed = [];
ids.forEach((idx) => {
const item = state.uploadedImages[idx];
if (!hasImageData(item)) return;
removed.push(item);
state.uploadedImages[idx] = makePlaceholderAtIndex(idx, item);
});
if (removed.length) pushItemsToBin(removed, 'deleted');
removeDuplicateMappedImages('duplicate-cleanup');
state.selected.clear();
state.pendingPlacement = [];
saveState();
render();
}
function clearAll() {
if (!state.uploadedImages.length) return;
if (!confirm('Clear all images?')) return;
const removed = state.uploadedImages.filter((x) => hasImageData(x));
if (removed.length) pushItemsToBin(removed, 'clear-all');
if (state.templateSlots.length) {
state.uploadedImages = state.templateSlots.map((slot, idx) => makePlaceholderAtIndex(idx, slot));
} else {
state.uploadedImages = [];
autoFitGridToAllItems();
}
state.selected.clear();
state.pendingPlacement = [];
state.panX = 0;
state.panY = 0;
state.scale = 0.5;
syncInputsFromState();
saveState();
render();
}
function loadSampleData() {
const names = [
['Layla_- Blue Specter.jpg', 'Premium'],
['Roger_- Fiend Haunter.jpg', 'Premium'],
['Rafaela_- Biomedic.jpg', 'Premium'],
['Nana_- Wind Fairy.jpg', 'Premium'],
['Paquito_- Manny Pacquiao.jpg', 'Premium']
];
state.uploadedImages = names.map(([n, g]) => ({ name: `- ${n}`, group: g, url: null, file: null }));
autoFitGridToAllItems();
state.selected.clear();
state.pendingPlacement = [];
saveState();
render();
}
async function handleFiles(e) {
const files = Array.from(e.target.files || []);
e.target.value = '';
if (!files.length) return;
if (files.length === 1 && !files[0].name.toLowerCase().endsWith('.zip')) {
const loaded = await tryLoadEmbeddedProjectFromFiles(files, { alertIfMissing: false });
if (loaded) return;
}
const zipFile = files.find((f) => String(f?.name || '').toLowerCase().endsWith('.zip')) || null;
const isZipInput = e.target.id === 'zipLoader' || !!zipFile;
if (isZipInput) {
const extracted = await processZip(zipFile || files[0], { openCrop: false });
if (extracted.length) await autoCropAndMapIncomingFiles(extracted, 'ZIP');
return;
}
await autoCropAndMapIncomingFiles(files, 'images');
}
async function handleInsertImageFile(e) {
const files = Array.from(e.target.files || []);
e.target.value = '';
if (!files.length) return;
const file = files[0];
if (!file || !String(file.type || '').toLowerCase().startsWith('image/')) {
alert('Choose an image file.');
return;
}
state.pendingImages = [file];
openCropModal(file, { mode: 'insert' });
}
async function handleProjectImageLoad(e) {
const files = Array.from(e.target.files || []);
e.target.value = '';
if (!files.length) return;
await tryLoadEmbeddedProjectFromFiles(files, { alertIfMissing: true });
}
async function tryLoadEmbeddedProjectFromFiles(files, options = {}) {
const list = Array.isArray(files) ? files : [];
const alertIfMissing = options.alertIfMissing === true;
for (let i = 0; i < list.length; i += 1) {
const file = list[i];
const embedded = await extractEmbeddedProjectFromImage(file);
if (!embedded) continue;
try {
applyProjectPayload(embedded);
saveState();
render();
notify(`Embedded project loaded from ${file.name || 'image'}.`);
return true;
} catch (err) {
console.error('Embedded project apply failed:', err);
alert('Embedded project data is invalid.');
return false;
}
}
if (alertIfMissing) alert('No embedded project data found in selected image.');
return false;
}
async function processZip(file, options = {}) {
const openCrop = options.openCrop !== false;
try {
showLoading('Unpacking ZIP...');
const extracted = await processZipBlob(file, { progressPrefix: 'Unpacking ZIP' });
if (!extracted.length) {
alert('No images found in ZIP');
return [];
}
if (openCrop) {
state.pendingImages = extracted;
openCropModal(extracted[0]);
}
return extracted;
} catch (err) {
console.error(err);
alert('ZIP processing failed.');
return [];
} finally {
hideLoading();
}
}
async function autoCropAndMapIncomingFiles(files, sourceLabel = 'images') {
const list = Array.isArray(files) ? files : [];
if (!list.length) return;
showLoading(`Processing ${list.length} ${sourceLabel}...`);
try {
const blobMap = new Map();
for (let i = 0; i < list.length; i += 1) {
const file = list[i];
try {
const blob = await cropSingleNormalizedTop(file);
blobMap.set(normalizeName(file.name), { blob, originalName: file.name });
} catch (err) {
console.error('Auto crop failed:', file?.name, err);
}
setLoadingProgress(
Math.round(((i + 1) / Math.max(1, list.length)) * 100),
'Auto top-crop',
file?.name || `Image ${i + 1}`
);
if ((i + 1) % 10 === 0) await yieldToUi();
}
const matchedKeys = new Set();
state.uploadedImages.forEach((item) => {
const key = normalizeName(item.name);
const entry = blobMap.get(key);
if (!entry) return;
if (item.url && item.url.startsWith('blob:')) URL.revokeObjectURL(item.url);
item.url = URL.createObjectURL(entry.blob);
item.file = new File([entry.blob], item.name, { type: entry.blob.type || 'image/jpeg' });
matchedKeys.add(key);
});
const newItems = [];
blobMap.forEach((entry, key) => {
if (matchedKeys.has(key)) return;
const name = entry.originalName || `Imported-${Date.now()}.jpg`;
newItems.push({
name,
group: 'Imported',
url: URL.createObjectURL(entry.blob),
file: new File([entry.blob], name, { type: entry.blob.type || 'image/jpeg' })
});
});
if (newItems.length) state.uploadedImages.push(...newItems);
removeDuplicateMappedImages('duplicate-auto-load');
autoArrangeGrid(state.uploadedImages.length);
saveState();
render();
} finally {
hideLoading();
}
}
async function processZipBlob(blob, options = {}) {
const zip = await JSZip.loadAsync(blob);
const entries = Object.entries(zip.files)
.filter(([, entry]) => !entry.dir)
.filter(([name]) => /\.(jpg|jpeg|png|webp|gif)$/i.test(name));
const extracted = [];
const total = Math.max(1, entries.length);
for (let i = 0; i < entries.length; i += 1) {
const [name, entry] = entries[i];
const fileBlob = await entry.async('blob');
const clean = name.split('/').pop() || name;
extracted.push(new File([fileBlob], clean, { type: fileBlob.type || 'image/jpeg' }));
setLoadingProgress(Math.round(((i + 1) / total) * 100), options.progressPrefix || 'Extracting ZIP', clean);
if ((i + 1) % 15 === 0) await yieldToUi();
}
return extracted;
}
async function fetchAndCacheEndpointAssets() {
showLoading('Fetching endpoint assets...');
try {
setLoadingProgress(5, 'Downloading template', ENDPOINT_TEMPLATE_URL);
const templateRes = await fetch(ENDPOINT_TEMPLATE_URL, { cache: 'no-store' });
if (!templateRes.ok) throw new Error(`Template fetch failed (${templateRes.status})`);
const templateData = await templateRes.json();
const normalized = normalizeTemplateData(templateData);
setLoadingProgress(20, 'Downloading ZIP', 'Starting download...');
const zipBlob = await fetchBlobWithProgress(ENDPOINT_ZIP_URL, (pct, detail) => {
const overall = 20 + Math.round(pct * 0.5);
setLoadingProgress(overall, 'Downloading ZIP', detail || ENDPOINT_ZIP_URL);
});
setLoadingProgress(74, 'Saving cache', 'Writing to browser storage...');
await cacheSetJson(CACHE_TEMPLATE_KEY, templateData);
await cacheSetBlob(CACHE_ZIP_KEY, zipBlob);
localStorage.setItem(CACHE_META_KEY, JSON.stringify({ savedAt: Date.now(), zipUrl: ENDPOINT_ZIP_URL, templateUrl: ENDPOINT_TEMPLATE_URL }));
setLoadingProgress(80, 'Applying template', 'Preparing image slots...');
applyNormalizedTemplate(normalized);
const extracted = await processZipBlob(zipBlob, { progressPrefix: 'Extracting cached ZIP' });
const side = clampNum(parseInt(el('skinW')?.value, 10), 1, 8000, state.appData.skinWidth || 256);
const cropped = await cropFilesTopLeft(extracted, side, 'Cropping endpoint images');
setLoadingProgress(95, 'Mapping images', 'Matching by filename...');
const optimized = await mapExtractedFilesToTemplate(cropped);
await cacheProcessedFiles(optimized);
saveState();
render();
setLoadingProgress(100, 'Done', `${cropped.length} image(s) loaded and cached.`);
alert('Endpoint assets fetched, cached, and loaded successfully.');
} catch (err) {
console.error(err);
alert('Fetch failed. Check connection and endpoint availability.');
} finally {
hideLoading();
}
}
async function loadFromCachedAssets({ reset = false, silent = false, showOverlay = true, persistAndRender = true } = {}) {
if (showOverlay) showLoading(reset ? 'Resetting from saved ZIP...' : 'Loading saved assets...');
try {
setLoadingProgress(8, 'Reading cache', 'Loading template...');
const templateData = await cacheGetJson(CACHE_TEMPLATE_KEY);
setLoadingProgress(20, 'Reading cache', 'Loading ZIP...');
const zipBlob = await cacheGetBlob(CACHE_ZIP_KEY);
if (!templateData || !zipBlob) {
if (!silent) alert('No saved endpoint cache found. Use Fetch first.');
return false;
}
const normalized = normalizeTemplateData(templateData);
applyNormalizedTemplate(normalized);
let restoredCount = 0;
const processedPayload = await cacheGetValue(CACHE_PROCESSED_FILES_KEY);
const cacheMatchesCurrentDims = processedPayload
&& processedPayload.version === CACHE_PROCESSED_VERSION
&& Number(processedPayload.skinWidth) === Number(state.appData.skinWidth)
&& Number(processedPayload.skinHeight) === Number(state.appData.skinHeight);
if (cacheMatchesCurrentDims && processedPayload?.files?.length) {
const processedFiles = processedPayload.files.map((row) => new File([row.blob], row.name, { type: row.type || 'image/jpeg' }));
setLoadingProgress(88, 'Mapping cached processed images', 'Skipping recrop...');
await mapExtractedFilesToTemplate(processedFiles, { skipOptimize: true });
restoredCount = processedFiles.length;
} else {
const extracted = await processZipBlob(zipBlob, { progressPrefix: 'Extracting saved ZIP' });
const side = clampNum(parseInt(el('skinW')?.value, 10), 1, 8000, state.appData.skinWidth || 256);
const cropped = await cropFilesTopLeft(extracted, side, 'Cropping saved images');
setLoadingProgress(92, 'Mapping images', 'Matching by filename...');
const optimized = await mapExtractedFilesToTemplate(cropped);
await cacheProcessedFiles(optimized);
restoredCount = cropped.length;
}
autoArrangeGrid(state.uploadedImages.length);
if (persistAndRender) {
saveState();
render();
}
setLoadingProgress(100, 'Done', `${restoredCount} cached image(s) restored.`);
return true;
} catch (err) {
console.error(err);
if (!silent) alert('Failed to restore from saved ZIP cache. Fetch again to refresh cache.');
return false;
} finally {
if (showOverlay) hideLoading();
}
}
async function mapExtractedFilesToTemplate(files, options = {}) {
const skipOptimize = options.skipOptimize === true;
const map = new Map();
const optimizedFiles = [];
for (let i = 0; i < files.length; i += 1) {
const file = files[i];
let optimized = file;
if (!skipOptimize) {
try {
optimized = await optimizeImageForGrid(file);
} catch (err) {
console.warn('Grid optimize skipped:', file?.name, err);
}
}
map.set(normalizeName(optimized.name), optimized);
optimizedFiles.push(optimized);
if ((i + 1) % 10 === 0) await yieldToUi();
}
state.uploadedImages.forEach((item) => {
const file = map.get(normalizeName(item.name));
if (!file) return;
if (item.url && item.url.startsWith('blob:')) URL.revokeObjectURL(item.url);
item.url = URL.createObjectURL(file);
item.file = file;
map.delete(normalizeName(item.name));
});
if (map.size) {
map.forEach((file) => {
state.uploadedImages.push({
name: file.name,
group: 'Imported',
url: URL.createObjectURL(file),
file
});
});
}
autoArrangeGrid(state.uploadedImages.length);
return optimizedFiles;
}
async function cacheProcessedFiles(files) {
const list = Array.isArray(files) ? files : [];
if (!list.length) return;
const payload = {
version: CACHE_PROCESSED_VERSION,
savedAt: Date.now(),
skinWidth: state.appData.skinWidth,
skinHeight: state.appData.skinHeight,
files: list.map((f) => ({
name: f.name,
type: f.type || 'image/jpeg',
blob: f
}))
};
await cacheSetValue(CACHE_PROCESSED_FILES_KEY, payload);
}
async function cropFilesTopLeft(files, side, progressLabel) {
const result = [];
for (let i = 0; i < files.length; i += 1) {
const file = files[i];
try {
const blob = await cropSingleNormalizedTop(file);
const outName = file?.name || `image-${i + 1}.jpg`;
result.push(new File([blob], outName, { type: blob.type || 'image/jpeg' }));
} catch (err) {
console.error('Top-left crop failed:', file?.name, err);
}
setLoadingProgress(
Math.min(99, 60 + Math.round(((i + 1) / Math.max(1, files.length)) * 32)),
progressLabel || 'Cropping images',
file?.name || `Image ${i + 1}`
);
if ((i + 1) % 10 === 0) await yieldToUi();
}
return result;
}
function toBlobAsync(canvas, type = 'image/jpeg', quality = 0.92) {
return new Promise((resolve, reject) => {
if (!canvas) {
reject(new Error('Canvas missing'));
return;
}
canvas.toBlob((blob) => {
if (!blob) reject(new Error('toBlob failed'));
else resolve(blob);
}, type, quality);
});
}
function getNameWithoutExt(value) {
return String(value || '').replace(/\.[^.]+$/, '').trim();
}
function resetCropModalUi() {
state.cropMode = 'batch';
if (el('cropModalTitle')) el('cropModalTitle').textContent = 'Crop Images';
if (el('cropModalHint')) el('cropModalHint').textContent = 'Adjust crop, then click "Confirm & Map" to apply to all images';
if (el('btnConfirmCrop')) el('btnConfirmCrop').textContent = 'Confirm & Map';
el('insertMetaPanel')?.classList.add('hidden');
el('insertCropNewGroupWrap')?.classList.add('hidden');
}
function onInsertCropGroupChange() {
const value = el('insertCropGroupSelect')?.value || '';
const wantNew = value === '__new__';
const wrap = el('insertCropNewGroupWrap');
if (wrap) wrap.classList.toggle('hidden', !wantNew);
if (wantNew && el('insertCropNewGroup') && !el('insertCropNewGroup').value.trim()) {
el('insertCropNewGroup').focus();
}
}
function buildInsertGroupSelect(preferredGroup = '') {
const select = el('insertCropGroupSelect');
if (!select) return;
const groups = ensureGroupOrder();
const all = new Set(groups);
all.add('Uncategorized');
select.innerHTML = '';
[...all].forEach((group) => {
const opt = document.createElement('option');
opt.value = group;
opt.textContent = group;
select.appendChild(opt);
});
const newOpt = document.createElement('option');
newOpt.value = '__new__';
newOpt.textContent = 'Create New Group...';
select.appendChild(newOpt);
const target = String(preferredGroup || '').trim();
if (target && all.has(target)) {
select.value = target;
if (el('insertCropNewGroup')) el('insertCropNewGroup').value = '';
} else if (target) {
select.value = '__new__';
if (el('insertCropNewGroup')) el('insertCropNewGroup').value = target;
} else {
select.value = state.selectedGroup && all.has(state.selectedGroup) ? state.selectedGroup : 'Uncategorized';
if (el('insertCropNewGroup')) el('insertCropNewGroup').value = '';
}
onInsertCropGroupChange();
}
function setupInsertCropMeta(file) {
const nameInput = el('insertCropName');
if (nameInput) {
const base = getNameWithoutExt(file?.name || '') || `Image ${state.uploadedImages.length + 1}`;
nameInput.value = base;
}
updateInsertPositionBounds();
const posInput = el('insertCropPosition');
const suggestedPos = Math.max(1, state.uploadedImages.length + 1);
if (posInput) posInput.value = String(suggestedPos);
const targetIndex = Math.max(0, suggestedPos - 1);
const inferredGroup = state.templateSlots[targetIndex]?.group
|| state.uploadedImages[targetIndex]?.group
|| state.selectedGroup
|| 'Uncategorized';
buildInsertGroupSelect(inferredGroup);
}
function getInsertCropGroup() {
const selected = String(el('insertCropGroupSelect')?.value || '').trim();
if (selected && selected !== '__new__') return selected;
const custom = String(el('insertCropNewGroup')?.value || '').trim();
return custom;
}
function openCropModal(file, options = {}) {
if (state.activeTab !== 'collage') return;
resetCropModalUi();
state.cropMode = options.mode === 'insert' ? 'insert' : 'batch';
const modal = el('image-processor-modal');
const preview = el('image-processor-preview');
if (!modal || !preview) return;
if (state.cropper) {
state.cropper.destroy();
state.cropper = null;
}
if (state.previewUrl) URL.revokeObjectURL(state.previewUrl);
state.previewUrl = URL.createObjectURL(file);
preview.src = state.previewUrl;
if (state.cropMode === 'insert') {
if (el('cropModalTitle')) el('cropModalTitle').textContent = 'Insert Image';
if (el('cropModalHint')) el('cropModalHint').textContent = 'Crop the image, set name/position/group, then click "Crop & Insert".';
if (el('btnConfirmCrop')) el('btnConfirmCrop').textContent = 'Crop & Insert';
el('insertMetaPanel')?.classList.remove('hidden');
setupInsertCropMeta(file);
}
modal.classList.add('active');
preview.onload = () => {
try {
const isInsert = state.cropMode === 'insert';
const targetAspect = isInsert
? Math.max(0.01, (Number(state.appData.skinWidth) || 1) / Math.max(1, Number(state.appData.skinHeight) || 1))
: 1;
state.cropper = new Cropper(preview, {
viewMode: 1,
dragMode: isInsert ? 'move' : 'none',
autoCropArea: isInsert ? 0.85 : 0.92,
aspectRatio: targetAspect,
movable: isInsert,
zoomable: isInsert,
scalable: false,
rotatable: false,
cropBoxMovable: isInsert,
cropBoxResizable: isInsert,
ready: () => {
const imgData = state.cropper?.getImageData();
if (!imgData) return;
if (!isInsert) {
const requested = clampNum(parseInt(state.appData.skinWidth, 10), 1, 8000, 256);
const size = Math.max(1, Math.floor(Math.min(requested, imgData.naturalWidth, imgData.naturalHeight)));
state.cropper.setData({ x: 0, y: 0, width: size, height: size });
if (el('cropW')) el('cropW').value = String(size);
if (el('cropH')) el('cropH').value = String(size);
return;
}
let width = Math.max(1, Math.floor(imgData.naturalWidth * 0.82));
let height = Math.max(1, Math.floor(width / targetAspect));
const maxHeight = Math.floor(imgData.naturalHeight * 0.82);
if (height > maxHeight) {
height = Math.max(1, maxHeight);
width = Math.max(1, Math.floor(height * targetAspect));
}
width = Math.min(width, imgData.naturalWidth);
height = Math.min(height, imgData.naturalHeight);
const x = Math.max(0, Math.floor((imgData.naturalWidth - width) / 2));
const y = Math.max(0, Math.floor((imgData.naturalHeight - height) / 2));
state.cropper.setData({ x, y, width, height });
if (el('cropW')) el('cropW').value = String(width);
if (el('cropH')) el('cropH').value = String(height);
},
crop: (evt) => {
if (!isInsert) {
const side = Math.max(1, Math.round(Math.min(evt.detail.width, evt.detail.height)));
if (Math.round(evt.detail.x) !== 0 || Math.round(evt.detail.y) !== 0) {
state.cropper.setData({ x: 0, y: 0, width: side, height: side });
}
if (el('cropW')) el('cropW').value = String(side);
if (el('cropH')) el('cropH').value = String(side);
return;
}
if (el('cropW')) el('cropW').value = String(Math.max(1, Math.round(evt.detail.width)));
if (el('cropH')) el('cropH').value = String(Math.max(1, Math.round(evt.detail.height)));
}
});
} catch (err) {
console.error(err);
closeCropModal();
}
};
}
function closeCropModal() {
el('image-processor-modal')?.classList.remove('active');
if (state.cropper) {
state.cropper.destroy();
state.cropper = null;
}
if (state.previewUrl) {
URL.revokeObjectURL(state.previewUrl);
state.previewUrl = null;
}
state.pendingImages = [];
resetCropModalUi();
}
function applyCustomCropSize() {
if (!state.cropper) return;
if (state.cropMode === 'insert') {
const width = clampNum(parseInt(el('cropW')?.value, 10), 1, 8000, state.appData.skinWidth);
const height = clampNum(parseInt(el('cropH')?.value, 10), 1, 8000, state.appData.skinHeight);
state.cropper.setData({ width, height });
if (el('cropW')) el('cropW').value = String(width);
if (el('cropH')) el('cropH').value = String(height);
return;
}
const side = clampNum(parseInt(el('cropW')?.value, 10), 1, 8000, state.appData.skinWidth);
state.cropper.setData({ x: 0, y: 0, width: side, height: side });
if (el('cropW')) el('cropW').value = String(side);
if (el('cropH')) el('cropH').value = String(side);
}
async function cropAndMapImages() {
if (state.cropMode === 'insert') {
await cropAndInsertSingleImage();
return;
}
if (!state.cropper || !state.pendingImages.length) return;
const side = clampNum(parseInt(el('cropW')?.value, 10), 1, 8000, state.appData.skinWidth);
const files = [...state.pendingImages];
closeCropModal();
showLoading(`Cropping ${files.length} image(s)...`);
try {
const blobMap = new Map();
for (let i = 0; i < files.length; i += 1) {
const file = files[i];
try {
const blob = await cropSingle(file, side);
blobMap.set(normalizeName(file.name), { blob, originalName: file.name });
} catch (err) {
console.error('Crop failed:', file.name, err);
}
setLoadingProgress(Math.round(((i + 1) / Math.max(1, files.length)) * 100), 'Cropping images', file.name);
if ((i + 1) % 10 === 0) await yieldToUi();
}
const matchedKeys = new Set();
let matched = 0;
state.uploadedImages.forEach((item) => {
const key = normalizeName(item.name);
const entry = blobMap.get(key);
if (!entry) return;
if (item.url && item.url.startsWith('blob:')) URL.revokeObjectURL(item.url);
item.url = URL.createObjectURL(entry.blob);
item.file = new File([entry.blob], item.name, { type: entry.blob.type });
matched += 1;
matchedKeys.add(key);
});
const newItems = [];
blobMap.forEach((entry, key) => {
if (matchedKeys.has(key)) return;
newItems.push({
name: entry.originalName,
group: 'Imported',
url: URL.createObjectURL(entry.blob),
file: new File([entry.blob], entry.originalName, { type: entry.blob.type })
});
});
if (newItems.length > 0) state.uploadedImages.push(...newItems);
const duplicateRemoved = removeDuplicateMappedImages('duplicate-import');
autoArrangeGrid(state.uploadedImages.length);
saveState();
render();
console.info(`Mapped ${matched + newItems.length} image(s). Added ${newItems.length}, duplicates removed ${duplicateRemoved}.`);
} finally {
hideLoading();
}
}
async function cropAndInsertSingleImage() {
if (!state.cropper || !state.pendingImages.length) return;
const typedName = String(el('insertCropName')?.value || '').trim();
const sourceBase = getNameWithoutExt(state.pendingImages[0]?.name || '').trim();
const name = typedName || sourceBase || `Image ${state.uploadedImages.length + 1}`;
if (!name) {
alert('Enter image name before inserting.');
el('insertCropName')?.focus();
return;
}
const group = getInsertCropGroup();
if (!group) {
alert('Choose a group or enter a new group name.');
if ((el('insertCropGroupSelect')?.value || '') === '__new__') el('insertCropNewGroup')?.focus();
else el('insertCropGroupSelect')?.focus();
return;
}
const positionRaw = parseInt(el('insertCropPosition')?.value, 10);
const maxPos = Math.max(1, state.uploadedImages.length + 1);
const position = clampNum(positionRaw, 1, maxPos, maxPos);
const width = clampNum(parseInt(el('cropW')?.value, 10), 1, 8000, state.appData.skinWidth);
const height = clampNum(parseInt(el('cropH')?.value, 10), 1, 8000, state.appData.skinHeight);
let blob;
try {
const canvas = state.cropper.getCroppedCanvas({
width,
height,
imageSmoothingEnabled: true,
imageSmoothingQuality: 'high'
});
blob = await toBlobAsync(canvas, 'image/jpeg', 0.92);
} catch (err) {
console.error('Insert crop failed:', err);
alert('Failed to crop the image.');
return;
}
const fileName = /\.[a-z0-9]{2,5}$/i.test(name) ? name : `${name}.jpg`;
const file = new File([blob], fileName, { type: blob.type || 'image/jpeg' });
const url = URL.createObjectURL(blob);
closeCropModal();
const inserted = insertItemAtPosition({ name, group, position, url, file });
if (el('insertCropName')) el('insertCropName').value = '';
if (el('insertCropNewGroup')) el('insertCropNewGroup').value = '';
notify(`Inserted "${name}" at position ${inserted.position} in group "${inserted.group}".`);
}
function cropSingle(file, fixedSide) {
return new Promise((resolve, reject) => {
const targetSide = clampNum(parseInt(fixedSide, 10), 1, 8000, 256);
const srcUrl = URL.createObjectURL(file);
const img = new Image();
img.style.position = 'fixed';
img.style.left = '-99999px';
img.style.top = '-99999px';
img.style.width = '1px';
img.style.height = '1px';
img.style.opacity = '0';
const cleanup = (cropper) => {
try { cropper?.destroy(); } catch (_) {}
if (img.parentNode) img.parentNode.removeChild(img);
URL.revokeObjectURL(srcUrl);
};
img.onload = () => {
if (typeof Cropper === 'undefined') {
cleanup(null);
reject(new Error('CropperJS not available'));
return;
}
document.body.appendChild(img);
let cropper;
try {
cropper = new Cropper(img, {
viewMode: 1,
dragMode: 'none',
autoCropArea: 1,
aspectRatio: 1,
movable: false,
zoomable: false,
scalable: false,
rotatable: false,
cropBoxMovable: false,
cropBoxResizable: false,
ready: () => {
const data = cropper.getImageData();
const size = Math.max(1, Math.min(targetSide, data.naturalWidth, data.naturalHeight));
cropper.setData({ x: 0, y: 0, width: size, height: size });
const out = cropper.getCroppedCanvas({
width: targetSide,
height: targetSide,
imageSmoothingEnabled: true,
imageSmoothingQuality: 'high'
});
if (!out) {
cleanup(cropper);
reject(new Error('Cropper canvas failed'));
return;
}
out.toBlob((blob) => {
cleanup(cropper);
if (!blob) reject(new Error('toBlob failed'));
else resolve(blob);
}, 'image/jpeg', 0.92);
}
});
} catch (err) {
cleanup(cropper);
reject(err);
}
};
img.onerror = () => {
cleanup(null);
reject(new Error('Image load failed'));
};
img.src = srcUrl;
});
}
function cropSingleNormalizedTop(file) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = async () => {
try {
const sourceW = Math.max(1, img.width || 1);
const sourceH = Math.max(1, img.height || 1);
const side = Math.max(1, Math.min(sourceW, sourceH));
const targetW = clampNum(parseInt(state.appData.skinWidth, 10), 20, 3000, 335);
const targetH = clampNum(parseInt(state.appData.skinHeight, 10), 20, 3000, 300);
const canvas = document.createElement('canvas');
canvas.width = targetW;
canvas.height = targetH;
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(img, 0, 0, side, side, 0, 0, targetW, targetH);
URL.revokeObjectURL(img.src);
const blob = await canvasToBlob(canvas, 'image/jpeg', COLLAGE_JPEG_QUALITY);
resolve(blob);
} catch (err) {
URL.revokeObjectURL(img.src);
reject(err);
}
};
img.onerror = () => reject(new Error('Image load failed'));
img.src = URL.createObjectURL(file);
});
}
function normalizeName(filename) {
return filename.replace(/^\-\s*/, '').replace(/\.[^/.]+$/, '').toLowerCase().trim();
}
async function downloadCollageJpeg() {
if (!state.uploadedImages.length) {
alert('Nothing to export.');
return;
}
syncStateFromInputs();
showLoading('Preparing JPEG export...');
try {
const rendered = await renderCollageJpegBlob({
phaseBase: 0,
phaseSpan: 100,
progressLabel: 'Rendering JPEG'
});
if (!rendered?.blob) {
notify('Failed to export image.');
return;
}
let finalBlob = rendered.blob;
try {
finalBlob = await embedProjectIntoImageBlob(rendered.blob);
} catch (metaErr) {
console.warn('Project metadata embed skipped:', metaErr);
}
const url = URL.createObjectURL(finalBlob);
const a = document.createElement('a');
a.href = url;
a.download = `collage-${Date.now()}.jpg`;
a.click();
URL.revokeObjectURL(url);
if (rendered.usedScale < 0.99) {
notify(`Collage exported at ${Math.round(rendered.usedScale * 100)}% scale.`);
}
} catch (err) {
console.error(err);
notify('JPEG export failed.');
} finally {
hideLoading();
}
}
function parseHexRgb(hex) {
const v = normalizeHex(hex || '#ffffff');
return {
r: parseInt(v.slice(1, 3), 16),
g: parseInt(v.slice(3, 5), 16),
b: parseInt(v.slice(5, 7), 16)
};
}
function getAutoTileBorderColor(bgHex) {
const { r, g, b } = parseHexRgb(bgHex);
const luma = (0.299 * r) + (0.587 * g) + (0.114 * b);
return luma >= 140 ? 'rgba(0,0,0,0.28)' : 'rgba(255,255,255,0.36)';
}
function drawTileBorder(ctx, x, y, w, h, thickness, color) {
if (!ctx || w <= 0 || h <= 0) return;
const t = Math.max(1, Math.floor(thickness || 1));
ctx.fillStyle = color;
ctx.fillRect(x, y, w, Math.min(t, h));
ctx.fillRect(x, Math.max(y, y + h - t), w, Math.min(t, h));
ctx.fillRect(x, y, Math.min(t, w), h);
ctx.fillRect(Math.max(x, x + w - t), y, Math.min(t, w), h);
}
async function renderCollageJpegBlob(options = {}) {
const phaseBase = clampNum(parseInt(options.phaseBase, 10), 0, 100, 0);
const phaseSpan = clampNum(parseInt(options.phaseSpan, 10), 1, 100, 100);
const progressLabel = String(options.progressLabel || 'Rendering JPEG');
const outputType = String(options.outputType || 'image/jpeg').toLowerCase() === 'image/png'
? 'image/png'
: 'image/jpeg';
const outputQuality = outputType === 'image/jpeg'
? clampNum(parseFloat(options.outputQuality), 0.6, 1, COLLAGE_JPEG_QUALITY)
: undefined;
const drawBorders = options.drawBorders === true;
const borderWidth = clampNum(parseInt(options.borderWidth, 10), 0, 24, 1);
const highQuality = options.highQuality === true;
const preserveNativeResolution = options.preserveNativeResolution === true;
const cols = state.appData.cols;
const rows = state.appData.rows;
const w = state.appData.skinWidth;
const h = state.appData.skinHeight;
const gap = state.appData.gap;
const bg = state.appData.bgColor;
const borderColor = String(options.borderColor || getAutoTileBorderColor(bg));
const rawW = cols * w + (cols - 1) * gap + gap * 2;
const rawH = rows * h + (rows - 1) * gap + gap * 2;
const filtered = getFilteredIndices();
const visibleIndices = filtered.slice(0, Math.max(1, cols) * Math.max(1, rows));
const visibleCount = Math.min(visibleIndices.length, cols * rows);
if (!visibleCount) return { blob: null, usedScale: 1, width: rawW, height: rawH };
const ua = navigator.userAgent || '';
const isMobileUa = /Android|iPhone|iPad|iPod|Mobile/i.test(ua);
const rawMemHint = Number(navigator.deviceMemory);
const memHint = Number.isFinite(rawMemHint) ? rawMemHint : (isMobileUa ? 3 : 6);
const isLowMem = isMobileUa || memHint <= 4;
const MAX_DIM = highQuality
? (isLowMem ? 5120 : 10000)
: (isLowMem ? 4096 : 6144);
const MAX_PIXELS = highQuality
? (isLowMem ? 18000000 : 70000000)
: (isLowMem ? 12000000 : 26000000);
const dimScale = Math.min(MAX_DIM / Math.max(1, rawW), MAX_DIM / Math.max(1, rawH));
const pixelScale = Math.sqrt(MAX_PIXELS / Math.max(1, rawW * rawH));
const baseRenderScale = Math.max(0.1, Math.min(2, dimScale, pixelScale));
const attempts = [...new Set([
preserveNativeResolution ? 1 : null,
baseRenderScale,
baseRenderScale * 0.86,
baseRenderScale * 0.72,
baseRenderScale * 0.58
]
.filter((x) => Number.isFinite(x))
.map((s) => clampNum(s, 0.1, 2, 0.5).toFixed(4)))].map(Number);
let exportBlob = null;
let usedScale = attempts[0];
const tileSize = isLowMem ? 512 : 1024;
for (let attemptIndex = 0; attemptIndex < attempts.length; attemptIndex += 1) {
const renderScale = attempts[attemptIndex];
const outW = Math.max(1, Math.floor(rawW * renderScale));
const outH = Math.max(1, Math.floor(rawH * renderScale));
usedScale = renderScale;
const canvas = document.createElement('canvas');
canvas.width = outW;
canvas.height = outH;
const ctx = canvas.getContext('2d');
if (!ctx) continue;
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.fillStyle = bg;
ctx.fillRect(0, 0, outW, outH);
for (let i = 0; i < visibleCount; i += 1) {
const srcIdx = visibleIndices[i];
const item = state.uploadedImages[srcIdx];
const col = i % cols;
const row = Math.floor(i / cols);
if (row >= rows) break;
const x = (gap + col * (w + gap)) * renderScale;
const y = (gap + row * (h + gap)) * renderScale;
const iw = w * renderScale;
const ih = h * renderScale;
const seamFix = gap === 0 ? Math.max(2, Math.ceil(renderScale)) : 0;
const drawX = Math.floor(x);
const drawY = Math.floor(y);
const baseW = Math.max(1, Math.ceil(iw));
const baseH = Math.max(1, Math.ceil(ih));
const extendRight = col < cols - 1 ? seamFix : 0;
const extendBottom = row < rows - 1 ? seamFix : 0;
const drawW = baseW + extendRight;
const drawH = baseH + extendBottom;
if (item && item.url) {
try {
const img = await loadImage(item.url);
if (baseW > tileSize || baseH > tileSize) {
for (let ty = 0; ty < baseH; ty += tileSize) {
for (let tx = 0; tx < baseW; tx += tileSize) {
const sw = Math.min(tileSize, baseW - tx);
const sh = Math.min(tileSize, baseH - ty);
const srcX = (tx / baseW) * img.width;
const srcY = (ty / baseH) * img.height;
const srcW = (sw / baseW) * img.width;
const srcH = (sh / baseH) * img.height;
ctx.drawImage(img, srcX, srcY, srcW, srcH, drawX + tx, drawY + ty, sw, sh);
}
}
} else {
ctx.drawImage(img, drawX, drawY, baseW, baseH);
}
// Eliminate seam lines at zero gap by extending edge pixels into neighboring cells.
if (seamFix > 0) {
if (extendRight > 0) {
ctx.drawImage(
img,
Math.max(0, img.width - 1), 0, 1, img.height,
drawX + baseW, drawY, extendRight, baseH
);
}
if (extendBottom > 0) {
ctx.drawImage(
img,
0, Math.max(0, img.height - 1), img.width, 1,
drawX, drawY + baseH, baseW + extendRight, extendBottom
);
}
if (extendRight > 0 && extendBottom > 0) {
ctx.drawImage(
img,
Math.max(0, img.width - 1), Math.max(0, img.height - 1), 1, 1,
drawX + baseW, drawY + baseH, extendRight, extendBottom
);
}
}
} catch (err) {
console.warn('Skipping image during export:', item.name, err);
}
}
if (drawBorders && borderWidth > 0) {
drawTileBorder(ctx, drawX, drawY, drawW, drawH, Math.max(1, borderWidth * renderScale), borderColor);
}
const localPct = Math.round(((attemptIndex + (i + 1) / Math.max(1, visibleCount)) / attempts.length) * 100);
const pct = Math.min(100, Math.max(0, Math.round(phaseBase + ((localPct / 100) * phaseSpan))));
setLoadingProgress(pct, progressLabel, `${item?.name || `Image ${i + 1}`} @ ${Math.round(renderScale * 100)}%`);
if ((i + 1) % 10 === 0) await yieldToUi();
}
if (isLikelyBrokenBlackCanvas(ctx, outW, outH, bg)) continue;
const blob = await canvasToBlob(canvas, outputType, outputQuality);
if (blob && blob.size > 2048) {
exportBlob = blob;
break;
}
}
return { blob: exportBlob, usedScale, width: rawW, height: rawH, type: outputType };
}
async function loadImageFromBlob(blob) {
const url = URL.createObjectURL(blob);
try {
return await loadImage(url);
} finally {
URL.revokeObjectURL(url);
}
}
async function requestProfileCompositeBlobViaPostMessage(frame, timeoutMs = 12000) {
const targetWin = frame?.contentWindow;
if (!targetWin) throw new Error('Profile frame is not ready yet.');
const requestId = `profile-export-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
return new Promise((resolve, reject) => {
let settled = false;
let timer = null;
const cleanup = () => {
if (timer) clearTimeout(timer);
window.removeEventListener('message', onMessage);
};
const finishOk = (blob) => {
if (settled) return;
settled = true;
cleanup();
resolve(blob);
};
const finishErr = (err) => {
if (settled) return;
settled = true;
cleanup();
reject(err instanceof Error ? err : new Error(String(err || 'Profile export failed.')));
};
const onMessage = (event) => {
if (event.source !== targetWin) return;
const data = event?.data;
if (!data || data.type !== 'MLBB_PROFILE_EXPORT_RESPONSE' || data.requestId !== requestId) return;
if (data.ok && data.blob instanceof Blob) {
finishOk(data.blob);
return;
}
finishErr(new Error(data?.error || 'Profile exporter returned an invalid response.'));
};
window.addEventListener('message', onMessage);
timer = setTimeout(() => {
finishErr(new Error('Profile exporter did not respond. Open Profile tab once and try again.'));
}, Math.max(2000, Number(timeoutMs) || 12000));
try {
targetWin.postMessage({
type: 'MLBB_PROFILE_EXPORT_REQUEST',
requestId
}, '*');
} catch (err) {
finishErr(new Error('Unable to request profile export from frame.'));
}
});
}
async function getProfileCompositeBlobFromFrame() {
const frame = el('profileFrame');
if (!frame) throw new Error('Profile frame not available.');
let exporter = null;
try {
exporter = frame.contentWindow?.exportProfileCompositeBlob;
} catch (_) {
exporter = null;
}
if (typeof exporter === 'function') {
const blob = await exporter();
if (!(blob instanceof Blob)) throw new Error('Profile export returned invalid image.');
return blob;
}
const blob = await requestProfileCompositeBlobViaPostMessage(frame);
if (!(blob instanceof Blob)) throw new Error('Profile export returned invalid image.');
return blob;
}
function closeStitchPreviewModal() {
el('stitchPreviewModal')?.classList.remove('active');
}
function showStitchPreview(stitchedBlob, filename) {
if (!(stitchedBlob instanceof Blob)) return;
if (state.stitchedPreviewUrl) {
URL.revokeObjectURL(state.stitchedPreviewUrl);
state.stitchedPreviewUrl = null;
}
state.stitchedBlob = stitchedBlob;
state.stitchedFilename = String(filename || (`profile-collage-stitch-${Date.now()}.jpg`));
state.stitchedPreviewUrl = URL.createObjectURL(stitchedBlob);
const img = el('stitchPreviewImage');
if (img) img.src = state.stitchedPreviewUrl;
el('stitchPreviewModal')?.classList.add('active');
}
function downloadStitchedPreviewImage() {
if (!(state.stitchedBlob instanceof Blob)) {
alert('No stitched image is ready yet.');
return;
}
const url = URL.createObjectURL(state.stitchedBlob);
const a = document.createElement('a');
a.href = url;
a.download = state.stitchedFilename || `profile-collage-stitch-${Date.now()}.jpg`;
a.click();
URL.revokeObjectURL(url);
}
async function stitchProfileAndCollage() {
if (!state.uploadedImages.length) {
alert('Nothing to stitch. Load collage images first.');
return;
}
syncStateFromInputs();
showLoading('Stitching profile + collage...');
try {
const rendered = await renderCollageJpegBlob({
phaseBase: 5,
phaseSpan: 55,
progressLabel: 'Rendering collage',
outputType: 'image/png',
drawBorders: state.appData.gap > 0,
borderWidth: STITCH_TILE_BORDER_WIDTH,
highQuality: true,
preserveNativeResolution: true
});
if (!rendered?.blob) {
notify('Failed to render collage for stitching.');
return;
}
setLoadingProgress(65, 'Rendering profile', 'Reading profile canvas...');
const profileBlob = await getProfileCompositeBlobFromFrame();
const collageImg = await loadImageFromBlob(rendered.blob);
const profileImg = await loadImageFromBlob(profileBlob);
// Keep collage fully rendered with no stitch-time resampling.
const collageDrawW = collageImg.width;
const collageDrawH = collageImg.height;
// Resize profile to collage width only; collage stays untouched.
const finalWidth = Math.max(1, collageDrawW);
const profileScale = finalWidth / Math.max(1, profileImg.width);
const profileDrawW = finalWidth;
const profileDrawH = Math.max(1, Math.round(profileImg.height * profileScale));
const finalHeight = Math.max(1, profileDrawH + collageDrawH);
setLoadingProgress(82, 'Stitching', 'Combining profile on top of collage...');
const canvas = document.createElement('canvas');
canvas.width = finalWidth;
canvas.height = finalHeight;
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.fillStyle = state.appData.bgColor || '#ffffff';
ctx.fillRect(0, 0, finalWidth, finalHeight);
ctx.drawImage(profileImg, 0, 0, profileDrawW, profileDrawH);
ctx.drawImage(collageImg, 0, profileDrawH, collageDrawW, collageDrawH);
const stitchedBlob = await canvasToBlob(canvas, 'image/jpeg', STITCH_JPEG_QUALITY);
if (!stitchedBlob) {
notify('Failed to create stitched image.');
return;
}
const stitchedFilename = `profile-collage-stitch-${Date.now()}.jpg`;
showStitchPreview(stitchedBlob, stitchedFilename);
setLoadingProgress(100, 'Done', 'Stitched preview ready. Use download button.');
notify('Stitch complete: profile on top, collage below.');
} catch (err) {
console.error(err);
alert(`Stitch failed: ${err.message || 'Unknown error'}`);
} finally {
hideLoading();
}
}
function isLikelyBrokenBlackCanvas(ctx, width, height, bgHex) {
const bg = String(bgHex || '').trim().toLowerCase();
if (bg === '#000000' || bg === '#000') return false;
try {
const points = [
[0.1, 0.1], [0.5, 0.1], [0.9, 0.1],
[0.1, 0.5], [0.5, 0.5], [0.9, 0.5],
[0.1, 0.9], [0.5, 0.9], [0.9, 0.9]
];
let blackCount = 0;
for (const [px, py] of points) {
const x = Math.max(0, Math.min(width - 1, Math.floor(width * px)));
const y = Math.max(0, Math.min(height - 1, Math.floor(height * py)));
const data = ctx.getImageData(x, y, 1, 1).data;
if (data[3] > 200 && data[0] < 8 && data[1] < 8 && data[2] < 8) blackCount += 1;
}
return blackCount >= points.length;
} catch (_) {
return false;
}
}
function saveTemplate() {
if (!state.uploadedImages.length) {
alert('No images to save.');
return;
}
const payload = {
skinWidth: state.appData.skinWidth,
skinHeight: state.appData.skinHeight,
cols: state.appData.cols,
rows: state.appData.rows,
gap: state.appData.gap,
bgColor: state.appData.bgColor,
groupOrder: ensureGroupOrder(),
files: Object.fromEntries(
state.uploadedImages.map((x, i) => [String(i), { name: x.name, group: x.group || 'Uncategorized' }])
)
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'mlbb-collage-template.json';
a.click();
URL.revokeObjectURL(url);
}
function setStartupTemplate(e) {
const file = e.target.files[0];
e.target.value = '';
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const data = JSON.parse(ev.target.result);
const normalized = normalizeTemplateData(data);
localStorage.setItem(STARTUP_TEMPLATE_KEY, JSON.stringify(data));
applyNormalizedTemplate(normalized);
syncInputsFromState();
saveState();
render();
alert('Startup template saved in browser storage.');
} catch (err) {
console.error(err);
alert('Invalid startup template file.');
}
};
reader.readAsText(file);
}
function clearStartupTemplate() {
localStorage.removeItem(STARTUP_TEMPLATE_KEY);
alert('Startup template cleared. Embedded/default will load next refresh.');
}
function loadTemplate(e) {
const file = e.target.files[0];
e.target.value = '';
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const data = JSON.parse(ev.target.result);
const normalized = normalizeTemplateData(data);
applyNormalizedTemplate(normalized);
syncInputsFromState();
saveState();
render();
alert('Template loaded.');
} catch (err) {
console.error(err);
alert('Invalid template file.');
}
};
reader.readAsText(file);
}
function extractOcrLines(text) {
const raw = String(text || '').split(/\r?\n/);
const lines = raw
.map((line) => line.replace(/^[\s\d\-\.#:]+/, '').trim())
.map((line) => line.replace(/\s+/g, ' '))
.filter((line) => /[A-Za-z]/.test(line))
.filter((line) => line.length >= 4);
const unique = [];
const seen = new Set();
lines.forEach((line) => {
const key = line.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
unique.push(line);
});
return unique;
}
async function runOcrOnFile(file) {
const worker = await Tesseract.createWorker('eng');
const { data } = await worker.recognize(file);
await worker.terminate();
return data?.text || '';
}
async function handleOcrImport(e) {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
if (typeof Tesseract === 'undefined') {
alert('OCR library failed to load. Check internet and refresh.');
return;
}
try {
showLoading('Running OCR...');
const text = await runOcrOnFile(file);
const lines = extractOcrLines(text);
const input = el('autoOrderInput');
if (!input) return;
if (lines.length) {
const existing = String(input.value || '').trim();
input.value = existing ? `${existing}\n${lines.join('\n')}` : lines.join('\n');
alert(`OCR imported ${lines.length} lines.`);
} else {
alert('OCR found no usable lines.');
}
} catch (err) {
console.error(err);
alert('OCR failed. Try a clearer image.');
} finally {
hideLoading();
}
}
function setActiveTab(tab) {
state.activeTab = (tab === 'ocr' || tab === 'profile') ? tab : 'collage';
const collageBtn = el('btnTabCollage');
const ocrBtn = el('btnTabOcr');
const profileBtn = el('btnTabProfile');
const collagePanel = el('tabCollage');
const ocrPanel = el('tabOcr');
const profilePanel = el('tabProfile');
const viewport = el('viewport');
const ocrWorkspace = el('ocr-workspace');
const profileWorkspace = el('profile-workspace');
const mobileBar = el('mobileQuickBar');
collageBtn?.classList.toggle('active', state.activeTab === 'collage');
collageBtn?.classList.toggle('btn-primary', state.activeTab === 'collage');
ocrBtn?.classList.toggle('active', state.activeTab === 'ocr');
ocrBtn?.classList.toggle('btn-primary', state.activeTab === 'ocr');
profileBtn?.classList.toggle('active', state.activeTab === 'profile');
profileBtn?.classList.toggle('btn-primary', state.activeTab === 'profile');
collagePanel?.classList.toggle('active', state.activeTab === 'collage');
ocrPanel?.classList.toggle('active', state.activeTab === 'ocr');
profilePanel?.classList.toggle('active', state.activeTab === 'profile');
if (state.activeTab !== 'collage') {
closeCropModal();
viewport?.classList.add('hidden');
ocrWorkspace?.classList.toggle('hidden', state.activeTab !== 'ocr');
profileWorkspace?.classList.toggle('hidden', state.activeTab !== 'profile');
el('floating-panel')?.classList.remove('visible');
mobileBar?.classList.add('hidden');
updateActiveSkinsBadge();
return;
}
viewport?.classList.remove('hidden');
ocrWorkspace?.classList.add('hidden');
profileWorkspace?.classList.add('hidden');
mobileBar?.classList.remove('hidden');
updateSelectionUi();
updateActiveSkinsBadge();
if (window.matchMedia('(max-width: 980px)').matches) {
setTimeout(() => fitCollageToViewport(), 0);
}
}
function handleOcrBatchSelect(e) {
const files = Array.from(e.target.files || []);
state.ocrInputFiles = files;
state.ocrProcessedBatchFiles = [];
state.ocrZipBlob = null;
state.ocrZipName = '';
state.ocrAllImageBlob = null;
state.ocrAllImageName = '';
if (el('btnDownloadOcrZip')) el('btnDownloadOcrZip').disabled = true;
const status = el('ocrStatus');
if (status) {
status.textContent = files.length
? 'Selected ' + files.length + ' image(s). Batch size: ' + clampNum(parseInt(el('ocrBatchSize')?.value, 10), 1, 100, 5) + '.'
: 'No OCR stack output yet.';
}
}
function clearOcrStackResults() {
state.ocrStackResults.forEach((x) => {
if (x.url) URL.revokeObjectURL(x.url);
});
state.ocrStackResults = [];
const host = el('ocrResults');
if (host) host.innerHTML = '';
}
function canvasToBlob(canvas, type = 'image/png', quality) {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (!blob) reject(new Error('Canvas export failed'));
else resolve(blob);
}, type, quality);
});
}
function loadImageFromFile(file) {
return new Promise((resolve, reject) => {
const img = new Image();
const objUrl = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(objUrl);
resolve(img);
};
img.onerror = () => {
URL.revokeObjectURL(objUrl);
reject(new Error('Image load failed: ' + file.name));
};
img.src = objUrl;
});
}
async function cropBandCanvas(file, topPct = 0.25, bottomPct = 0.7) {
const img = await loadImageFromFile(file);
const w = Math.max(1, img.naturalWidth || img.width || 1);
const h = Math.max(1, img.naturalHeight || img.height || 1);
const y1 = Math.max(0, Math.floor(h * topPct));
const y2 = Math.min(h, Math.ceil(h * bottomPct));
const cropH = Math.max(1, y2 - y1);
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = cropH;
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(img, 0, y1, w, cropH, 0, 0, w, cropH);
return canvas;
}
function stackCanvasesVertically(canvases) {
const width = Math.max(1, ...canvases.map((c) => c.width));
const height = Math.max(1, canvases.reduce((sum, c) => sum + c.height, 0));
const out = document.createElement('canvas');
out.width = width;
out.height = height;
const ctx = out.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
let y = 0;
canvases.forEach((c) => {
const x = Math.floor((width - c.width) / 2);
ctx.drawImage(c, x, y, c.width, c.height);
y += c.height;
});
return out;
}
function resizeCanvas(canvas, scale = 1) {
const s = clampNum(parseFloat(scale), 0.1, 1, 1);
if (s >= 0.999) return canvas;
const out = document.createElement('canvas');
out.width = Math.max(1, Math.round(canvas.width * s));
out.height = Math.max(1, Math.round(canvas.height * s));
const ctx = out.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(canvas, 0, 0, out.width, out.height);
return out;
}
function clampByte(v) {
return Math.max(0, Math.min(255, Math.round(v)));
}
function applySharpen(canvas) {
const ctx = canvas.getContext('2d');
const { width, height } = canvas;
if (width < 3 || height < 3) return;
const srcData = ctx.getImageData(0, 0, width, height);
const src = srcData.data;
const dst = new Uint8ClampedArray(src);
const k = [
0, -1, 0,
-1, 5, -1,
0, -1, 0
];
for (let y = 1; y < height - 1; y += 1) {
for (let x = 1; x < width - 1; x += 1) {
const idx2 = (y * width + x) * 4;
for (let c = 0; c < 3; c += 1) {
let sum = 0;
let ki = 0;
for (let ky = -1; ky <= 1; ky += 1) {
for (let kx = -1; kx <= 1; kx += 1) {
const pIdx = ((y + ky) * width + (x + kx)) * 4 + c;
sum += src[pIdx] * k[ki];
ki += 1;
}
}
dst[idx2 + c] = clampByte(sum);
}
dst[idx2 + 3] = src[idx2 + 3];
}
}
srcData.data.set(dst);
ctx.putImageData(srcData, 0, 0);
}
function renderOcrResults(outputs) {
clearOcrStackResults();
const host = el('ocrResults');
if (!host) return;
outputs.forEach((row, i) => {
const url = URL.createObjectURL(row.blob);
state.ocrStackResults.push({ url, name: row.name, count: row.count });
const card = document.createElement('article');
card.className = 'ocr-result-card';
const img = document.createElement('img');
img.src = url;
img.alt = row.name;
const meta = document.createElement('div');
meta.className = 'ocr-result-meta';
meta.textContent = 'Batch ' + (i + 1) + ': ' + row.count + ' image(s)';
card.appendChild(img);
card.appendChild(meta);
host.appendChild(card);
});
}
async function processOcrStackBatches() {
const files = state.ocrInputFiles.length
? state.ocrInputFiles.slice()
: Array.from(el('ocrBatchLoader')?.files || []);
if (!files.length) {
alert('Select images for OCR stack first.');
return;
}
const batchSize = 5;
const outputScale = 0.8;
const outputJpegQuality = 0.8;
const outputs = [];
let failed = 0;
if (el('btnDownloadOcrZip')) el('btnDownloadOcrZip').disabled = true;
state.ocrProcessedBatchFiles = [];
state.ocrZipBlob = null;
state.ocrZipName = '';
state.ocrAllImageBlob = null;
state.ocrAllImageName = '';
try {
showLoading('Processing OCR in batches of 5...');
for (let i = 0; i < files.length; i += batchSize) {
const batch = files.slice(i, i + batchSize);
const strips = [];
for (const file of batch) {
try {
const strip = await cropBandCanvas(file, 0.25, 0.7);
strips.push(strip);
} catch (err) {
console.error(err);
failed += 1;
}
}
if (!strips.length) continue;
const stacked = stackCanvasesVertically(strips);
applySharpen(stacked);
const resized = resizeCanvas(stacked, outputScale);
const blob = await canvasToBlob(resized, 'image/jpeg', outputJpegQuality);
const index = outputs.length + 1;
outputs.push({
name: 'ocr-stack-batch-' + String(index).padStart(3, '0') + '.jpg',
blob,
count: strips.length
});
setLoadingProgress(Math.round(((i + batch.length) / Math.max(1, files.length)) * 70), 'Stacking batches of 5', `Batch ${index}`);
await yieldToUi();
}
if (!outputs.length) {
clearOcrStackResults();
const status2 = el('ocrStatus');
if (status2) status2.textContent = 'No valid output generated.';
alert('Could not process selected images.');
return;
}
const zip = new JSZip();
outputs.forEach((row) => zip.file(row.name, row.blob));
state.ocrZipBlob = await zip.generateAsync({ type: 'blob' });
state.ocrZipName = 'ocr-stack-' + Date.now() + '.zip';
state.ocrAllImageBlob = outputs[0].blob;
state.ocrAllImageName = outputs[0].name;
state.ocrProcessedBatchFiles = outputs.map((row) => new File([row.blob], row.name, { type: 'image/jpeg' }));
if (el('btnDownloadOcrZip')) el('btnDownloadOcrZip').disabled = false;
renderOcrResults(outputs);
const status = el('ocrStatus');
if (status) {
status.textContent = `Done. ${outputs.length} stacked batch image(s), resized to 80% JPEG preview/output. Failed: ${failed}.`;
}
} catch (err) {
console.error(err);
alert('OCR stack processing failed.');
} finally {
hideLoading();
}
}
function downloadOcrZip() {
if (!state.ocrZipBlob) {
alert('No OCR ZIP ready. Process images first.');
return;
}
const url = URL.createObjectURL(state.ocrZipBlob);
const a = document.createElement('a');
a.href = url;
a.download = state.ocrZipName || ('ocr-stack-' + Date.now() + '.zip');
a.click();
URL.revokeObjectURL(url);
}
function initGeminiSettings() {
const savedKey = localStorage.getItem(GEMINI_API_KEY_KEY) || '';
const savedModel = localStorage.getItem(GEMINI_MODEL_KEY) || GEMINI_DEFAULT_MODEL;
const savedAuto = localStorage.getItem(GEMINI_AUTO_APPLY_KEY);
if (el('geminiApiKey')) el('geminiApiKey').value = savedKey;
if (el('geminiModel')) el('geminiModel').value = savedModel;
if (el('geminiAutoApplyCollage')) el('geminiAutoApplyCollage').checked = savedAuto !== '0';
updateSelectedGeminiModelInfo();
}
function persistGeminiSettings() {
const key = String(el('geminiApiKey')?.value || '');
const model = String(el('geminiModel')?.value || '').trim() || GEMINI_DEFAULT_MODEL;
const autoApply = el('geminiAutoApplyCollage') ? !!el('geminiAutoApplyCollage').checked : true;
localStorage.setItem(GEMINI_API_KEY_KEY, key);
localStorage.setItem(GEMINI_MODEL_KEY, model);
localStorage.setItem(GEMINI_AUTO_APPLY_KEY, autoApply ? '1' : '0');
}
async function fetchGeminiModels() {
const apiKey = String(el('geminiApiKey')?.value || '').trim();
const info = el('geminiModelInfo');
if (!apiKey) {
if (info) info.textContent = 'Enter Gemini API key first.';
return;
}
showLoading('Fetching Gemini models...');
try {
const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(apiKey)}`);
const payload = await res.json();
if (!res.ok) throw new Error(payload?.error?.message || `Model list failed (${res.status})`);
const models = Array.isArray(payload?.models) ? payload.models : [];
const freeTierModels = models
.filter((m) => isLikelyFreeTierGeminiModel(String(m?.name || '').replace(/^models\//, '')))
.filter((m) => Array.isArray(m?.supportedGenerationMethods) && m.supportedGenerationMethods.includes('generateContent'))
.map((m) => ({
name: String(m?.name || '').replace(/^models\//, '').trim(),
displayName: String(m?.displayName || '').trim(),
inputTokenLimit: Number.isFinite(m?.inputTokenLimit) ? m.inputTokenLimit : null,
outputTokenLimit: Number.isFinite(m?.outputTokenLimit) ? m.outputTokenLimit : null
}))
.filter((m) => m.name);
state.geminiModelsMeta = freeTierModels;
const names = freeTierModels.map((m) => m.name);
const list = el('geminiModelList');
if (list) {
list.innerHTML = '';
names.forEach((name) => {
const opt = document.createElement('option');
opt.value = name;
list.appendChild(opt);
});
}
if (names.length && el('geminiModel') && !el('geminiModel').value.trim()) {
const preferred = names.find((n) => n === GEMINI_DEFAULT_MODEL);
el('geminiModel').value = preferred || names[0];
}
persistGeminiSettings();
if (info) info.textContent = names.length ? `Loaded ${names.length} free-tier model(s).` : 'No free-tier Gemini models found.';
const limitsBox = el('geminiModelLimits');
if (limitsBox) {
limitsBox.value = names.length
? freeTierModels.map((m) => `${m.name} | input: ${m.inputTokenLimit ?? '-'} | output: ${m.outputTokenLimit ?? '-'}`).join('\n')
: 'No free-tier Gemini models found for this API key.';
}
updateSelectedGeminiModelInfo();
setGeminiResultText(names.length ? JSON.stringify({ freeTierModels: freeTierModels }, null, 2) : JSON.stringify(payload, null, 2));
} catch (err) {
console.error(err);
if (info) info.textContent = `Model fetch failed: ${err.message || 'Unknown error'}`;
const limitsBox = el('geminiModelLimits');
if (limitsBox) limitsBox.value = `Model fetch failed: ${err.message || 'Unknown error'}`;
setGeminiResultText(`Model fetch failed:\n${err.message || 'Unknown error'}`);
} finally {
hideLoading();
}
}
function isLikelyFreeTierGeminiModel(name) {
const n = String(name || '').toLowerCase();
if (!n.startsWith('gemini')) return false;
if (!n.includes('flash')) return false;
if (n.includes('pro')) return false;
if (n.includes('vision')) return false;
return true;
}
function updateSelectedGeminiModelInfo() {
const model = String(el('geminiModel')?.value || '').trim();
const info = el('geminiModelInfo');
if (!info) return;
if (!model) {
info.textContent = 'Pick a model.';
return;
}
const meta = (state.geminiModelsMeta || []).find((m) => m.name === model);
if (!meta) {
info.textContent = `Model: ${model}. Limit info unavailable (fetch models).`;
return;
}
info.textContent = `Model: ${meta.name} | Input limit: ${meta.inputTokenLimit ?? '-'} | Output limit: ${meta.outputTokenLimit ?? '-'}`;
}
function handleGeminiZipSelect(e) {
const file = e.target.files?.[0] || null;
state.geminiZipFile = file;
e.target.value = '';
const info = el('geminiZipInfo');
if (!info) return;
info.textContent = file
? `Selected file: ${file.name} (${Math.round(file.size / 1024)} KB)`
: 'Uses processed OCR image (all-at-once) by default, or choose your own file.';
}
function setGeminiResultText(text) {
state.geminiResponseText = String(text || '');
const box = el('geminiResult');
if (box) box.value = state.geminiResponseText;
const dl = el('btnDownloadGeminiResult');
if (dl) dl.disabled = !state.geminiResponseText.trim();
renderGeminiMarkdownPreview();
}
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function formatGeminiMarkdownLite(text) {
const source = String(text || '');
if (!source.trim()) return '<p>No response yet.</p>';
const blocks = source.split(/\n{2,}/);
return blocks.map((block) => {
const lines = block.split('\n');
const first = lines[0] || '';
if (/^###\s+/.test(first)) return `<h3>${escapeHtml(first.replace(/^###\s+/, ''))}</h3>`;
if (/^##\s+/.test(first)) return `<h2>${escapeHtml(first.replace(/^##\s+/, ''))}</h2>`;
if (/^#\s+/.test(first)) return `<h1>${escapeHtml(first.replace(/^#\s+/, ''))}</h1>`;
if (lines.every((x) => /^\s*[-*]\s+/.test(x))) {
const items = lines.map((x) => `<li>${escapeHtml(x.replace(/^\s*[-*]\s+/, ''))}</li>`).join('');
return `<ul>${items}</ul>`;
}
if (/^```/.test(first) || /^ {4,}/.test(first)) return `<pre><code>${escapeHtml(block.replace(/^```[a-z]*\s*/i, '').replace(/```$/, ''))}</code></pre>`;
return `<p>${escapeHtml(block).replace(/\n/g, '<br>')}</p>`;
}).join('');
}
function renderGeminiMarkdownPreview() {
const box = el('geminiMarkdownPreview');
if (!box) return;
box.innerHTML = formatGeminiMarkdownLite(state.geminiResponseText);
}
function normalizeGeminiUploadMime(file) {
const name = String(file?.name || '').toLowerCase();
const type = String(file?.type || '').toLowerCase();
if (name.endsWith('.png') || type.includes('image/png')) return 'image/png';
if (name.endsWith('.jpg') || name.endsWith('.jpeg') || type.includes('image/jpeg')) return 'image/jpeg';
if (name.endsWith('.webp') || type.includes('image/webp')) return 'image/webp';
if (name.endsWith('.gif') || type.includes('image/gif')) return 'image/gif';
if (name.endsWith('.zip')) return 'application/zip';
if (type.includes('zip')) return 'application/zip';
return 'application/octet-stream';
}
function isZipFile(file) {
const name = String(file?.name || '').toLowerCase();
const mime = normalizeGeminiUploadMime(file);
return name.endsWith('.zip') || mime === 'application/zip';
}
async function extractImagesFromZipFile(file) {
if (typeof JSZip === 'undefined') throw new Error('JSZip is not available');
const zip = await JSZip.loadAsync(file);
const out = [];
for (const [name, entry] of Object.entries(zip.files)) {
if (entry.dir) continue;
if (!/\.(png|jpg|jpeg|webp|gif)$/i.test(name)) continue;
const blob = await entry.async('blob');
const clean = name.split('/').pop() || name;
const mime = normalizeGeminiUploadMime(new File([blob], clean, { type: blob.type || 'application/octet-stream' }));
out.push(new File([blob], clean, { type: mime }));
if (out.length >= 80) break;
}
return out;
}
async function makeActiveGeminiInputFiles() {
if (state.geminiZipFile) {
if (isZipFile(state.geminiZipFile)) {
return extractImagesFromZipFile(state.geminiZipFile);
}
const mime = normalizeGeminiUploadMime(state.geminiZipFile);
return [new File([state.geminiZipFile], state.geminiZipFile.name || ('upload-' + Date.now()), { type: mime })];
}
if (Array.isArray(state.ocrProcessedBatchFiles) && state.ocrProcessedBatchFiles.length) {
return state.ocrProcessedBatchFiles.slice();
}
if (state.ocrInputFiles.length) {
return state.ocrInputFiles
.map((f) => new File([f], f.name || ('ocr-' + Date.now() + '.png'), { type: normalizeGeminiUploadMime(f) }))
.filter((f) => String(f.type || '').startsWith('image/'))
.slice(0, 80);
}
if (state.ocrAllImageBlob) {
return [new File([state.ocrAllImageBlob], state.ocrAllImageName || ('ocr-stack-all-' + Date.now() + '.png'), { type: 'image/png' })];
}
return [];
}
function isImageMime(mime) {
return String(mime || '').toLowerCase().startsWith('image/');
}
async function optimizeImageForGemini(file, options = {}) {
const mime = normalizeGeminiUploadMime(file);
if (!isImageMime(mime)) return file;
const maxDim = clampNum(parseInt(options.maxDim, 10), 256, 5000, GEMINI_UPLOAD_MAX_DIM);
const quality = clampNum(parseFloat(options.quality), 0.5, 0.98, GEMINI_UPLOAD_JPEG_QUALITY);
const img = await loadImageFromFile(file);
const sw = Math.max(1, img.naturalWidth || img.width || 1);
const sh = Math.max(1, img.naturalHeight || img.height || 1);
const scale = Math.min(1, maxDim / Math.max(sw, sh));
const tw = Math.max(1, Math.round(sw * scale));
const th = Math.max(1, Math.round(sh * scale));
const canvas = document.createElement('canvas');
canvas.width = tw;
canvas.height = th;
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(img, 0, 0, tw, th);
const outBlob = await canvasToBlob(canvas, 'image/jpeg', quality);
const base = String(file.name || ('img-' + Date.now())).replace(/\.[^.]+$/, '');
return new File([outBlob], `${base}.jpg`, { type: 'image/jpeg' });
}
async function optimizeImageForGrid(file) {
const img = await loadImageFromFile(file);
const sw = Math.max(1, img.naturalWidth || img.width || 1);
const sh = Math.max(1, img.naturalHeight || img.height || 1);
const tw = clampNum(parseInt(state.appData.skinWidth, 10), 20, 3000, 335);
const th = clampNum(parseInt(state.appData.skinHeight, 10), 20, 3000, 300);
const scale = Math.max(tw / sw, th / sh);
const dw = Math.max(1, Math.round(sw * scale));
const dh = Math.max(1, Math.round(sh * scale));
const dx = Math.floor((tw - dw) / 2);
const dy = 0;
const canvas = document.createElement('canvas');
canvas.width = tw;
canvas.height = th;
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(img, dx, dy, dw, dh);
const blob = await canvasToBlob(canvas, 'image/jpeg', COLLAGE_JPEG_QUALITY);
const base = String(file?.name || ('grid-' + Date.now())).replace(/\.[^.]+$/, '');
return new File([blob], `${base}.jpg`, { type: 'image/jpeg' });
}
async function optimizeGeminiInputFiles(files) {
const list = Array.isArray(files) ? files : [];
const out = [];
for (let i = 0; i < list.length; i += 1) {
const file = list[i];
const optimized = await optimizeImageForGemini(file, {
maxDim: GEMINI_UPLOAD_MAX_DIM,
quality: GEMINI_UPLOAD_JPEG_QUALITY
});
out.push(optimized);
if ((i + 1) % 8 === 0) await yieldToUi();
}
return out;
}
async function uploadFileToGemini(apiKey, file) {
const mime = normalizeGeminiUploadMime(file);
const startRes = await fetch(`https://generativelanguage.googleapis.com/upload/v1beta/files?key=${encodeURIComponent(apiKey)}`, {
method: 'POST',
headers: {
'X-Goog-Upload-Protocol': 'resumable',
'X-Goog-Upload-Command': 'start',
'X-Goog-Upload-Header-Content-Length': String(file.size),
'X-Goog-Upload-Header-Content-Type': mime,
'Content-Type': 'application/json'
},
body: JSON.stringify({
file: {
display_name: file.name || ('ocr-zip-' + Date.now() + '.zip')
}
})
});
if (!startRes.ok) {
throw new Error(`Gemini upload start failed (${startRes.status})`);
}
const uploadUrl = startRes.headers.get('x-goog-upload-url');
if (!uploadUrl) throw new Error('Gemini upload URL missing');
const uploadRes = await fetch(uploadUrl, {
method: 'POST',
headers: {
'X-Goog-Upload-Offset': '0',
'X-Goog-Upload-Command': 'upload, finalize',
'Content-Type': mime
},
body: file
});
if (!uploadRes.ok) {
throw new Error(`Gemini upload finalize failed (${uploadRes.status})`);
}
const uploaded = await uploadRes.json();
const meta = uploaded?.file || uploaded;
if (!meta?.uri) throw new Error('Gemini file URI missing');
return {
uri: meta.uri,
mimeType: mime,
name: meta.name || file.name
};
}
async function uploadFilesToGemini(apiKey, files, options = {}) {
const all = Array.isArray(files) ? files : [];
const maxConcurrency = clampNum(parseInt(options.maxConcurrency, 10), 1, 8, 4);
const uploaded = new Array(all.length);
let nextIndex = 0;
let done = 0;
const worker = async () => {
while (true) {
const idx = nextIndex;
nextIndex += 1;
if (idx >= all.length) break;
uploaded[idx] = await uploadFileToGemini(apiKey, all[idx]);
done += 1;
if (typeof options.onProgress === 'function') {
options.onProgress(done, all.length, all[idx]);
}
}
};
const workers = [];
const count = Math.min(maxConcurrency, all.length || 1);
for (let i = 0; i < count; i += 1) workers.push(worker());
await Promise.all(workers);
return uploaded.filter(Boolean);
}
function extractGeminiText(payload) {
const candidates = Array.isArray(payload?.candidates) ? payload.candidates : [];
const parts = candidates[0]?.content?.parts || [];
const text = parts.map((p) => p?.text || '').join('\n').trim();
if (text) return text;
return JSON.stringify(payload || {}, null, 2);
}
function buildGeminiStrictPrompt(userPrompt) {
const base = String(userPrompt || '').trim();
const schema = [
'Return STRICT JSON only. No markdown, no code fences, no explanation.',
'Use this schema:',
'{',
' "names": ["skin name 1", "skin name 2"],',
' "template": { optional template json },',
' "notes": "optional short note"',
'}'
].join('\n');
return `${base}\n\n${schema}`.trim();
}
function tryParseGeminiJson(text) {
const raw = String(text || '').trim();
if (!raw) return null;
try {
return JSON.parse(raw);
} catch (_) {}
const fenceMatch = raw.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
if (fenceMatch?.[1]) {
try {
return JSON.parse(fenceMatch[1].trim());
} catch (_) {}
}
const first = raw.indexOf('{');
const last = raw.lastIndexOf('}');
if (first >= 0 && last > first) {
const slice = raw.slice(first, last + 1);
try {
return JSON.parse(slice);
} catch (_) {}
}
return null;
}
function extractNamesFromGeminiJson(data) {
if (!data || typeof data !== 'object') return [];
const direct = Array.isArray(data.names) ? data.names : [];
const autoOrder = Array.isArray(data.autoOrder) ? data.autoOrder : [];
const skins = Array.isArray(data.skins) ? data.skins : [];
const nested = Array.isArray(data?.collage?.names) ? data.collage.names : [];
const merged = [...direct, ...autoOrder, ...nested, ...skins];
const out = [];
const seen = new Set();
merged.forEach((row) => {
const name = typeof row === 'string' ? row.trim() : String(row?.name || '').trim();
if (!name) return;
const key = name.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
out.push(name);
});
return out;
}
function tryExtractTemplateFromGeminiJson(data) {
if (!data || typeof data !== 'object') return null;
if (data.template && typeof data.template === 'object') return data.template;
if (data.files && typeof data.files === 'object') return data;
return null;
}
function autoApplyGeminiJson(data, options = {}) {
const autoApplyCollage = options.autoApplyCollage !== false;
let appliedTemplate = false;
let appliedNames = false;
const templateRaw = tryExtractTemplateFromGeminiJson(data);
if (templateRaw) {
try {
const normalized = normalizeTemplateData(templateRaw);
applyNormalizedTemplate(normalized);
appliedTemplate = true;
} catch (err) {
console.warn('Gemini template ignored:', err);
}
}
const names = extractNamesFromGeminiJson(data);
if (names.length) {
const input = el('autoOrderInput');
if (input) {
input.value = names.join('\n');
appliedNames = true;
if (autoApplyCollage) applyAutoCollageOrder();
}
}
if ((appliedNames && !autoApplyCollage) || (!appliedNames && appliedTemplate)) {
saveState();
render();
}
return { appliedTemplate, appliedNames, namesCount: names.length, autoAppliedCollage: appliedNames && autoApplyCollage };
}
async function sendZipToGemini() {
const apiKey = String(el('geminiApiKey')?.value || '').trim();
const prompt = String(el('geminiPrompt')?.value || '').trim();
const model = String(el('geminiModel')?.value || GEMINI_DEFAULT_MODEL).trim() || GEMINI_DEFAULT_MODEL;
const inputFilesRaw = await makeActiveGeminiInputFiles();
const status = el('ocrStatus');
persistGeminiSettings();
if (!apiKey || !prompt || !inputFilesRaw.length) {
if (status) status.textContent = 'Gemini requires API key, prompt, and images (from selected ZIP or OCR input).';
setGeminiResultText('Missing required fields: API key, prompt, or images.');
return;
}
showLoading('Sending images to Gemini...');
setGeminiResultText('');
try {
setLoadingProgress(3, 'Preparing images', `Optimizing ${inputFilesRaw.length} image(s)...`);
const optimized = await optimizeGeminiInputFiles(inputFilesRaw);
if (status) status.textContent = `Uploading ${optimized.length} optimized image(s) to Gemini...`;
const uploadedFiles = await uploadFilesToGemini(apiKey, optimized, {
maxConcurrency: 6,
onProgress: (done, total, file) => {
setLoadingProgress(10 + Math.round((done / Math.max(1, total)) * 45), 'Gemini upload', file?.name || `Image ${done}`);
}
});
setLoadingProgress(55, 'Gemini processing', `${model} with ${uploadedFiles.length} image(s)`);
const fileParts = uploadedFiles.map((uploaded) => ({
file_data: {
mime_type: uploaded.mimeType,
file_uri: uploaded.uri
}
}));
const body = {
contents: [{
role: 'user',
parts: [
{ text: buildGeminiStrictPrompt(prompt) },
...fileParts
]
}]
};
const genRes = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(apiKey)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const payload = await genRes.json();
if (!genRes.ok) {
setGeminiResultText(JSON.stringify(payload || {}, null, 2));
throw new Error(payload?.error?.message || `Gemini generate failed (${genRes.status})`);
}
const text = extractGeminiText(payload);
setGeminiResultText(text);
const parsed = tryParseGeminiJson(text);
if (!parsed) {
if (status) status.textContent = 'Gemini response ready (not valid JSON, auto-apply skipped).';
setLoadingProgress(100, 'Gemini done', 'Response received');
return;
}
const autoApplyCollage = el('geminiAutoApplyCollage') ? !!el('geminiAutoApplyCollage').checked : true;
const applied = autoApplyGeminiJson(parsed, { autoApplyCollage });
if (status) {
status.textContent = applied.appliedNames || applied.appliedTemplate
? `Gemini parsed. Names: ${applied.namesCount}, Template: ${applied.appliedTemplate ? 'yes' : 'no'}, Auto collage: ${applied.autoAppliedCollage ? 'yes' : 'no'}.`
: 'Gemini JSON parsed, but nothing applicable found.';
}
setLoadingProgress(100, 'Gemini done', 'Response received');
} catch (err) {
console.error(err);
if (status) status.textContent = `Gemini failed: ${err.message || 'Unknown error'}`;
if (!String(state.geminiResponseText || '').trim()) {
setGeminiResultText(`Gemini failed:\n${err.message || 'Unknown error'}`);
}
} finally {
hideLoading();
}
}
function downloadGeminiResult() {
const text = String(state.geminiResponseText || '').trim();
if (!text) return;
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'gemini-response-' + Date.now() + '.txt';
a.click();
URL.revokeObjectURL(url);
}
function startPan(e) {
if (e.button !== 0) return;
state.panning = true;
state.panMoved = false;
state.panStartedOnSkin = !!e.target.closest('.skin-item');
state.panDownClientX = e.clientX;
state.panDownClientY = e.clientY;
state.startPanX = e.clientX - state.panX;
state.startPanY = e.clientY - state.panY;
}
function onPan(e) {
if (!state.panning) return;
const movedX = e.clientX - state.panDownClientX;
const movedY = e.clientY - state.panDownClientY;
const movedDist = Math.hypot(movedX, movedY);
if (!state.panMoved && movedDist >= 4) {
state.panMoved = true;
if (state.panStartedOnSkin) state.suppressSkinClickUntil = Date.now() + 260;
}
if (!state.panMoved) return;
state.panX = e.clientX - state.startPanX;
state.panY = e.clientY - state.startPanY;
applyCollageTransform();
}
function endPan() {
if (state.panMoved && state.panStartedOnSkin) {
state.suppressSkinClickUntil = Date.now() + 160;
}
state.panning = false;
state.panMoved = false;
state.panStartedOnSkin = false;
}
function onWheelZoom(e) {
e.preventDefault();
adjustZoom(-e.deltaY * 0.001);
}
function adjustZoom(delta) {
state.scale = clampNum(state.scale + delta, 0.1, 2, 0.5);
if (el('zoomLevel')) el('zoomLevel').value = state.scale;
updateZoomText();
applyCollageTransform();
}
function fitCollageToViewport() {
const viewport = el('viewport');
if (!viewport) return;
const totalCols = Math.max(1, state.appData.cols);
const totalRows = Math.max(1, state.appData.rows);
const gap = Math.max(0, state.appData.gap);
const collageWidth = (totalCols * state.appData.skinWidth) + ((totalCols + 1) * gap);
const collageHeight = (totalRows * state.appData.skinHeight) + ((totalRows + 1) * gap);
const vw = Math.max(1, viewport.clientWidth - 18);
const vh = Math.max(1, viewport.clientHeight - 18);
const nextScale = Math.min(2, Math.max(0.1, Math.min(vw / collageWidth, vh / collageHeight)));
state.scale = clampNum(nextScale, 0.1, 2, 0.5);
state.panX = 0;
state.panY = 0;
if (el('zoomLevel')) el('zoomLevel').value = state.scale;
updateZoomText();
applyCollageTransform();
}
function getPointerDistance(a, b) {
if (!a || !b) return 0;
return Math.hypot((a.x - b.x), (a.y - b.y));
}
function startPanFromPoint(clientX, clientY, startedOnSkin) {
state.panning = true;
state.panMoved = false;
state.panStartedOnSkin = !!startedOnSkin;
state.panDownClientX = clientX;
state.panDownClientY = clientY;
state.startPanX = clientX - state.panX;
state.startPanY = clientY - state.panY;
}
function onPanFromPoint(clientX, clientY) {
if (!state.panning) return false;
const movedX = clientX - state.panDownClientX;
const movedY = clientY - state.panDownClientY;
const movedDist = Math.hypot(movedX, movedY);
if (!state.panMoved && movedDist >= 4) {
state.panMoved = true;
if (state.panStartedOnSkin) state.suppressSkinClickUntil = Date.now() + 260;
}
if (!state.panMoved) return false;
state.panX = clientX - state.startPanX;
state.panY = clientY - state.startPanY;
applyCollageTransform();
return true;
}
function onViewportPointerDown(e) {
if (e.pointerType === 'mouse' && e.button !== 0) return;
const viewport = el('viewport');
const startedOnSkin = !!e.target.closest('.skin-item');
// Do not capture immediately when pressing a skin tile; it can swallow tile clicks.
if (!startedOnSkin) viewport?.setPointerCapture?.(e.pointerId);
state.activePointers.set(e.pointerId, {
x: e.clientX,
y: e.clientY,
startedOnSkin
});
if (state.activePointers.size === 1) {
const p = state.activePointers.get(e.pointerId);
startPanFromPoint(p.x, p.y, p.startedOnSkin);
} else if (state.activePointers.size >= 2) {
const [a, b] = [...state.activePointers.values()];
state.panning = false;
state.panMoved = false;
state.pinchStartDistance = Math.max(1, getPointerDistance(a, b));
state.pinchStartScale = state.scale;
}
}
function onViewportPointerMove(e) {
if (!state.activePointers.has(e.pointerId)) return;
const prev = state.activePointers.get(e.pointerId);
state.activePointers.set(e.pointerId, { ...prev, x: e.clientX, y: e.clientY });
if (state.activePointers.size >= 2) {
const [a, b] = [...state.activePointers.values()];
const dist = Math.max(1, getPointerDistance(a, b));
const factor = dist / Math.max(1, state.pinchStartDistance);
state.scale = clampNum(state.pinchStartScale * factor, 0.1, 2, state.scale);
if (el('zoomLevel')) el('zoomLevel').value = state.scale;
updateZoomText();
applyCollageTransform();
state.suppressSkinClickUntil = Date.now() + 250;
if (e.pointerType === 'touch') e.preventDefault();
return;
}
const moved = onPanFromPoint(e.clientX, e.clientY);
if (moved) {
const viewport = el('viewport');
viewport?.setPointerCapture?.(e.pointerId);
}
if (moved && e.pointerType === 'touch') e.preventDefault();
}
function onViewportPointerUp(e) {
if (state.activePointers.has(e.pointerId)) state.activePointers.delete(e.pointerId);
const viewport = el('viewport');
try {
viewport?.releasePointerCapture?.(e.pointerId);
} catch (_) {}
if (state.activePointers.size === 0) {
endPan();
return;
}
if (state.activePointers.size === 1) {
const p = [...state.activePointers.values()][0];
startPanFromPoint(p.x, p.y, p.startedOnSkin);
} else if (state.activePointers.size >= 2) {
const [a, b] = [...state.activePointers.values()];
state.panning = false;
state.pinchStartDistance = Math.max(1, getPointerDistance(a, b));
state.pinchStartScale = state.scale;
}
}
function applyCollageTransform() {
const area = el('collage-area');
if (!area) return;
area.style.transform = `translate(calc(-50% + ${state.panX}px), calc(-50% + ${state.panY}px)) scale(${state.scale})`;
}
function handleKeydown(e) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'f') {
e.preventDefault();
toggleUtilityDrawer(true);
return;
}
if (e.ctrlKey && e.key.toLowerCase() === 'z') {
e.preventDefault();
undo();
}
if ((e.ctrlKey && e.key.toLowerCase() === 'y') || (e.ctrlKey && e.shiftKey && e.key === 'Z')) {
e.preventDefault();
redo();
}
if ((e.key === 'Delete' || e.key === 'Backspace') && state.selected.size) {
e.preventDefault();
deleteSelected();
}
if (e.key === 'Escape') {
if (state.drawerOpen) {
toggleUtilityDrawer(false);
return;
}
if (el('image-processor-modal')?.classList.contains('active')) closeCropModal();
else clearSelection();
}
if (!e.ctrlKey && !e.metaKey && e.key.toLowerCase() === 'm') {
toggleMulti();
}
}
function showLoading(msg) {
const overlay = el('loading-overlay');
if (!overlay) return;
state.loadingProgress = 0;
const text = overlay.querySelector('.loading-text');
if (text) text.textContent = msg || 'PROCESSING...';
const label = el('loading-progress-label');
if (label) label.textContent = msg || 'Starting...';
const detail = el('loading-progress-detail');
if (detail) detail.textContent = '';
const bar = el('loading-progress-bar');
if (bar) bar.style.width = '0%';
overlay.classList.add('active');
}
function hideLoading() {
el('loading-overlay')?.classList.remove('active');
}
function setLoadingProgress(percent, message, detail) {
const pct = clampNum(parseInt(percent, 10), 0, 100, 0);
state.loadingProgress = pct;
const bar = el('loading-progress-bar');
const label = el('loading-progress-label');
const detailEl = el('loading-progress-detail');
if (bar) bar.style.width = `${pct}%`;
if (label && message) label.textContent = `${message} (${pct}%)`;
if (detailEl) detailEl.textContent = detail || '';
}
async function yieldToUi() {
await new Promise((resolve) => setTimeout(resolve, 0));
}
function openCacheDb() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(CACHE_DB_NAME, 1);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(CACHE_STORE_NAME)) {
db.createObjectStore(CACHE_STORE_NAME);
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error || new Error('IndexedDB open failed'));
});
}
async function cacheSetValue(key, value) {
const db = await openCacheDb();
await new Promise((resolve, reject) => {
const tx = db.transaction(CACHE_STORE_NAME, 'readwrite');
tx.objectStore(CACHE_STORE_NAME).put(value, key);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error || new Error('Cache write failed'));
});
db.close();
}
async function cacheGetValue(key) {
const db = await openCacheDb();
const value = await new Promise((resolve, reject) => {
const tx = db.transaction(CACHE_STORE_NAME, 'readonly');
const req = tx.objectStore(CACHE_STORE_NAME).get(key);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error || new Error('Cache read failed'));
});
db.close();
return value;
}
function cacheSetBlob(key, blob) {
return cacheSetValue(key, blob);
}
function cacheGetBlob(key) {
return cacheGetValue(key);
}
function cacheSetJson(key, data) {
return cacheSetValue(key, data);
}
function cacheGetJson(key) {
return cacheGetValue(key);
}
async function fetchBlobWithProgress(url, onProgress) {
const res = await fetch(url, { cache: 'no-store' });
if (!res.ok) throw new Error(`Download failed (${res.status})`);
const total = Number(res.headers.get('content-length')) || 0;
if (!res.body || !total) {
const blob = await res.blob();
if (onProgress) onProgress(100, 'Downloaded');
return blob;
}
const reader = res.body.getReader();
const chunks = [];
let received = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.byteLength;
const pct = Math.round((received / total) * 100);
if (onProgress) onProgress(pct, `${Math.round(received / 1024 / 1024)}MB / ${Math.round(total / 1024 / 1024)}MB`);
if (received % (1024 * 1024 * 4) < value.byteLength) await yieldToUi();
}
return new Blob(chunks);
}
function loadImage(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.decoding = 'async';
const src = String(url || '');
if (src && !src.startsWith('blob:') && !src.startsWith('data:')) {
img.crossOrigin = 'anonymous';
img.referrerPolicy = 'no-referrer';
}
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('Image load failed'));
img.src = src;
});
}
function normalizeHex(hex) {
const v = String(hex || '').trim();
return /^#[0-9a-fA-F]{6}$/.test(v) ? v : '#ffffff';
}
function clampNum(value, min, max, fallback) {
if (!Number.isFinite(value)) return fallback;
return Math.max(min, Math.min(max, value));
}
function debounce(fn, wait) {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), wait);
};
}
function notify(message) {
const msg = String(message || '').trim();
if (!msg) return;
const status = el('ocrStatus');
if (status) status.textContent = msg;
console.info(msg);
}
function disableBrowserDialogs() {
try {
window.alert = (msg) => notify(msg);
window.confirm = (msg) => {
notify(msg);
return true;
};
} catch (_) {}
}
function buildProjectPayload() {
return {
version: 1,
savedAt: Date.now(),
appData: { ...state.appData },
groupOrder: state.groupOrder.slice(),
templateSlots: state.templateSlots.map((x) => ({ name: x.name, group: x.group || 'Uncategorized' })),
uploadedImages: state.uploadedImages.map((x) => ({ name: x.name, group: x.group || 'Uncategorized' }))
};
}
function applyProjectPayload(payload) {
if (!payload || typeof payload !== 'object') throw new Error('Invalid project payload');
if (!Array.isArray(payload.uploadedImages)) throw new Error('Missing uploaded images');
if (payload.appData && typeof payload.appData === 'object') {
state.appData = { ...state.appData, ...payload.appData };
}
state.groupOrder = Array.isArray(payload.groupOrder) ? payload.groupOrder.slice() : [];
state.templateSlots = Array.isArray(payload.templateSlots)
? payload.templateSlots.map((x) => ({ name: x?.name || '', group: x?.group || 'Uncategorized' }))
: [];
state.uploadedImages = payload.uploadedImages.map((x) => ({
name: x?.name || 'Imported',
group: x?.group || 'Uncategorized',
url: null,
file: null
}));
state.selected.clear();
state.pendingPlacement = [];
state.pendingBinPlacement = [];
state.groupDrag = null;
state.panX = 0;
state.panY = 0;
ensureGroupOrder();
syncInputsFromState();
}
async function embedProjectIntoImageBlob(imageBlob) {
const payload = buildProjectPayload();
const json = JSON.stringify(payload);
const imgBytes = new Uint8Array(await imageBlob.arrayBuffer());
if (!(imgBytes[0] === 0xFF && imgBytes[1] === 0xD8)) {
return imageBlob;
}
const encoded = btoa(unescape(encodeURIComponent(json)));
const encoder = new TextEncoder();
const maxChunkChars = 54000;
const totalChunks = Math.ceil(encoded.length / maxChunkChars);
const segments = [];
for (let i = 0; i < totalChunks; i += 1) {
const chunk = encoded.slice(i * maxChunkChars, (i + 1) * maxChunkChars);
const tag = `MLBBP1:${i + 1}/${totalChunks}:${chunk}`;
const payloadBytes = encoder.encode(tag);
const segLen = payloadBytes.length + 2;
if (segLen > 65535) throw new Error('Project payload chunk too large');
const seg = new Uint8Array(4 + payloadBytes.length);
seg[0] = 0xFF;
seg[1] = 0xFE; // COM marker
seg[2] = (segLen >> 8) & 0xFF;
seg[3] = segLen & 0xFF;
seg.set(payloadBytes, 4);
segments.push(seg);
}
const extra = segments.reduce((sum, seg) => sum + seg.length, 0);
const out = new Uint8Array(imgBytes.length + extra);
let offset = 0;
out.set(imgBytes.slice(0, 2), offset);
offset += 2;
segments.forEach((seg) => {
out.set(seg, offset);
offset += seg.length;
});
out.set(imgBytes.slice(2), offset);
return new Blob([out], { type: 'image/jpeg' });
}
async function extractEmbeddedProjectFromImage(file) {
const bytes = new Uint8Array(await file.arrayBuffer());
// Preferred path: parse JPEG COM segments injected by this app.
if (bytes[0] === 0xFF && bytes[1] === 0xD8) {
const decoder = new TextDecoder();
const parts = [];
let pos = 2;
while (pos + 4 <= bytes.length) {
if (bytes[pos] !== 0xFF) {
pos += 1;
continue;
}
const marker = bytes[pos + 1];
if (marker === 0xD9 || marker === 0xDA) break;
if (marker === 0x00 || marker === 0xFF) {
pos += 1;
continue;
}
const segLen = (bytes[pos + 2] << 8) | bytes[pos + 3];
if (segLen < 2 || pos + 2 + segLen > bytes.length) break;
if (marker === 0xFE) {
const data = bytes.slice(pos + 4, pos + 2 + segLen);
const txt = decoder.decode(data);
if (txt.startsWith('MLBBP1:')) {
const first = txt.indexOf(':');
const second = txt.indexOf(':', first + 1);
if (first > -1 && second > first) {
const idxRaw = txt.slice(first + 1, second);
const split = idxRaw.split('/');
const idx = parseInt(split[0], 10);
const total = parseInt(split[1], 10);
const chunk = txt.slice(second + 1);
if (Number.isFinite(idx) && Number.isFinite(total) && chunk) {
parts.push({ idx, total, chunk });
}
}
}
}
pos += 2 + segLen;
}
if (parts.length) {
parts.sort((a, b) => a.idx - b.idx);
const expected = parts[0].total;
if (expected > 0 && parts.length === expected) {
const raw = parts.map((p) => p.chunk).join('');
try {
const json = decodeURIComponent(escape(atob(raw)));
return JSON.parse(json);
} catch (_) {
return null;
}
}
}
}
// Backward compatibility: trailing marker from older versions.
const text = new TextDecoder().decode(bytes);
const idx = text.lastIndexOf(EMBED_MARKER);
if (idx < 0) return null;
const raw = text.slice(idx + EMBED_MARKER.length).trim();
if (!raw) return null;
try {
const json = decodeURIComponent(escape(atob(raw)));
return JSON.parse(json);
} catch (_) {
return null;
}
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();