// 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 = `::${i + 1}${g}`; 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, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } 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 = '
Bin is empty
'; return; } const filtered = getFilteredBinEntries(state.searchQuery || ''); if (!filtered.length) { host.innerHTML = '
No bin match for current search
'; 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 ? `${name}` : `
N/A
`; const selectedClass = state.binSelected.has(idx) ? ' selected' : ''; return `
${preview}
${name}
${group} - ${reason}
`; }) .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, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function formatGeminiMarkdownLite(text) { const source = String(text || ''); if (!source.trim()) return '

No response yet.

'; const blocks = source.split(/\n{2,}/); return blocks.map((block) => { const lines = block.split('\n'); const first = lines[0] || ''; if (/^###\s+/.test(first)) return `

${escapeHtml(first.replace(/^###\s+/, ''))}

`; if (/^##\s+/.test(first)) return `

${escapeHtml(first.replace(/^##\s+/, ''))}

`; if (/^#\s+/.test(first)) return `

${escapeHtml(first.replace(/^#\s+/, ''))}

`; if (lines.every((x) => /^\s*[-*]\s+/.test(x))) { const items = lines.map((x) => `
  • ${escapeHtml(x.replace(/^\s*[-*]\s+/, ''))}
  • `).join(''); return ``; } if (/^```/.test(first) || /^ {4,}/.test(first)) return `
    ${escapeHtml(block.replace(/^```[a-z]*\s*/i, '').replace(/```$/, ''))}
    `; return `

    ${escapeHtml(block).replace(/\n/g, '
    ')}

    `; }).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();