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