Slate needs a modern browser

Please upgrade to one of:

  • Chrome / Edge 111 or newer
  • Firefox 113 or newer
  • Safari 16.4 or newer

Slate uses OffscreenCanvas, createImageBitmap, and File System Access API — all standard in current browsers.

`; } // ── Contact sheet build ────────────────────────────────────────────────────── async function buildContactSheet(files, onProgress) { const cols = cfg.cols; const rows = Math.ceil(files.length / cols); const thumbW = cfg.thumbSize; const thumbH = Math.round(thumbW * 0.75); // 4:3 cell const labelH = cfg.showLabels ? 20 : 0; const pad = 8; const cellW = thumbW + pad; const cellH = thumbH + labelH + pad; const outW = cols * cellW + pad; const outH = rows * cellH + pad; const canvas = new OffscreenCanvas(outW, outH); const ctx = canvas.getContext('2d'); ctx.fillStyle = cfg.bg; ctx.fillRect(0, 0, outW, outH); ctx.font = '10px -apple-system, sans-serif'; ctx.textBaseline = 'top'; for (let i = 0; i < files.length; i++) { const file = files[i].file ?? (files[i].handle ? await files[i].handle.getFile() : null); if (!file) continue; const row = Math.floor(i / cols); const col = i % cols; const x = pad + col * cellW; const y = pad + row * cellH; try { const bitmap = await createImageBitmap(file); // Cover-fit the thumb into thumbW × thumbH const sRatio = bitmap.width / bitmap.height; const tRatio = thumbW / thumbH; let sx = 0, sy = 0, sw = bitmap.width, sh = bitmap.height; if (sRatio > tRatio) { // wider source: crop horizontally sw = bitmap.height * tRatio; sx = (bitmap.width - sw) / 2; } else { sh = bitmap.width / tRatio; sy = (bitmap.height - sh) / 2; } ctx.drawImage(bitmap, sx, sy, sw, sh, x, y, thumbW, thumbH); bitmap.close?.(); } catch { ctx.fillStyle = '#444'; ctx.fillRect(x, y, thumbW, thumbH); } if (cfg.showLabels) { ctx.fillStyle = pickReadable(cfg.bg); const label = files[i].name.length > 32 ? files[i].name.slice(0, 30) + '…' : files[i].name; ctx.fillText(label, x + 4, y + thumbH + 4); } onProgress(i + 1, files.length); } return canvas.convertToBlob({ type: cfg.sheetFormat === 'jpeg' ? 'image/jpeg' : 'image/png', quality: 0.9 }); } // ── Helpers ────────────────────────────────────────────────────────────────── async function thumbToDataUrl(bitmap, maxSize, type, quality) { const scale = Math.min(maxSize / bitmap.width, maxSize / bitmap.height, 1); const w = Math.round(bitmap.width * scale); const h = Math.round(bitmap.height * scale); const c = new OffscreenCanvas(w, h); const ctx = c.getContext('2d'); ctx.imageSmoothingQuality = 'high'; ctx.drawImage(bitmap, 0, 0, w, h); const blob = await c.convertToBlob({ type, quality }); return blobToDataUrl(blob); } function blobToDataUrl(blob) { return new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result); r.onerror = () => rej(new Error('read failed')); r.readAsDataURL(blob); }); } function pickReadable(bg) { // Pick black or white text depending on bg luminance const { r, g, b } = hexToRgb(bg); const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255; return lum > 0.55 ? '#111' : '#fafafa'; } function pickSoft(bg) { const { r, g, b } = hexToRgb(bg); const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255; return lum > 0.55 ? 'rgba(0,0,0,.12)' : 'rgba(255,255,255,.12)'; } function hexToRgb(hex) { const s = hex.replace('#', ''); const h = s.length === 3 ? s.split('').map(c => c + c).join('') : s; return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16) }; } function tstamp() { const d = new Date(), p = n => String(n).padStart(2, '0'); return String(d.getFullYear()).slice(-2) + p(d.getMonth()+1) + p(d.getDate()) + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds()); } return { initGallery }; })(); const __m_ops_jpeg_lossless_js = (() => { // Lossless JPEG rotate via EXIF orientation manipulation. // The pixel bytes are untouched — we only flip the orientation tag. // Works in any viewer that honours EXIF orientation (most do). // CW rotation sequence for non-flipped orientations const CW_NORMAL = [1, 6, 3, 8]; // Same sequence for flipped orientations const CW_FLIPPED = [2, 5, 4, 7]; function computeNewOrientation(current, cwTurns) { const t = ((cwTurns % 4) + 4) % 4; const i = CW_NORMAL.indexOf(current); if (i !== -1) return CW_NORMAL[(i + t) % 4]; const fi = CW_FLIPPED.indexOf(current); if (fi !== -1) return CW_FLIPPED[(fi + t) % 4]; return current; } // Returns a new Blob with the rotated orientation tag. // cwTurns: 1 = 90° CW, 2 = 180°, 3 = 90° CCW async function rotateJpegLossless(blob, cwTurns) { if (blob.type && blob.type !== 'image/jpeg') throw new Error('Not a JPEG'); const buf = new Uint8Array(await blob.arrayBuffer()); const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); if (view.getUint16(0) !== 0xFFD8) throw new Error('Not a JPEG (missing SOI marker)'); // Walk APP0-APP15 segments looking for EXIF APP1 let offset = 2; while (offset < buf.length - 2) { const marker = view.getUint16(offset); if (marker === 0xFFDA) break; // SOS — no more metadata if (marker < 0xFFE0 || marker > 0xFFEF) { // Not an APP marker; move past it if it has a length const segLen = view.getUint16(offset + 2); offset += 2 + segLen; continue; } const segLen = view.getUint16(offset + 2); if (marker === 0xFFE1 && view.getUint32(offset + 4) === 0x45786966 && view.getUint16(offset + 8) === 0x0000) { // EXIF APP1 found — locate orientation tag (0x0112) in IFD0 const tiffStart = offset + 10; const byteOrder = view.getUint16(tiffStart); if (byteOrder !== 0x4949 && byteOrder !== 0x4D4D) throw new Error('Invalid TIFF byte order'); const le = byteOrder === 0x4949; if (view.getUint16(tiffStart + 2, le) !== 42) throw new Error('Invalid TIFF magic'); const ifd0Off = tiffStart + view.getUint32(tiffStart + 4, le); const count = view.getUint16(ifd0Off, le); for (let i = 0; i < count; i++) { const entryOff = ifd0Off + 2 + i * 12; if (view.getUint16(entryOff, le) === 0x0112) { // Orientation entry: value is inline at entryOff + 8 (type SHORT, count 1) const current = view.getUint16(entryOff + 8, le); const next = computeNewOrientation(current, cwTurns); // Return a copy with the new value const out = new Uint8Array(buf); const outView = new DataView(out.buffer); outView.setUint16(entryOff + 8, next, le); return new Blob([out], { type: 'image/jpeg' }); } } // APP1 exists but no orientation tag — inject one return insertOrientationInExif(buf, offset, segLen, tiffStart, computeNewOrientation(1, cwTurns)); } offset += 2 + segLen; } // No EXIF segment at all — inject a minimal one return injectMinimalExif(buf, computeNewOrientation(1, cwTurns)); } // Build a minimal APP1 EXIF segment containing just orientation = `ori`. // Little-endian TIFF, IFD0 with 1 entry, orientation (0x0112 / SHORT / count=1 / value). function buildMinimalExifSegment(ori) { const tiffLen = 8 /*header*/ + 2 /*count*/ + 12 /*entry*/ + 4 /*next IFD*/; const segLen = 2 /*length field*/ + 6 /*Exif\0\0*/ + tiffLen; const segBuf = new Uint8Array(2 + segLen); const dv = new DataView(segBuf.buffer); let p = 0; dv.setUint16(p, 0xFFE1); p += 2; // APP1 marker dv.setUint16(p, segLen); p += 2; // length (excludes marker) new Uint8Array(segBuf.buffer, p, 6).set([0x45,0x78,0x69,0x66,0x00,0x00]); p += 6; // "Exif\0\0" const tiffStart = p; dv.setUint16(p, 0x4949); p += 2; // II (little-endian) dv.setUint16(p, 42, true); p += 2; dv.setUint32(p, 8, true); p += 4; // IFD0 offset (from tiffStart) dv.setUint16(p, 1, true); p += 2; // entry count dv.setUint16(p, 0x0112, true); p += 2; // tag dv.setUint16(p, 3, true); p += 2; // type SHORT dv.setUint32(p, 1, true); p += 4; // count dv.setUint16(p, ori, true); p += 2; // value dv.setUint16(p, 0, true); p += 2; // padding to 4 bytes dv.setUint32(p, 0, true); p += 4; // next IFD offset (none) return segBuf; } function injectMinimalExif(buf, ori) { const seg = buildMinimalExifSegment(ori); const out = new Uint8Array(2 + seg.length + (buf.length - 2)); out[0] = 0xFF; out[1] = 0xD8; out.set(seg, 2); out.set(buf.subarray(2), 2 + seg.length); return new Blob([out], { type: 'image/jpeg' }); } // Replace the existing APP1 segment with a version that has the orientation tag added. // This is rare: only fires when EXIF exists but lacks orientation. For simplicity we // swap it for a minimal EXIF rather than splicing into the old IFD. function insertOrientationInExif(buf, segStart, oldSegLen, _tiffStart, ori) { const seg = buildMinimalExifSegment(ori); const before = buf.subarray(0, segStart); const after = buf.subarray(segStart + 2 + oldSegLen); const out = new Uint8Array(before.length + seg.length + after.length); out.set(before, 0); out.set(seg, before.length); out.set(after, before.length + seg.length); return new Blob([out], { type: 'image/jpeg' }); } return { computeNewOrientation, rotateJpegLossless }; })(); const __m_ui_jpegrotate_js = (() => { const state = __m_core_state_js.state; const on = __m_core_events_js.on; const emit = __m_core_events_js.emit; const rotateJpegLossless = __m_ops_jpeg_lossless_js.rotateJpegLossless; const esc = __m_core_html_js.esc; function initJpegRotate() { on('modal:jpeg-rotate', showModal); } async function showModal() { const entry = state.session.files[state.session.currentIndex]; const currentFile = entry?.file; const isJpeg = currentFile && (currentFile.type === 'image/jpeg' || /\.jpe?g$/i.test(entry.name ?? '')); let dlg = document.getElementById('modal-jpeg-rotate'); if (!dlg) { dlg = document.createElement('dialog'); dlg.id = 'modal-jpeg-rotate'; document.body.appendChild(dlg); } dlg.innerHTML = `

Lossless JPEG rotate

Rotates the current JPEG without re-encoding — the pixel bytes stay untouched and only the EXIF orientation tag is flipped. Zero quality loss. Any viewer that honours EXIF orientation will display the rotated image correctly.

${isJpeg ? '' : `
Current file is not a JPEG — this operation only works on JPEGs.
`} ${isJpeg ? `

File: ${esc(entry.name)}

` : ''}
${isJpeg ? `` : ''}
`; dlg.querySelector('[data-cancel]').addEventListener('click', () => dlg.close()); dlg.querySelector('[data-apply]')?.addEventListener('click', async () => { const turns = +dlg.querySelector('input[name="jr-angle"]:checked').value; dlg.close(); await runRotate(entry, currentFile, turns); }); dlg.showModal(); } async function runRotate(entry, file, turns) { try { const rotated = await rotateJpegLossless(file, turns); const saved = await saveRotated(entry, rotated); emit('toast', { message: `Saved as ${saved}`, level: 'ok' }); } catch (err) { emit('error', { message: `Lossless rotate failed: ${err.message}` }); } } async function saveRotated(entry, blob) { const suffix = tstamp(); const base = entry.name.replace(/\.[^.]+$/, ''); const name = `${base}-${suffix}.jpg`; const dir = state.session.folderHandle; if (dir) { try { const fh = await dir.getFileHandle(name, { create: true }); const w = await fh.createWritable(); await w.write(blob); await w.close(); return name; } catch { /* fall through to download */ } } const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 5000); return name; } function tstamp() { const d = new Date(), p = n => String(n).padStart(2, '0'); return String(d.getFullYear()).slice(-2) + p(d.getMonth()+1) + p(d.getDate()) + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds()); } return { initJpegRotate }; })(); const __m_core_session_js = (() => { const DB_NAME = 'slate.v1'; const DB_VER = 1; const STORE = 'sessions'; let _db = null; async function getDb() { if (_db) return _db; return new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, DB_VER); req.onupgradeneeded = () => req.result.createObjectStore(STORE); req.onsuccess = () => { _db = req.result; resolve(_db); }; req.onerror = () => reject(req.error); }); } async function saveSession(data) { try { const db = await getDb(); await new Promise((res, rej) => { const tx = db.transaction(STORE, 'readwrite'); tx.objectStore(STORE).put(data, 'current'); tx.oncomplete = res; tx.onerror = rej; }); } catch {} } async function loadSession() { try { const db = await getDb(); return await new Promise((res) => { const tx = db.transaction(STORE, 'readonly'); const req = tx.objectStore(STORE).get('current'); req.onsuccess = () => res(req.result ?? null); req.onerror = () => res(null); }); } catch { return null; } } async function clearSession() { try { const db = await getDb(); await new Promise((res) => { const tx = db.transaction(STORE, 'readwrite'); tx.objectStore(STORE).delete('current'); tx.oncomplete = res; tx.onerror = res; }); } catch {} } return { saveSession, loadSession, clearSession }; })(); const __m_encode_png_js = (() => { async function encodePng(canvas) { const out = new OffscreenCanvas(canvas.width, canvas.height); out.getContext('2d').drawImage(canvas, 0, 0); return out.convertToBlob({ type: 'image/png' }); } return { encodePng }; })(); const __m_encode_jpeg_js = (() => { async function encodeJpeg(canvas, quality = 0.9, exifRaw = null) { const out = new OffscreenCanvas(canvas.width, canvas.height); const ctx = out.getContext('2d'); ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, out.width, out.height); ctx.drawImage(canvas, 0, 0); const blob = await out.convertToBlob({ type: 'image/jpeg', quality: quality / 100 }); if (!exifRaw) return blob; // Inject raw APP1 EXIF segment right after the SOI marker const jpegBuf = await blob.arrayBuffer(); const app1 = new Uint8Array(exifRaw); const rest = new Uint8Array(jpegBuf, 2); // skip SOI const merged = new Uint8Array(2 + app1.byteLength + rest.byteLength); merged[0] = 0xFF; merged[1] = 0xD8; // SOI merged.set(app1, 2); merged.set(rest, 2 + app1.byteLength); return new Blob([merged], { type: 'image/jpeg' }); } return { encodeJpeg }; })(); const __m_encode_webp_js = (() => { async function encodeWebp(canvas, quality = 0.9, lossless = false) { const out = new OffscreenCanvas(canvas.width, canvas.height); out.getContext('2d').drawImage(canvas, 0, 0); return out.convertToBlob({ type: 'image/webp', quality: lossless ? undefined : quality / 100 }); } return { encodeWebp }; })(); const __m_app_js = (() => { const state = __m_core_state_js.state; const loadPrefs = __m_core_state_js.loadPrefs; const savePrefs = __m_core_state_js.savePrefs; const emit = __m_core_events_js.emit; const on = __m_core_events_js.on; const once = __m_core_events_js.once; const hasFSA = __m_core_fs_js.hasFSA; const hasDirPicker = __m_core_fs_js.hasDirPicker; const openFileFSA = __m_core_fs_js.openFileFSA; const openFileFallback = __m_core_fs_js.openFileFallback; const openFolderFSA = __m_core_fs_js.openFolderFSA; const openFromClipboard = __m_core_fs_js.openFromClipboard; const handleDrop = __m_core_fs_js.handleDrop; const navigateNext = __m_core_fs_js.navigateNext; const navigatePrev = __m_core_fs_js.navigatePrev; const navigateFirst = __m_core_fs_js.navigateFirst; const navigateLast = __m_core_fs_js.navigateLast; const navigateSkip = __m_core_fs_js.navigateSkip; const initViewer = __m_ui_viewer_js.initViewer; const fitToWindow = __m_ui_viewer_js.fitToWindow; const zoomStep = __m_ui_viewer_js.zoomStep; const zoomTo = __m_ui_viewer_js.zoomTo; const toggleFullscreen = __m_ui_viewer_js.toggleFullscreen; const initRulers = __m_ui_rulers_js.initRulers; const setRulersVisible = __m_ui_rulers_js.setRulersVisible; const initThumbstrip = __m_ui_thumbstrip_js.initThumbstrip; const toggleThumbstrip = __m_ui_thumbstrip_js.toggleThumbstrip; const initToolbar = __m_ui_toolbar_js.initToolbar; const initPanels = __m_ui_panels_js.initPanels; const showPanel = __m_ui_panels_js.showPanel; const initSelection = __m_ui_selection_js.initSelection; const clearSelection = __m_ui_selection_js.clearSelection; const selectAll = __m_ui_selection_js.selectAll; const nudgeSelection = __m_ui_selection_js.nudgeSelection; const initToasts = __m_ui_toast_js.initToasts; const initBatch = __m_ui_batch_js.initBatch; const initSlideshow = __m_ui_slideshow_js.initSlideshow; const initRightSidebar = __m_ui_rightsidebar_js.initRightSidebar; const initContextBar = __m_ui_contextbar_js.initContextBar; const initOpRunner = __m_core_oprunner_js.initOpRunner; const replayOps = __m_core_oprunner_js.replayOps; const initModals = __m_ui_modals_js.initModals; const initToolHandlers = __m_ui_toolhandlers_js.initToolHandlers; const initHelp = __m_ui_help_js.initHelp; const showHelp = __m_ui_help_js.showHelp; const initGallery = __m_ui_gallery_js.initGallery; const initJpegRotate = __m_ui_jpegrotate_js.initJpegRotate; const saveSession = __m_core_session_js.saveSession; const loadSession = __m_core_session_js.loadSession; const clearSession = __m_core_session_js.clearSession; const openFromHandle = __m_core_fs_js.openFromHandle; const openFromFile = __m_core_fs_js.openFromFile; const esc = __m_core_html_js.esc; const encodePng = __m_encode_png_js.encodePng; const encodeJpeg = __m_encode_jpeg_js.encodeJpeg; const encodeWebp = __m_encode_webp_js.encodeWebp; const saveDownload = __m_core_fs_js.saveDownload; const saveOverwrite = __m_core_fs_js.saveOverwrite; const saveSidecar = __m_core_fs_js.saveSidecar; function tstamp() { const d = new Date(), p = n => String(n).padStart(2, '0'); return String(d.getFullYear()).slice(-2) + p(d.getMonth()+1) + p(d.getDate()) + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds()); } // Feature probe — no UA sniffing function checkCompat() { const required = [ typeof OffscreenCanvas !== 'undefined', typeof createImageBitmap !== 'undefined', !!HTMLCanvasElement.prototype.toBlob, typeof ResizeObserver !== 'undefined', typeof IntersectionObserver !== 'undefined', ]; return required.every(Boolean); } function showCompatError() { document.getElementById('compat-warning')?.removeAttribute('hidden'); document.getElementById('shell')?.remove(); document.getElementById('empty-state')?.remove(); } // ── Boot ────────────────────────────────────────────────────────────────────── async function boot() { if (!checkCompat()) { showCompatError(); return; } // Inject icons sprite const iconSvg = document.getElementById('icon-sprite'); if (iconSvg) { // already inline } else { const resp = await fetch('./ui/icons.svg').catch(() => null); if (resp?.ok) { const text = await resp.text(); const div = document.createElement('div'); div.innerHTML = text; div.style.display = 'none'; document.body.insertBefore(div, document.body.firstChild); } } loadPrefs(); initToasts(); initViewer(); initRulers(); initThumbstrip(); initToolbar(); initPanels(); initSelection(); initBatch(); initSlideshow(); initRightSidebar(); initContextBar(); initOpRunner(); initModals(); initToolHandlers(); initHelp(); initGallery(); initJpegRotate(); setupEmptyState(); setupKeyboard(); setupDragDrop(); setupSave(); setupSession(); setupWorkspaceNudge(); on('image:loaded', () => { document.getElementById('empty-state')?.classList.add('hidden'); // Check MP warning const mp = (state.image.width * state.image.height) / 1_000_000; if (mp > 100) { emit('toast', { message: `Large image (${mp.toFixed(0)}MP). Consider resizing first.`, level: 'warn', duration: 6000 }); } else if (mp > 50) { emit('toast', { message: `Large image (${mp.toFixed(0)}MP). May be slow on this device.`, level: 'warn', duration: 4000 }); } }); on('selection:clear', () => clearSelection()); on('fit:window', () => fitToWindow()); on('tool:changed', ({ tool }) => { showPanel(tool === 'crop'); const canvas = document.getElementById('main-canvas'); if (canvas) { const cursors = { crop: 'crosshair', redact: 'crosshair', blur: 'crosshair', text: 'text', arrow: 'crosshair' }; canvas.style.cursor = tool ? (cursors[tool] ?? 'default') : ''; } }); // FSA fallback banner if (!hasFSA) { showFallbackBanner(); } updateVersion(); } function updateVersion() { const footer = document.getElementById('app-version'); if (footer) footer.textContent = 'Slate v1.0.0'; } // ── Empty state ─────────────────────────────────────────────────────────────── function setupEmptyState() { const el = document.getElementById('empty-state'); if (!el) return; el.innerHTML = `

Drop an image to get started

Open any image to crop, redact, annotate, and export — nothing leaves your browser.

Or drop an image anywhere on this page

${!hasFSA ? '

Firefox / Safari: folder write-back unavailable. Download only.

' : ''}
`; document.getElementById('dz-open-file')?.addEventListener('click', () => hasFSA ? openFileFSA() : openFileFallback()); document.getElementById('dz-open-folder')?.addEventListener('click', () => openFolderFSA()); document.getElementById('dz-paste')?.addEventListener('click', () => openFromClipboard()); } // ── Fallback banner ─────────────────────────────────────────────────────────── function showFallbackBanner() { const mainArea = document.getElementById('main-area'); if (!mainArea) return; const banner = document.createElement('div'); banner.className = 'banner banner-info'; banner.setAttribute('role', 'status'); banner.innerHTML = ` Running in compatibility mode — folder open and overwrite save are unavailable in this browser. `; banner.querySelector('.banner-close').addEventListener('click', () => banner.remove()); mainArea.insertBefore(banner, mainArea.firstChild); } // ── Keyboard shortcuts ──────────────────────────────────────────────────────── function setupKeyboard() { window.addEventListener('keydown', (e) => { const meta = e.metaKey || e.ctrlKey; const tag = document.activeElement?.tagName; const inInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; // Always-fire shortcuts if (e.key === '?' && !inInput) { showHelp(); return; } if (meta && e.key === 's') { e.preventDefault(); smartSave(); return; } if (meta && e.shiftKey && e.key === 's') { e.preventDefault(); emit('modal:export', null); return; } if (meta && e.key === 'o') { e.preventDefault(); hasFSA ? openFileFSA() : openFileFallback(); return; } if (meta && e.shiftKey && e.key === 'O') { e.preventDefault(); openFolderFSA(); return; } if (meta && e.key === 'z') { e.preventDefault(); emit('history:undo', null); return; } if (meta && e.shiftKey && e.key === 'Z') { e.preventDefault(); emit('history:redo', null); return; } if (inInput) return; // Paste if (meta && e.key === 'v') { e.preventDefault(); openFromClipboard(); return; } // Zoom if (meta && (e.key === '=' || e.key === '+')) { e.preventDefault(); zoomStep(1); return; } if (meta && e.key === '-') { e.preventDefault(); zoomStep(-1); return; } if (meta && e.key === '0') { e.preventDefault(); fitToWindow(); return; } if (meta && e.key === '1') { e.preventDefault(); zoomTo(1); return; } // View if (meta && !e.shiftKey && (e.key === 'r' || e.key === 'R') && state.image.original) { e.preventDefault(); const newVal = !state.view.rulers; setRulersVisible(newVal); document.getElementById('tb-rulers')?.setAttribute('aria-pressed', String(newVal)); savePrefs(); return; } if (!state.image.original) return; // T — thumbstrip toggle (unless text tool is focused) if (e.key === 't' || e.key === 'T') { if (state.activeTool !== 'text') { toggleThumbstrip(); const vis = !document.getElementById('thumbstrip-bar')?.classList.contains('hidden'); document.getElementById('tb-thumbstrip')?.setAttribute('aria-pressed', String(vis)); savePrefs(); return; } } // Shift+T — text tool if (e.shiftKey && (e.key === 't' || e.key === 'T')) { emit('tool:changed', { tool: 'text' }); return; } // Tool keys if (e.key === 'c' || e.key === 'C') { emit('tool:activate', { tool: 'crop' }); return; } if (e.key === 'r' || e.key === 'R') { emit('tool:activate', { tool: 'redact' }); return; } if (e.key === 'b' || e.key === 'B') { emit('tool:activate', { tool: 'blur' }); return; } if (e.key === 'a' || e.key === 'A') { emit('tool:activate', { tool: 'arrow' }); return; } // F — fullscreen if (e.key === 'f' || e.key === 'F') { toggleFullscreen(); return; } // Escape if (e.key === 'Escape') { if (state.view.fullscreen) { toggleFullscreen(); return; } if (state.selection.shape) { clearSelection(); return; } if (state.activeTool) { state.activeTool = null; emit('tool:changed', { tool: null }); return; } return; } // Enter — commit active selection tool if (e.key === 'Enter' && state.selection.shape) { const t = state.activeTool; if (t === 'crop') { emit('op:crop', null); return; } if (t === 'redact') { emit('op:redact', null); return; } if (t === 'blur') { emit('op:blur', null); return; } } // Navigation if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { if (state.activeTool || state.selection.shape) { // Nudge selection const d = e.key === 'ArrowLeft' ? -1 : 1; const big = e.shiftKey ? 10 : 1; nudgeSelection(d * big, 0, e.altKey); } else { if (e.key === 'ArrowLeft') navigatePrev(); else navigateNext(); } e.preventDefault(); return; } if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { if (state.selection.shape) { const d = e.key === 'ArrowUp' ? -1 : 1; const big = e.shiftKey ? 10 : 1; nudgeSelection(0, d * big, e.altKey); e.preventDefault(); } return; } if (e.key === 'Home') { navigateFirst(); return; } if (e.key === 'End') { navigateLast(); return; } if (e.key === 'PageUp') { navigateSkip(-10); return; } if (e.key === 'PageDown') { navigateSkip(10); return; } // Select all / deselect if (meta && e.key === 'a') { e.preventDefault(); selectAll(); return; } if (meta && e.key === 'd') { e.preventDefault(); clearSelection(); return; } }); } // ── Drag and drop ───────────────────────────────────────────────────────────── function setupDragDrop() { const app = document.getElementById('app'); const dropzone = () => document.getElementById('dropzone'); app.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; dropzone()?.classList.add('drag-over'); }); app.addEventListener('dragleave', (e) => { if (!app.contains(e.relatedTarget)) { dropzone()?.classList.remove('drag-over'); } }); app.addEventListener('drop', async (e) => { e.preventDefault(); dropzone()?.classList.remove('drag-over'); await handleDrop(e.dataTransfer); }); } // ── Save ────────────────────────────────────────────────────────────────────── function setupSave() { on('save:request', async (payload) => { if (!state.image.working) return; await doSave(payload?.dialog); }); } async function doSave(showDialog = false) { const { export: exp, image, session } = state; const entry = session.files[session.currentIndex]; const baseName = entry?.name?.replace(/\.[^.]+$/, '') ?? 'image'; // Encode const keepExif = !exp.stripExif && exp.format === 'jpeg'; const exifRaw = keepExif ? (image.exif?.raw ?? null) : null; let blob; try { if (exp.format === 'png') blob = await encodePng(image.working); else if (exp.format === 'jpeg') blob = await encodeJpeg(image.working, exp.quality, exifRaw); else blob = await encodeWebp(image.working, exp.quality); } catch (err) { emit('error', { message: `Save failed: ${err.message}` }); return; } const ext = exp.format === 'jpeg' ? 'jpg' : exp.format; const filename = `${baseName}-${tstamp()}.${ext}`; try { if (exp.saveMode === 'overwrite' && entry?.handle) { const confirmed = await confirmDialog('This replaces the original file. Undo history will be lost on next open.'); if (!confirmed) return; await saveOverwrite(blob); emit('toast', { message: 'File overwritten.', level: 'ok' }); } else if (exp.saveMode === 'sidecar' && session.folderHandle) { const saved = await saveSidecar(blob, ext); emit('toast', { message: `Saved as ${saved}`, level: 'ok' }); } else { await saveDownload(blob, filename); emit('toast', { message: `Downloaded ${filename}`, level: 'ok' }); } clearSession(); emit('save:complete', null); } catch (err) { emit('error', { message: `Save failed: ${err.message}` }); } } // ── Session save / restore ───────────────────────────────────────────────────── function setupSession() { on('history:changed', () => { const entry = state.session.files[state.session.currentIndex]; if (!entry) return; const ops = state.history._ops.slice(0, state.history.pointer + 1); if (entry.handle) { // FSA file: store handle — no pixel data needed, re-reads from disk on restore saveSession({ type: 'fsa', handle: entry.handle, name: entry.name, format: state.image.format, ops }); } else if (entry.file) { // Drag-drop / clipboard / fallback picker: store the File blob itself in IDB saveSession({ type: 'file', file: entry.file, name: entry.name, format: state.image.format, ops }); } }); on('image:loaded', () => { if (state.session.source !== 'restore') clearSession(); }); checkSession(); } async function checkSession() { const saved = await loadSession(); if (!saved?.ops?.length) return; if (saved.type === 'fsa' && !saved.handle) return; if (saved.type === 'file' && !saved.file) return; const mainArea = document.getElementById('main-area'); if (!mainArea) return; const count = saved.ops.length; const banner = document.createElement('div'); banner.id = 'session-banner'; banner.className = 'banner banner-info'; banner.innerHTML = ` Resume editing ${esc(saved.name)}? (${count} edit${count !== 1 ? 's' : ''}) `; mainArea.insertBefore(banner, mainArea.firstChild); document.getElementById('session-resume').addEventListener('click', async () => { banner.remove(); // Register the replay listener BEFORE opening. openFromHandle emits // 'image:loaded' during its await; if we subscribed after, we'd miss it // and the ops would never replay. if (saved.ops.length) { once('image:loaded', async () => { try { await replayOps(saved.ops); } catch (err) { emit('error', { message: `Could not restore edits: ${err.message}` }); } }); } try { if (saved.type === 'fsa') { await openFromHandle(saved.handle); } else { await openFromFile(saved.file); } } catch { /* error toast already shown */ } }); document.getElementById('session-discard').addEventListener('click', () => { banner.remove(); clearSession(); }); } async function confirmDialog(message) { return new Promise((resolve) => { let dialog = document.getElementById('dialog-confirm'); if (!dialog) { dialog = document.createElement('dialog'); dialog.id = 'dialog-confirm'; dialog.innerHTML = `

Confirm

`; document.body.appendChild(dialog); } dialog.querySelector('#confirm-msg').textContent = message; dialog.querySelector('#confirm-cancel').onclick = () => { dialog.close(); resolve(false); }; dialog.querySelector('#confirm-ok').onclick = () => { dialog.close(); resolve(true); }; dialog.showModal(); }); } // ── Smart save ──────────────────────────────────────────────────────────────── async function smartSave() { if (!state.image.working) return; const { session } = state; const entry = session.files[session.currentIndex]; if (session.folderHandle) { // Workspace folder set → sidecar save directly, no dialog state.export.saveMode = 'sidecar'; await doSave(false); } else if (entry?.handle) { // Opened via FSA file picker → overwrite state.export.saveMode = 'overwrite'; await doSave(false); } else { // No known target → open export modal so user can choose emit('modal:export', null); } } // ── Workspace nudge ─────────────────────────────────────────────────────────── function setupWorkspaceNudge() { const NON_FSA = new Set(['picker', 'dragdrop', 'paste']); on('image:loaded', () => { document.getElementById('workspace-nudge')?.remove(); if (!NON_FSA.has(state.session.source)) return; if (state.session.folderHandle) return; showWorkspaceNudge(); }); } function showWorkspaceNudge() { if (document.getElementById('workspace-nudge')) return; const nudge = document.createElement('div'); nudge.id = 'workspace-nudge'; nudge.className = 'workspace-nudge'; nudge.setAttribute('role', 'status'); if (hasDirPicker) { nudge.innerHTML = ` Pick a save folder to preserve your work across sessions. `; nudge.querySelector('#workspace-nudge-pick').addEventListener('click', async () => { try { const dirHandle = await window.showDirectoryPicker({ mode: 'readwrite' }); state.session.folderHandle = dirHandle; nudge.remove(); emit('toast', { message: 'Save folder set — Ctrl+S will save here.', level: 'ok' }); } catch (err) { if (err.name !== 'AbortError') emit('error', { message: `Could not set folder: ${err.message}` }); } }); } else { nudge.innerHTML = ` Use Chrome on desktop to enable folder save and session restore. `; } nudge.querySelector('.workspace-nudge-close').addEventListener('click', () => nudge.remove()); document.getElementById('app')?.appendChild(nudge); } // ── Start ───────────────────────────────────────────────────────────────────── if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot); } else { boot(); } return { }; })();