/** * Gallery Editor — canvas-based image editor with layers, brush, eraser, text, crop, inpaint mask. */ import uiModule from './ui.js'; import dragSortModule from './dragSort.js'; import spinnerModule from './spinner.js'; import { attachColorPicker } from './colorPicker.js'; import modalManager from './modalManager.js'; import { canvasCoords as _canvasCoords } from './editor/canvas-coords.js'; import { drawCheckerboard as _drawCheckerboard } from './editor/checkerboard.js'; import { dilateMask as _dilateMask, applyInpaintFeather as _applyInpaintFeather } from './editor/mask-utils.js'; import { lassoOffsetPoints as _lassoOffsetPointsImpl, getLassoPath as _getLassoPathImpl, buildLassoMask as _buildLassoMaskImpl, } from './editor/tools/lasso-mask.js'; import { floodFillMask as _floodFillMask } from './editor/tools/flood-fill.js'; import { drawHistogram as _drawHistogram } from './editor/fx/histogram.js'; import { applyAdjustment as _applyAdjToCanvas, renderLayerPixelAdjustments as _renderLayerPixelAdjustmentsImpl, renderLayerWithAdjLayers as _renderLayerWithAdjLayers, } from './editor/fx/pixel-pass.js'; import { layerFilterString as _layerFilterString, fxFilterToSlider as _fxFilterToSlider, } from './editor/fx/filter-string.js'; import { layerHasAdjustments as _layerHasAdjustments, layerNeedsPixelPass as _layerNeedsPixelPass, adjustmentsKey as _adjustmentsKey, defaultAdjParams as _defaultAdjParams, adjLayerLabel as _adjLayerLabel, ADJ_ICONS as _ADJ_ICONS, HISTORY_ICON as _HISTORY_ICON, isMaskCanvasEmpty as _isMaskCanvasEmpty, isLayerEmpty as _isLayerEmpty, relTime as _relTime, } from './editor/layer-helpers.js'; import { computeSnap as _computeSnapImpl, cursorForHandle as _cursorForHandle } from './editor/snap.js'; import { layerUnionAlpha as _layerUnionAlphaImpl, seamMask as _seamMaskImpl, layerBodyMask as _layerBodyMaskImpl, } from './editor/harmonize-masks.js'; import { gaussianBlur as _gaussianBlur, zoomBlur as _zoomBlur, motionBlur as _motionBlur, } from './editor/filters/blur.js'; import { edgeFeather as _edgeFeather } from './editor/filters/edge-feather.js'; import { buildThumbnail as _buildThumbnailImpl, buildMergedMaskCanvas as _buildMergedMaskCanvasImpl, } from './editor/composite-helpers.js'; import { buildToolbar as _buildToolbar } from './editor/build/toolbar.js'; import { buildTopbar as _buildTopbar } from './editor/build/topbar.js'; import { controlsHTML as _controlsHTML, layerPanelHTML as _layerPanelHTML, } from './editor/build/controls.js'; import { transformPopupHTML as _transformPopupHTML, attachSpinRepeat as _attachSpinRepeat, } from './editor/build/transform-popup.js'; import { shortcutsPopupHTML as _shortcutsPopupHTML, historyPanelHTML as _historyPanelHTMLImpl, canvasSizePromptHTML as _canvasSizePromptHTML, } from './editor/build/popups.js'; import { state } from './editor/state.js'; import { createMoveTool } from './editor/tools/move.js'; import { createCropTool } from './editor/tools/crop.js'; import { createLassoTool } from './editor/tools/lasso.js'; import { createWandTool } from './editor/tools/wand.js'; import { createCloneTool } from './editor/tools/clone.js'; import { createTransformDragTool } from './editor/tools/transform-drag.js'; import { createStrokeTool } from './editor/tools/stroke.js'; import { createLayerPanelRenderer } from './editor/layer-panel.js'; import { syncOverlay as _syncTransformOverlayImpl, drawHandles as _drawTransformHandlesImpl, getHandleAt as _getTransformHandleImpl, } from './editor/tools/transform-handles.js'; import { createCanvasTransforms } from './editor/canvas-transforms.js'; import { createApplyImageTool } from './editor/ai-tool-runner.js'; import { createStrokePipeline } from './editor/stroke-pipeline.js'; import { createAdjPopupSystem } from './editor/fx/adj-popup.js'; import { createHistoryPanel } from './editor/history-panel.js'; import { createTransformSession } from './editor/tools/transform-session.js'; import { wireCanvasEvents } from './editor/canvas-events.js'; import { buildRightPanel } from './editor/build/right-panel.js'; import { wireSliderUx } from './editor/slider-ux.js'; import { createShortcutsPopover } from './editor/shortcuts-popover.js'; import { wireKeyboardShortcuts } from './editor/keyboard-shortcuts.js'; import { wireClipboardAndDrop } from './editor/clipboard-and-drop.js'; import { wireAIModelSelectors } from './editor/ai-models.js'; import { wireInpaintButtons } from './editor/ai-inpaint.js'; import { wireAIToolsMisc } from './editor/ai-tools-misc.js'; import { wireRembgAndSharpen } from './editor/ai-rembg.js'; import { wireStrokeToolSliders } from './editor/stroke-tool-sliders.js'; import { wireImport } from './editor/wire-import.js'; import { wireMergeButtons } from './editor/wire-merge-buttons.js'; import { wireSelectionControls } from './editor/wire-selection-controls.js'; import { wireInpaintControls } from './editor/wire-inpaint-controls.js'; import { wireTopbar, closeOtherTopbarMenus as _closeOtherTopbarMenus } from './editor/wire-topbar.js'; import { wireTopbarOverflow } from './editor/wire-topbar-overflow.js'; import { wireTopbarMenus } from './editor/wire-topbar-menus.js'; const API_BASE = window.location.origin; // ── State ── // Transform-overlay canvas — sits over the main canvas with extra margin // so resize / rotation handles render OUTSIDE the image edges. Pointer // events disabled; the main canvas still handles all input. const _TRANSFORM_OVERLAY_MARGIN = 60; // image-space px of slack on each side // Thin wrappers around the transform-handles impls — the refactor // imported them under *Impl aliases but several call sites still use // the bare names. Without these, _startTransform threw a ReferenceError // before opening the popup, so Transform showed no handles / no popup. function _drawTransformHandles() { _drawTransformHandlesImpl(_TRANSFORM_OVERLAY_MARGIN); } function _getTransformHandle(x, y) { return _getTransformHandleImpl(x, y); } function _syncTransformOverlay() { _syncTransformOverlayImpl(_TRANSFORM_OVERLAY_MARGIN); } // Inpaint uses a much bigger default brush — when the user enters // the inpaint tool for the first time in this editor session we bump // the slider to this value (without touching other tools). const _INPAINT_DEFAULT_BRUSH = 100; function _galleryEditMounted() { return !!document.querySelector('#gallery-editor-container .gallery-editor'); } if (!window.__galleryEditEscHardGuardInstalled) { window.__galleryEditEscHardGuardInstalled = true; window.addEventListener('keydown', (e) => { if (e.key !== 'Escape') return; if (window.__galleryEditLive || _galleryEditMounted()) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); } }, true); } // Document-level click-away handlers for topbar dropdowns. Each // openEditor invocation adds 6 of these (save / edge / image / filter / // resize / more), and without removal they accumulated across reopens. // Tracked here and removed wholesale in closeEditor. function _registerDocClickAway(handler) { document.addEventListener('click', handler); state.editorDocClickHandlers.push(handler); } // Drawing state // Move tool state // Crop state // Persistent mode toggle for wand clicks. 'replace' = a new click // replaces the selection (default); 'add' = always union; 'subtract' = // always remove from the existing selection. Shift / Alt held during a // click still override this transiently. // Last seed click so the tolerance slider can re-run the wand live. // Stored in canvas coords (same units `_runMagicWand` accepts). // Lasso state // Transform state // Snapshot of the layer's pixels at the moment the transform started. // Lets the popup live-preview by re-applying from the original on every // input change instead of stacking destructive edits. // Current popup-driven values (re-applied on every change). // Inpaint mask (separate canvas, same dimensions as image) // Cached canvas reused each composite() to merge every visible mask // sub-layer into a single tinted overlay (avoids re-allocating on each // frame). Recreated lazily if dimensions change. // Softer default than the original full-saturation red — the user // found the previous tint distracting. Tweakable via the color picker // under the Paint/Erase row. // Persistent paint/erase toggle for the Inpaint brush. False = paint // (default), true = erase. Ctrl+Alt held during a stroke flips this // transiently for the duration of that one stroke. // Resolved per-stroke at pointerdown: state.inpaintEraseMode XOR (Ctrl+Alt). // Most-recent inpaint result layer id — the post-generation Feather // slider edits this layer's alpha edge live. // _dilateMask + _applyInpaintFeather live in editor/mask-utils.js // — see import at top of file. // Eraser settings // Edge softness, 0..100. 0 = hard pixel edge; higher values blur the // stroke's alpha so the eraser fades out at the brush perimeter. // Brush settings (same shape as eraser). // Clone Stamp brush modifiers — independent from the Brush tool's // settings so users can dial in cloning without losing their brush // preset (and vice-versa). // `state.cloneSourceX/Y` is the sample anchor set by Alt-click. While // painting, the source point moves in lockstep with the brush so the // sampled offset stays constant (Photoshop "aligned" mode). // First brush coord of the current stroke — used to compute the // running offset (`sample = source + (current - strokeStart)`). // Snapshot of the source layer's pixels at stroke-start so we can keep // sampling clean pixels even after the brush has painted over them // (avoids feedback / smearing). // Double-tap detection for the Clone tool on touch devices — sets the // sample anchor without a keyboard Alt modifier. // Undo/Redo const MAX_HISTORY = 20; /** Get the selected AI endpoint+model. Returns { endpoint, model }. * Dropdown values are encoded as "::" so users can pick * a specific model on a multi-model endpoint (e.g. dall-e-2 vs gpt-image-1). */ function _getSelectedAIEndpoint(type) { let raw = ''; if (type === 'inpaint') { raw = document.getElementById('ge-ai-inpaint')?.value || ''; } else if (type) { // Per-tool dropdowns (harmonize/upscale/style). Each lives in its // own section's panel and is marked with data-ge-tool-model="". const sel = document.querySelector(`select[data-ge-tool-model="${type}"]`); raw = sel?.value || ''; } if (!raw) raw = document.getElementById('ge-ai-model')?.value || ''; if (!raw) return { endpoint: '', model: '' }; const idx = raw.indexOf('::'); if (idx < 0) return { endpoint: raw, model: '' }; return { endpoint: raw.slice(0, idx), model: raw.slice(idx + 2) }; } /** Shared helper: flatten layers → POST to API → add result as new layer. */ // Maps a layer-name (the past-participle returned from each AI tool — // "BG Removed", "Sharpened", etc.) into a present-progressive label for // the busy button state ("Removing…", "Sharpening…"). Falls back to a // neutral "Processing…" when the layer name doesn't match a known verb. const _BUSY_LABELS = { 'bg removed': 'Removing…', 'sharpened': 'Sharpening…', 'enhanced': 'Enhancing…', 'harmonized': 'Harmonizing…', 'upscaled': 'Upscaling…', 'styled': 'Styling…', }; function _deriveBusyLabel(layerName) { if (!layerName) return 'Processing…'; return _BUSY_LABELS[String(layerName).toLowerCase()] || 'Processing…'; } // AI-tool runner — sharpen / harmonize / upscale / style / bg-remove // all flatten the doc, POST a PNG to a server endpoint, and drop the // result back as a new layer. Full implementation in editor/ai-tool- // runner.js; instantiated lazily (so it can reference function decls // that haven't hoisted at module load time? — actually all named // function decls hoist, so we instantiate at module top). const _applyImageTool = createApplyImageTool({ flatten: () => flatten(), saveState: _saveState, createLayer, composite, renderLayerPanel: () => _renderLayerPanel(), deriveBusyLabel: (name) => _deriveBusyLabel(name), getSelectedAIEndpoint: (type) => _getSelectedAIEndpoint(type), openCookbookForDependency: (pkg) => _openCookbookForDependency(pkg), openCookbookForImg2img: () => _openCookbookForImg2img(), spinnerModule, uiModule, }); // Layer offsets for move tool // ── Layer class ── function createLayer(name, width, height) { const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const layer = { id: 'layer-' + (state.nextLayerId++), name, canvas, ctx: canvas.getContext('2d'), visible: true, opacity: 1, locked: false, // Mask sub-layers — same shape as adjLayers, parallel concept. // Each entry: {id, name, canvas, visible}. The "active" mask is the // one that paint / lasso / inpaint operations target; rendered as a // red overlay in composite(). masks: [], activeMaskId: null, // Non-destructive adjustments. B/C/S/H are applied at composite() // via ctx.filter (fast, CSS). Levels + Color Balance need per-pixel // math and are baked into a cached canvas (layer._adjCache) that // gets re-rendered only when those values change. adjustments: { brightness: 1, // 0..2 (1 = neutral) contrast: 1, // 0..2 (1 = neutral) saturation: 1, // 0..2 (0 = grayscale, 1 = neutral) hue: 0, // degrees, -180..180 // Levels — Photoshop-style three-stop adjust applied per channel. // input 0..255, gamma 0.1..9.9. Default is identity. levels: { inBlack: 0, inWhite: 255, gamma: 1.0, outBlack: 0, outWhite: 255 }, // Color Balance — additive per-channel shifts weighted by tone. // Each value is -100..+100 mapping to roughly ±60 in 0..255 space. colorBalance: { shadows: { r: 0, g: 0, b: 0 }, midtones: { r: 0, g: 0, b: 0 }, highlights: { r: 0, g: 0, b: 0 }, }, }, }; state.layerOffsets.set(layer.id, { x: 0, y: 0 }); return layer; } // _layerFilterString + _fxFilterToSlider live in editor/fx/filter-string.js // — see import at top. // _layerHasAdjustments lives in editor/layer-helpers.js — see import at top. // ── Mask sub-layers ── // Resolves to the parent layer that should own masks for the current // edit. The "active" parent is whichever layer the user has selected, // excluding mask-sublayer entries themselves. function _activeParentLayer() { return state.layers.find(l => l.id === state.activeLayerId) || state.layers[state.layers.length - 1] || null; } // Find the active mask sub-layer (the one paint/lasso/inpaint ops // target). Returns null if the parent has no masks OR if no mask is // currently activated (i.e. the user explicitly selected the parent // pixels as the paint target). Earlier code fell back to "last mask // in the list" which meant clicking the parent row couldn't escape // mask-paint mode — that surprised the user, so the fallback is // gone. function _getActiveMaskLayer() { const parent = _activeParentLayer(); if (!parent || !parent.masks || !parent.masks.length) return null; if (!parent.activeMaskId) return null; return parent.masks.find(m => m.id === parent.activeMaskId) || null; } // Get-or-create a mask sub-layer on the active parent. Used by tools // that need a mask to write into (Brush on mask, Inpaint stroke, // lasso→mask, wand→mask). function _ensureActiveMaskLayer() { const parent = _activeParentLayer(); if (!parent) return null; if (!parent.masks) parent.masks = []; let mask = _getActiveMaskLayer(); if (mask) return mask; const c = document.createElement('canvas'); c.width = state.imgWidth; c.height = state.imgHeight; mask = { id: 'mask-' + (state.nextLayerId++), name: 'Mask ' + (parent.masks.length + 1), canvas: c, ctx: c.getContext('2d'), visible: true, }; parent.masks.push(mask); parent.activeMaskId = mask.id; return mask; } // True if any visible layer in the doc carries a mask sub-layer; drives // the "red overlay" pass in composite(). function _hasAnyMasks() { for (const l of state.layers) { if (l.masks && l.masks.length) return true; } return false; } // Union of every VISIBLE mask sub-layer across the whole document, // returned as a fresh image-sized canvas with white = masked area. // Used by inpaint Generate/Remove so the AI sees the combined region // instead of just the active mask. Returns null when no masks exist // (caller should fall back to the active mask plumbing in that case). function _buildMergedMaskCanvas() { return _buildMergedMaskCanvasImpl(state.layers, state.imgWidth, state.imgHeight); } // True if the layer needs the (slower) per-pixel LUT pass — i.e. Levels // or Color Balance are non-identity. Brightness/Contrast/Saturation/Hue // alone can stay on the fast CSS-filter path. // _layerNeedsPixelPass + _adjustmentsKey live in editor/layer-helpers.js. // Per-pixel Levels + Color Balance. Renders the layer.canvas into a // cached canvas (layer._adjCache) with the LUT-style transforms applied. // CSS-filter adjustments (B/C/S/H) are still applied at composite() on // top of this cache. // Pixel-pass adjustment math lives in editor/fx/pixel-pass.js. This // wrapper forwards the layer + a fresh adjustments-cache key so // existing callers stay unchanged. function _renderLayerPixelAdjustments(layer) { return _renderLayerPixelAdjustmentsImpl(layer, _adjustmentsKey(layer.adjustments)); } // Layer FX popup — floating window bound to a specific layer. Sliders // edit that layer's adjustments and live-update composite(). The popup // stays open across clicks elsewhere unless dismissed via its × button. // FX / adjustment-popup machinery — full implementation in // editor/fx/adj-popup.js. Wrappers preserve the legacy names that // every layer-row FX button, panel-row click, and undo/redo path // already references. const _adjPopupSystem = createAdjPopupSystem({ composite, saveState: _saveState, renderLayerPanel: () => _renderLayerPanel(), }); const _closeFxPopup = _adjPopupSystem.closeFxPopup; const _ensureAdjustments = _adjPopupSystem.ensureAdjustments; const _ensureFxDock = _adjPopupSystem.ensureFxDock; const _closeFxMenu = _adjPopupSystem.closeFxMenu; const _openFxPopup = _adjPopupSystem.openFxPopup; const _openAdjPopup = _adjPopupSystem.openAdjPopup; const _editAdjLayer = _adjPopupSystem.editAdjLayer; const _closeAdjPopup = _adjPopupSystem.closeAdjPopup; const _minimiseAdjPopup = _adjPopupSystem.minimiseAdjPopup; const _syncFxPanelToActiveLayerIfPresent = _adjPopupSystem.syncFxPanelToActiveLayerIfPresent; function activeLayer() { return state.layers.find(l => l.id === state.activeLayerId) || null; } // Flood-fill enclosed regions of the inpaint mask. After the user // draws a closed shape (circle, lasso, whatever), the interior is // alpha=0 surrounded by white mask. We mark all alpha=0 pixels // reachable from the canvas edges as "outside"; anything still alpha=0 // after that pass is enclosed and gets filled with white. function _fillEnclosedMaskRegions() { if (!state.maskCanvas || !state.maskCtx) return; const w = state.maskCanvas.width, h = state.maskCanvas.height; if (w * h > 4096 * 4096) return; // safety cap const img = state.maskCtx.getImageData(0, 0, w, h); const d = img.data; // visited bitmap — 0 = unvisited, 1 = reached from edge (outside), // 2 = mask (alpha>0). After BFS, alpha=0 pixels with visited[i]=0 // are enclosed. const visited = new Uint8Array(w * h); const stack = []; // Pre-mark all mask pixels as visited=2 so we don't cross them. for (let i = 0, j = 0; i < d.length; i += 4, j++) { if (d[i + 3] > 0) visited[j] = 2; } // Seed flood from every edge pixel that's empty. const seed = (x, y) => { const k = y * w + x; if (visited[k] === 0) { visited[k] = 1; stack.push(k); } }; for (let x = 0; x < w; x++) { seed(x, 0); seed(x, h - 1); } for (let y = 0; y < h; y++) { seed(0, y); seed(w - 1, y); } // BFS — 4-connected. while (stack.length) { const k = stack.pop(); const x = k % w, y = (k - x) / w; if (x > 0) { const n = k - 1; if (visited[n] === 0) { visited[n] = 1; stack.push(n); } } if (x < w - 1) { const n = k + 1; if (visited[n] === 0) { visited[n] = 1; stack.push(n); } } if (y > 0) { const n = k - w; if (visited[n] === 0) { visited[n] = 1; stack.push(n); } } if (y < h - 1) { const n = k + w; if (visited[n] === 0) { visited[n] = 1; stack.push(n); } } } // Anything still visited=0 → enclosed empty region. Fill white. let filled = false; for (let j = 0, i = 0; j < visited.length; j++, i += 4) { if (visited[j] === 0) { d[i] = 255; d[i + 1] = 255; d[i + 2] = 255; d[i + 3] = 255; filled = true; } } if (filled) state.maskCtx.putImageData(img, 0, 0); } // True if a layer has no opaque pixels — used to tag the row in the // layer panel as "(empty)" so the user can tell at a glance which // layers carry actual content. // Lightweight loading overlay anchored to the canvas area. Used for // blocking operations (rotation on big images, etc.) so the user gets // feedback while the main thread is busy. The actual heavy call should // be deferred with rAF so the overlay paints before the block. function _showCanvasLoading(message) { if (!state.container) return; let overlay = state.container.querySelector('.ge-canvas-loading'); if (!overlay) { overlay = document.createElement('div'); overlay.className = 'ge-canvas-loading ge-frosted'; overlay.innerHTML = `
`; state.container.appendChild(overlay); } overlay.querySelector('.ge-canvas-loading-msg').textContent = message || 'Working…'; overlay.style.display = ''; } function _hideCanvasLoading() { const overlay = state.container && state.container.querySelector('.ge-canvas-loading'); if (overlay) overlay.style.display = 'none'; } // Cheap "is this mask canvas blank?" check. Used to suffix "(empty)" on // mask sub-layer rows in the panel so the user can tell at a glance // which masks have actually been painted on. // _isMaskCanvasEmpty lives in editor/layer-helpers.js. // Document-wide rotate / flip — implementations in // editor/canvas-transforms.js. Wrappers preserve the legacy names that // the topbar Image menu and shortcuts already wire to. const _canvasTransforms = createCanvasTransforms({ saveState: _saveState, composite, fitZoom: () => _fitZoom(), showCanvasLoading: (label) => _showCanvasLoading(label), hideCanvasLoading: () => _hideCanvasLoading(), }); function _rotateAllLayers(deg) { return _canvasTransforms.rotateAll(deg); } function _flipAllLayers(axis) { return _canvasTransforms.flipAll(axis); } // _isLayerEmpty lives in editor/layer-helpers.js. // ── Composite ── function composite() { if (!state.mainCtx) return; state.mainCtx.clearRect(0, 0, state.mainCanvas.width, state.mainCanvas.height); // Checkerboard background _drawCheckerboard(state.mainCtx, state.mainCanvas.width, state.mainCanvas.height); for (const layer of state.layers) { if (!layer.visible) continue; state.mainCtx.globalAlpha = layer.opacity; const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 }; // Source = layer.canvas walked through all its adjustment // sub-layers (plus any staged-in-progress edit). Falls back to // raw layer.canvas when no adjustments are present. const source = _renderLayerWithAdjLayers(layer); state.mainCtx.drawImage(source, off.x, off.y); state.mainCtx.globalAlpha = 1; } // Show mask overlay as red tint whenever a mask sub-layer is present // on the active parent (was previously gated on inpaint-tool only; now // masks are first-class layer entities, so users see them in any tool). // Mask canvas has white pixels — we tint them red for visibility. if (state.maskVisible) { // Build a SINGLE merged mask canvas from every visible mask // sub-layer (union of alpha — `lighter` keeps max alpha per pixel, // so overlapping strokes don't visually stack). Then tint it red // once and composite at the configured opacity. Result: the user // sees a flat, consistent translucent red over the masked area // regardless of how many strokes / masks contributed to it. Mask // visibility is INDEPENDENT of parent visibility — hiding the // parent layer doesn't hide its masks. let _haveAny = false; if (!state.compositeMaskUnion) state.compositeMaskUnion = document.createElement('canvas'); const union = state.compositeMaskUnion; union.width = state.mainCanvas.width; union.height = state.mainCanvas.height; const uctx = union.getContext('2d'); uctx.clearRect(0, 0, union.width, union.height); uctx.globalCompositeOperation = 'lighter'; for (const ly of state.layers) { if (!ly.masks || !ly.masks.length) continue; for (const mk of ly.masks) { if (!mk.visible) continue; if (!mk.canvas || !mk.canvas.width || !mk.canvas.height) continue; uctx.drawImage(mk.canvas, 0, 0); _haveAny = true; } } uctx.globalCompositeOperation = 'source-over'; if (_haveAny) { // Tint the merged mask in-place: anywhere alpha exists becomes // red; everywhere else stays transparent. uctx.globalCompositeOperation = 'source-in'; uctx.fillStyle = state.maskTintColor || 'rgba(255, 50, 50, 1)'; uctx.fillRect(0, 0, union.width, union.height); uctx.globalCompositeOperation = 'source-over'; state.mainCtx.globalAlpha = state.maskTintOpacity; state.mainCtx.drawImage(union, 0, 0); state.mainCtx.globalAlpha = 1; } } // Draw transform handles if active if (state.transformActive) _drawTransformHandles(); else if (state.transformOverlay) state.transformOverlay.style.display = 'none'; // Persist the lasso selection overlay across composite redraws — without // this, leaving/re-entering the canvas (which triggers _endDraw → // composite) would visually wipe a completed selection even though // state.lassoPoints is still populated. if (!state.lassoActive && state.lassoPoints.length >= 3) _drawLassoOverlay(); // Same idea for the crop overlay — once the user releases the drag, // the crop rect should remain visible until they Apply or cancel. // Hovering over the floating Apply button counts as a mouseleave on // the canvas, which used to wipe the overlay. if (state.cropRect && !state.cropping) _drawCropOverlay(); // Snap guides — drawn while the user is moving a layer with Ctrl held. if (state.activeSnapGuides && state.activeSnapGuides.length) _drawSnapGuides(); // Magic-wand selection overlay (translucent red tint of the mask). if (state.wandMask && state.wandLayerId && state.wandMaskVisible) _drawWandOverlay(); // Keep the per-tool clear-X badges in sync. Cheap: two classList // toggles. Composite runs on every visible state change, so this // catches every lasso/wand mutation site without each one having to // remember to call the sync helper. _syncToolClearIndicators(); } function _drawSnapGuides() { const ctx = state.mainCtx; ctx.save(); ctx.strokeStyle = 'rgba(224, 108, 117, 0.85)'; ctx.lineWidth = 1 / state.zoom; ctx.setLineDash([4 / state.zoom, 3 / state.zoom]); for (const g of state.activeSnapGuides) { ctx.beginPath(); if (g.vertical) { ctx.moveTo(g.x, 0); ctx.lineTo(g.x, state.imgHeight); } else { ctx.moveTo(0, g.y); ctx.lineTo(state.imgWidth, g.y); } ctx.stroke(); } ctx.restore(); } // Draw the dim-everything-else + cleared-crop-window overlay for the // current `state.cropRect`. Shared by _continueCrop (live preview during drag) // and composite() (re-draw after canvas redraws while the crop is held). function _drawCropOverlay() { if (!state.cropRect || !state.mainCtx || !state.mainCanvas) return; const { x, y, w, h } = state.cropRect; state.mainCtx.fillStyle = 'rgba(0,0,0,0.4)'; state.mainCtx.fillRect(0, 0, state.mainCanvas.width, state.mainCanvas.height); state.mainCtx.clearRect(x, y, w, h); state.mainCtx.save(); state.mainCtx.beginPath(); state.mainCtx.rect(x, y, w, h); state.mainCtx.clip(); _drawCheckerboard(state.mainCtx, state.mainCanvas.width, state.mainCanvas.height); for (const layer of state.layers) { if (!layer.visible) continue; state.mainCtx.globalAlpha = layer.opacity; const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 }; state.mainCtx.drawImage(layer.canvas, off.x, off.y); } state.mainCtx.globalAlpha = 1; state.mainCtx.restore(); state.mainCtx.strokeStyle = '#fff'; state.mainCtx.lineWidth = 1; state.mainCtx.setLineDash([4, 4]); state.mainCtx.strokeRect(x, y, w, h); state.mainCtx.setLineDash([]); } // _drawCheckerboard lives in editor/checkerboard.js — see import at top. // ── History ── function _snapshotState() { let wand = null; if (state.wandMask) { try { const wctx = state.wandMask.getContext('2d'); wand = { layerId: state.wandLayerId, w: state.wandMask.width, h: state.wandMask.height, imageData: wctx.getImageData(0, 0, state.wandMask.width, state.wandMask.height), seed: state.wandLastSeed ? { ...state.wandLastSeed } : null, }; } catch {} } return { imgWidth: state.imgWidth, imgHeight: state.imgHeight, wand, layers: state.layers.map(l => { // getImageData throws on a 0-sized canvas — guard so a single // broken layer/mask can't take down the whole snapshot (which // would silently break undo/redo for brush strokes etc.). let imageData = null; try { if (l.canvas.width > 0 && l.canvas.height > 0) { imageData = l.ctx.getImageData(0, 0, l.canvas.width, l.canvas.height); } } catch (_) { /* keep imageData=null, restore will skip */ } return { id: l.id, name: l.name, visible: l.visible, opacity: l.opacity, locked: l.locked, canvasW: l.canvas.width, canvasH: l.canvas.height, imageData, offset: { ...(state.layerOffsets.get(l.id) || { x: 0, y: 0 }) }, // Deep-clone defensively — a non-serializable / circular value here // would throw out of the whole snapshot (and historically aborted // every mutating op). Fall back to [] rather than blow up. adjLayers: (() => { try { return l.adjLayers ? JSON.parse(JSON.stringify(l.adjLayers)) : []; } catch (e) { console.error('[gallery] adjLayers not serializable, dropping from snapshot:', e); return []; } })(), masks: (l.masks || []).map(m => { let mImageData = null; try { if (m.canvas.width > 0 && m.canvas.height > 0) { mImageData = m.ctx.getImageData(0, 0, m.canvas.width, m.canvas.height); } } catch (_) {} return { id: m.id, name: m.name, visible: m.visible !== false, canvasW: m.canvas.width, canvasH: m.canvas.height, imageData: mImageData, }; }), activeMaskId: l.activeMaskId || null, isBase: !!l.isBase, }; }), }; } function _saveState(label) { // saveState() runs FIRST in every mutating op (import, paste, copy, // merge, mask, delete, brush, …). If anything here throws, the whole // operation aborts before its real work runs — which silently breaks // import ("no layer created") and every layer button. So each step is // isolated: a history-snapshot failure must degrade (lose one undo // step) rather than kill the user's action. try { const snap = _snapshotState(); snap._label = label || 'Edit'; snap._ts = Date.now(); state.undoStack.push(snap); if (state.undoStack.length > MAX_HISTORY) state.undoStack.shift(); state.redoStack = []; } catch (e) { console.error('[gallery] saveState snapshot failed (continuing without this undo step):', e); } try { _invalidateWandCache(); } catch (e) { console.error('[gallery] invalidateWandCache:', e); } try { _schedulePersist(); } catch (e) { console.error('[gallery] schedulePersist:', e); } try { _refreshHistoryPanelIfOpen(); } catch (e) { console.error('[gallery] refreshHistoryPanel:', e); } } // ────────── Persistent edit drafts (server-backed) ────────── // The previous implementation keyed drafts by gallery image-id in // localStorage; that meant blank-canvas sessions silently lost work and // drafts couldn't roam between devices. We now hold a server-side draft // row identified by a uuid (`state.draftId`) and PUT updates to it on a // debounced timer. `state.imageId` (gallery id) is still tracked separately // for "save back to the original photo" behaviour. const PERSIST_DEBOUNCE_MS = 800; const THUMB_MAX = 160; function _schedulePersist() { if (!state.editorOpen || !state.layers.length) return; if (state.persistTimer) clearTimeout(state.persistTimer); state.persistTimer = setTimeout(() => { state.persistTimer = null; _persistDraft(); }, PERSIST_DEBOUNCE_MS); } function _buildDraftPayload() { return { v: 2, imageId: state.imageId, imgWidth: state.imgWidth, imgHeight: state.imgHeight, activeLayerId: state.activeLayerId, nextLayerId: state.nextLayerId, layers: state.layers.map(l => ({ id: l.id, name: l.name, visible: l.visible, opacity: l.opacity, locked: l.locked, isBase: !!l.isBase, canvasW: l.canvas.width, canvasH: l.canvas.height, offset: { ...(state.layerOffsets.get(l.id) || { x: 0, y: 0 }) }, dataUrl: l.canvas.toDataURL('image/png'), })), }; } function _buildThumbnail() { return _buildThumbnailImpl(state.layers, state.imgWidth, state.imgHeight, state.layerOffsets, THUMB_MAX, 0.6); } async function _persistDraft() { if (!state.editorOpen || !state.layers.length) return; // Coalesce concurrent saves — if one's already in-flight, mark dirty // and let the running call kick off another when it returns. if (state.persistInFlight) { state.persistDirty = true; return; } const payload = _buildDraftPayload(); const thumbnail = _buildThumbnail(); const body = { name: state.draftName || 'Untitled', source_image_id: state.imageId || null, width: state.imgWidth, height: state.imgHeight, payload, thumbnail, }; const doRequest = async () => { if (state.draftId) { const res = await fetch(`/api/editor-drafts/${encodeURIComponent(state.draftId)}`, { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); // 404 means our row was deleted while editing — fall through to // create a fresh one so the user doesn't lose work. if (res.status === 404) { state.draftId = null; return doRequest(); } if (!res.ok) throw new Error(`PUT failed: ${res.status}`); return res.json(); } else { const res = await fetch('/api/editor-drafts', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!res.ok) throw new Error(`POST failed: ${res.status}`); const out = await res.json(); if (out && out.id) state.draftId = out.id; return out; } }; state.persistInFlight = doRequest() .catch((e) => { console.warn('[ge] draft save failed', e); }) .then(() => { state.persistInFlight = null; if (state.persistDirty) { state.persistDirty = false; _schedulePersist(); } }); return state.persistInFlight; } async function _loadDraftById(draftId) { if (!draftId) return null; try { const res = await fetch(`/api/editor-drafts/${encodeURIComponent(draftId)}`, { credentials: 'same-origin', }); if (!res.ok) return null; const out = await res.json(); if (!out || !out.payload || !Array.isArray(out.payload.layers)) return null; return out; } catch (_) { return null; } } async function _findDraftForImage(imageId) { if (!imageId) return null; try { const res = await fetch('/api/editor-drafts', { credentials: 'same-origin' }); if (!res.ok) return null; const out = await res.json(); const match = (out.drafts || []).find(d => d.source_image_id === imageId); if (!match) return null; return _loadDraftById(match.id); } catch (_) { return null; } } async function _clearDraftServer(draftId) { if (!draftId) return; try { await fetch(`/api/editor-drafts/${encodeURIComponent(draftId)}`, { method: 'DELETE', credentials: 'same-origin', }); } catch (_) { /* best-effort */ } } // Hydrate state.layers from a previously-persisted draft. Accepts either the // raw payload (v1 localStorage shape) or a server response with // {payload: {...}}. Returns a promise that resolves once every layer's // dataURL has decoded into its canvas. function _restoreDraft(draft) { return new Promise((resolve) => { // Server response: {id, name, payload:{...}, ...}. Unwrap. const data = draft.payload && draft.payload.layers ? draft.payload : draft; state.imgWidth = data.imgWidth; state.imgHeight = data.imgHeight; _initCanvasFromDims(data.imgWidth, data.imgHeight); state.layers = []; state.layerOffsets.clear(); let pending = data.layers.length; if (pending === 0) { resolve(); return; } data.layers.forEach((s, idx) => { const layer = createLayer(s.name || 'Layer', s.canvasW || state.imgWidth, s.canvasH || state.imgHeight); layer.id = s.id; layer.visible = s.visible !== false; layer.opacity = typeof s.opacity === 'number' ? s.opacity : 1; layer.locked = !!s.locked; if (s.isBase) layer.isBase = true; state.layers[idx] = layer; state.layerOffsets.set(layer.id, { ...(s.offset || { x: 0, y: 0 }) }); const img = new Image(); img.onload = () => { if (!state.editorOpen) { resolve(); return; } layer.ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height); layer.ctx.drawImage(img, 0, 0); if (--pending === 0) resolve(); }; img.onerror = () => { if (--pending === 0) resolve(); }; img.src = s.dataUrl; }); state.nextLayerId = data.nextLayerId || (state.layers.reduce((m, l) => Math.max(m, l.id || 0), 0) + 1); state.activeLayerId = data.activeLayerId || (state.layers[state.layers.length - 1]?.id ?? null); }); } // Used both by the fresh openEditor path and by _restoreDraft. The full // _initCanvas in openEditor is closure-scoped, so factored out here. function _initCanvasFromDims(w, h) { state.imgWidth = w; state.imgHeight = h; if (state.mainCanvas) { state.mainCanvas.width = w; state.mainCanvas.height = h; } state.maskCanvas = document.createElement('canvas'); state.maskCanvas.width = w; state.maskCanvas.height = h; state.maskCtx = state.maskCanvas.getContext('2d'); } function _restoreState(snap) { // Restore canvas dimensions first so layer imageData fits cleanly. This // is what makes Ctrl+Z work for crops (which change the main canvas // size) in addition to paint strokes. const dimsChanged = snap.imgWidth && snap.imgHeight && (snap.imgWidth !== state.imgWidth || snap.imgHeight !== state.imgHeight); if (snap.imgWidth && snap.imgHeight) { state.imgWidth = snap.imgWidth; state.imgHeight = snap.imgHeight; if (state.mainCanvas) { state.mainCanvas.width = snap.imgWidth; state.mainCanvas.height = snap.imgHeight; } if (state.maskCanvas) { state.maskCanvas.width = snap.imgWidth; state.maskCanvas.height = snap.imgHeight; } } const layerStates = snap.layers || snap; // Rebuild the state.layers array from the snapshot order. This lets Ctrl+Z // restore deleted layers (previously the loop only updated existing // ones and silently dropped any layer the snapshot still knew about). // Layers absent from the snapshot are dropped — that's the desired // behavior for undoing an "+Add" or a paste. const _existingById = new Map(state.layers.map(l => [l.id, l])); const _rebuilt = []; for (const s of layerStates) { let layer = _existingById.get(s.id); if (!layer) { // Layer was deleted (or merged away). Recreate it from the // snapshot's ImageData so Ctrl+Z brings it back. const c = document.createElement('canvas'); c.width = s.canvasW || state.imgWidth; c.height = s.canvasH || state.imgHeight; layer = { id: s.id, name: s.name, canvas: c, ctx: c.getContext('2d'), visible: true, opacity: 1, locked: false }; } else { _existingById.delete(s.id); } layer.name = s.name; layer.visible = s.visible; layer.opacity = s.opacity; layer.locked = s.locked; if (s.canvasW && s.canvasH) { layer.canvas.width = s.canvasW; layer.canvas.height = s.canvasH; } try { if (s.imageData) layer.ctx.putImageData(s.imageData, 0, 0); } catch (_) {} state.layerOffsets.set(layer.id, { ...s.offset }); // Restore adjustment sub-layers + invalidate the composite cache // so the live render reflects the rolled-back FX state. layer.adjLayers = s.adjLayers ? JSON.parse(JSON.stringify(s.adjLayers)) : []; if (s.isBase !== undefined) layer.isBase = s.isBase; // Restore mask sub-layers — rebuild each mask's canvas from the // snapshot's imageData. We don't reuse old mask canvases (snapshot // dims might differ after a transform) so a fresh canvas is safer. layer.masks = (s.masks || []).map(ms => { const mc = document.createElement('canvas'); mc.width = ms.canvasW || state.imgWidth; mc.height = ms.canvasH || state.imgHeight; const mctx = mc.getContext('2d'); try { if (ms.imageData) mctx.putImageData(ms.imageData, 0, 0); } catch {} return { id: ms.id, name: ms.name, canvas: mc, ctx: mctx, visible: ms.visible !== false }; }); layer.activeMaskId = s.activeMaskId || (layer.masks[0]?.id ?? null); layer._adjFinal = null; layer._adjFinalKey = null; layer._stagedAdj = null; layer._editingAdjId = null; _rebuilt.push(layer); } // Drop any layer that's no longer in the snapshot. for (const lost of _existingById.values()) state.layerOffsets.delete(lost.id); state.layers = _rebuilt; if (!state.layers.find(l => l.id === state.activeLayerId) && state.layers.length) { state.activeLayerId = state.layers[state.layers.length - 1].id; } // Repoint the global mask plumbing at the active parent's active // mask sub-layer (if any) — undo can swap the actual canvas object. { const m = _getActiveMaskLayer(); if (m) { state.maskCanvas = m.canvas; state.maskCtx = m.ctx; } } // Restore wand selection (or clear it if the snapshot had none). if (snap.wand && snap.wand.imageData) { const mc = document.createElement('canvas'); mc.width = snap.wand.w; mc.height = snap.wand.h; mc.getContext('2d').putImageData(snap.wand.imageData, 0, 0); state.wandMask = mc; state.wandLayerId = snap.wand.layerId; state.wandLastSeed = snap.wand.seed ? { ...snap.wand.seed } : null; } else { state.wandMask = null; state.wandLayerId = null; state.wandLastSeed = null; } composite(); _renderLayerPanel(); _syncToolClearIndicators(); // Refit the viewport when canvas size changed (crop undo/redo) so the // user sees the full restored image, not the zoomed-in upper-left // corner left over from the previous fit. if (dimsChanged) _fitZoom(); // Update the topbar canvas-size badge directly (the helper is scoped // inside _buildEditor, so we touch the DOM here). const sizeLabel = document.getElementById('ge-canvas-size'); if (sizeLabel) sizeLabel.textContent = `${state.imgWidth}×${state.imgHeight}`; } function undo() { if (state.undoStack.length === 0) return; const cur = _snapshotState(); cur._label = 'Current'; cur._ts = Date.now(); state.redoStack.push(cur); _restoreState(state.undoStack.pop()); _refreshHistoryPanelIfOpen(); } function redo() { if (state.redoStack.length === 0) return; const cur = _snapshotState(); cur._label = 'Current'; cur._ts = Date.now(); state.undoStack.push(cur); _restoreState(state.redoStack.pop()); _refreshHistoryPanelIfOpen(); } // Jump to any state in the labeled history. Negative offsets go back // (into state.undoStack), positive go forward (into state.redoStack). 0 = current. // Used by the history panel. // History panel — full implementation in editor/history-panel.js. // Wrappers preserve the legacy names that the topbar History button // + undo/redo paths already reference. const _historyPanel = createHistoryPanel({ undo, redo }); const _jumpToHistory = _historyPanel.jumpToHistory; const _toggleHistoryPanel = _historyPanel.toggleHistoryPanel; const _refreshHistoryPanelIfOpen = _historyPanel.refreshHistoryPanelIfOpen; // _relTime lives in editor/layer-helpers.js. // ── Canvas event helpers ── // _canvasCoords lives in editor/canvas-coords.js — see import at top. // ── Drawing ── function _beginDraw(e) { // Fall back to the parent resolver so a stale activeLayerId doesn't // block strokes when there ARE layers present. const layer = activeLayer() || _activeParentLayer(); // Transform-tool drag (handle grab or move-fallback) — handler in // editor/tools/transform-drag.js. if (_transformDragTool.tryBegin(e)) return; // Magic wand is selection-only — works even on locked layers because // it doesn't mutate the layer until an action (Erase/Copy) is taken. // Full implementation in editor/tools/wand.js. if (state.tool === 'wand') return _wandTool.click(e); // Inpaint can create its own layer + mask on the fly, so skip the // "no active layer → bail" gate for it specifically. if (state.tool !== 'inpaint' && (!layer || layer.locked)) return; // Keep activeLayerId in sync so downstream lookups resolve. if (layer && state.activeLayerId !== layer.id) state.activeLayerId = layer.id; if (state.tool === 'move') return _beginMove(e); if (state.tool === 'crop') return _beginCrop(e); if (state.tool === 'lasso') return _beginLasso(e); // Clone-stamp — source pick + stroke start. Full implementation in // editor/tools/clone.js; per-sample stamping continues through the // shared `_strokeTo` pipeline below. if (state.tool === 'clone') return _cloneTool.begin(e); // Brush / Eraser / Inpaint share a stroke pipeline — handler in // editor/tools/stroke.js. Returns true for those tools, false // otherwise (any other tool that reached here is a no-op). _strokeTool.tryBegin(e); } function _continueDraw(e) { // _continueDraw is now bound to the window so drags can extend past // the canvas. The brush-cursor overlay should only follow the cursor // when it's actually over the canvas, otherwise hide it. const overCanvas = state.mainCanvas && e.target === state.mainCanvas; if (['eraser', 'inpaint', 'lasso', 'brush'].includes(state.tool) && state.mainCanvas) { if (overCanvas) _updateBrushCursor(e); else if (state.cursorEl) state.cursorEl.style.display = 'none'; } // Transform-tool hover-cursor + handle drag — handler in // editor/tools/transform-drag.js. Returns true when the drag is // consuming the event (rotation / resize); the hover-cursor pass // returns false so the dispatcher can still fall through to other // tools that share the canvas hover (none currently, but kept for // future-proofing). if (_transformDragTool.tryContinue(e)) return; if (state.lassoActive) return _continueLasso(e); if (!state.drawing) { if (state.moving) return _continueMove(e); if (state.cropping || state.cropMoving) return _continueCrop(e); return; } // In-progress stroke (brush / eraser / inpaint / clone) — handler in // editor/tools/stroke.js. _strokeTool.tryContinue(e); } function _endDraw() { // Transform-tool drag end — handler in editor/tools/transform-drag.js. if (_transformDragTool.tryEnd()) return; if (state.lassoActive) return _endLasso(); if (state.moving) return _endMove(); if (state.cropping || state.cropMoving) return _endCrop(); // Stroke end (brush / eraser / inpaint / clone) — handler in // editor/tools/stroke.js. _strokeTool.tryEnd(); } // Floating popup that appears after an inpaint stroke so the user can // type a prompt and Generate without diverting to the side panel. The // popup re-uses the existing #ge-inpaint-prompt and #ge-inpaint-run // elements by reparenting them into a positioned wrapper, so all the // existing handlers (Enter to submit, generate-button click) still fire. // Inpaint-stroke prompt popup feature was removed — the user types in // the side panel and hits Generate there. Helpers _showInpaintPrompt / // _dismissInpaintPrompt and their dismiss-handlers were dead code and // have been deleted. // Clone Stamp painter — stamps circular samples from the source // snapshot at every interpolated point between the last brush position // and the current one, so a drag produces a continuous clone. The // sample offset is fixed at stroke-start (Photoshop "aligned" mode): // `sample = source + (cursor − strokeStart)`. // Stroke pipeline — paints one segment last→current onto the active // layer (or active mask sub-layer). Full implementation in // editor/stroke-pipeline.js. const _strokePipeline = createStrokePipeline({ // Use the fallback-capable parent resolver so the stroke pipeline // and _getActiveMaskLayer() agree on which layer is active. Plain // activeLayer() returns null when activeLayerId is stale, which made // strokeTo bail even though a mask had been created on the fallback // parent — the "inpaint draws nothing" bug. activeLayer: () => activeLayer() || _activeParentLayer(), getActiveMaskLayer: () => _getActiveMaskLayer(), composite, }); const _strokeTo = _strokePipeline.strokeTo; const _cloneStrokeTo = _strokePipeline.cloneStrokeTo; // ── Brush cursor overlay ── function _updateBrushCursor(e) { if (!state.mainCanvas) return; const rect = state.mainCanvas.getBoundingClientRect(); const clientX = e.touches ? e.touches[0].clientX : e.clientX; const clientY = e.touches ? e.touches[0].clientY : e.clientY; if (!state.cursorEl) { state.cursorEl = document.createElement('div'); state.cursorEl.className = 'ge-brush-cursor'; document.body.appendChild(state.cursorEl); } // Lasso uses the feather radius (or a sensible default) so the circle // shows the area that will be selected. Other tools use the brush size. let basePx; if (state.tool === 'lasso') { const f = parseInt(document.getElementById('ge-lasso-feather')?.value || '0'); basePx = Math.max(10, f * 2); } else { basePx = state.brushSize; } const diameter = basePx * state.zoom; state.cursorEl.style.width = diameter + 'px'; state.cursorEl.style.height = diameter + 'px'; state.cursorEl.style.left = (clientX - diameter / 2) + 'px'; state.cursorEl.style.top = (clientY - diameter / 2) + 'px'; state.cursorEl.style.display = ''; if (state.tool === 'inpaint') { // Visual cue for paint vs erase mode. Ctrl+Alt held mid-hover also // flips the cursor so the user sees the effective mode before they // click. Red = paint mask, white-dashed = erase mask. const flip = e && e.ctrlKey && e.altKey; const eraseEffective = flip ? !state.inpaintEraseMode : state.inpaintEraseMode; if (eraseEffective) { state.cursorEl.style.borderColor = 'rgba(255,255,255,0.9)'; state.cursorEl.style.background = 'rgba(255,255,255,0.10)'; state.cursorEl.style.borderStyle = 'dashed'; } else { state.cursorEl.style.borderColor = 'rgba(255,80,80,0.8)'; state.cursorEl.style.background = 'rgba(255,50,50,0.25)'; state.cursorEl.style.borderStyle = 'solid'; } } else if (state.tool === 'lasso') { state.cursorEl.style.borderColor = 'rgba(255,255,255,0.85)'; state.cursorEl.style.background = 'rgba(0,0,0,0.15)'; state.cursorEl.style.borderStyle = 'solid'; } else { state.cursorEl.style.borderColor = state.tool === 'eraser' ? 'rgba(255,255,255,0.6)' : state.color; state.cursorEl.style.background = 'transparent'; state.cursorEl.style.borderStyle = 'solid'; } } // ── Move tool ── // Move tool — full implementation lives in editor/tools/move.js. Wrap // `_beginMove` / `_continueMove` / `_endMove` to the factory output so // the existing dispatcher (_beginDraw / _continueDraw / _endDraw) keeps // working without changes. const _moveTool = createMoveTool({ activeLayer, saveState: _saveState, composite }); const _beginMove = _moveTool.begin; const _continueMove = _moveTool.drag; const _endMove = _moveTool.end; // ── Crop tool ── // Crop tool — full implementation in editor/tools/crop.js. Wire // `_beginCrop` / `_continueCrop` / `_endCrop` to the factory output so // the existing dispatcher keeps working without changes. const _cropTool = createCropTool({ composite, showCropApply: () => _showCropApply() }); const _beginCrop = _cropTool.begin; const _continueCrop = _cropTool.drag; const _endCrop = _cropTool.end; function _showCropApply() { let pop = state.container.querySelector('.ge-crop-apply'); if (pop) pop.remove(); // A small floating panel: W × H inputs and the Apply button. pop = document.createElement('div'); pop.className = 'ge-crop-apply'; pop.innerHTML = ` × `; const area = state.container.querySelector('.ge-canvas-area'); if (!area || !state.cropRect || !state.mainCanvas) return; area.appendChild(pop); pop.querySelector('.ge-crop-apply-btn').addEventListener('click', () => _applyCrop()); // Editing W/H updates the crop rect anchored at its top-left so the // user sees the dimensions live in the overlay. const wInput = pop.querySelector('.ge-crop-w'); const hInput = pop.querySelector('.ge-crop-h'); const onSize = () => { if (!state.cropRect) return; const w = Math.max(1, parseInt(wInput.value, 10) || state.cropRect.w); const h = Math.max(1, parseInt(hInput.value, 10) || state.cropRect.h); state.cropRect = { ...state.cropRect, w, h }; composite(); }; wInput.addEventListener('input', onSize); hInput.addEventListener('input', onSize); // Enter in either field triggers apply. [wInput, hInput].forEach(inp => { inp.addEventListener('keydown', (e) => { if (e.key === 'Enter') _applyCrop(); }); }); // Position the panel just outside the bottom-right corner of the // crop rectangle (where the user finished dragging), in area coords. const canvasRect = state.mainCanvas.getBoundingClientRect(); const areaRect = area.getBoundingClientRect(); const scaleX = canvasRect.width / state.mainCanvas.width; const scaleY = canvasRect.height / state.mainCanvas.height; const localX = (canvasRect.left - areaRect.left) + (state.cropRect.x + state.cropRect.w) * scaleX; const localY = (canvasRect.top - areaRect.top) + (state.cropRect.y + state.cropRect.h) * scaleY; pop.style.position = 'absolute'; pop.style.left = (localX + 6) + 'px'; pop.style.top = (localY + 6) + 'px'; // Clamp inside the CANVAS image bounds (not just the canvas-area) so // the panel doesn't sit on the dark padding around the canvas — it // stays anchored over the actual image. requestAnimationFrame(() => { const bRect = pop.getBoundingClientRect(); const canvasLeft = canvasRect.left - areaRect.left; const canvasTop = canvasRect.top - areaRect.top; const canvasRight = canvasLeft + canvasRect.width; const canvasBottom = canvasTop + canvasRect.height; let nx = parseFloat(pop.style.left) || 0; let ny = parseFloat(pop.style.top) || 0; if (nx + bRect.width > canvasRight - 4) nx = canvasRight - bRect.width - 4; if (ny + bRect.height > canvasBottom - 4) ny = canvasBottom - bRect.height - 4; nx = Math.max(canvasLeft + 4, nx); ny = Math.max(canvasTop + 4, ny); pop.style.left = nx + 'px'; pop.style.top = ny + 'px'; }); } function _applyCrop() { if (!state.cropRect) return; _saveState('Crop'); const { x, y, w, h } = state.cropRect; const cw = Math.round(w); const ch = Math.round(h); for (const layer of state.layers) { const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 }; const data = layer.ctx.getImageData(x - off.x, y - off.y, cw, ch); layer.canvas.width = cw; layer.canvas.height = ch; layer.ctx.putImageData(data, 0, 0); state.layerOffsets.set(layer.id, { x: 0, y: 0 }); } state.mainCanvas.width = cw; state.mainCanvas.height = ch; state.imgWidth = cw; state.imgHeight = ch; if (state.maskCanvas) { state.maskCanvas.width = cw; state.maskCanvas.height = ch; } state.cropRect = null; const btn = state.container.querySelector('.ge-crop-apply'); if (btn) btn.remove(); composite(); _fitZoom(); } // Text tool was removed from the toolbar; the _placeText implementation // (and the `state.tool === 'text'` dispatcher branch) used to live here // but had no remaining call sites and was dead code. // ── Free Transform (Ctrl+Alt+T) ── // Transform session — full implementation in // editor/tools/transform-session.js. Wrappers preserve the legacy // names that the dispatcher / toolbar / shortcuts already reference. const _transformSession = createTransformSession({ activeLayer, saveState: _saveState, composite, fitZoom: () => _fitZoom(), drawTransformHandles: () => _drawTransformHandles(), showCanvasLoading: (label) => _showCanvasLoading(label), hideCanvasLoading: () => _hideCanvasLoading(), undo, uiModule, }); const _startTransform = _transformSession.startTransform; const _openTransformPopup = _transformSession.openTransformPopup; const _closeTransformPopup = _transformSession.closeTransformPopup; const _reapplyTransform = _transformSession.reapplyTransform; const _confirmTransform = _transformSession.confirmTransform; const _cancelTransform = _transformSession.cancelTransform; // ── Lasso tool ── // Lasso tool — full implementation in editor/tools/lasso.js. const _lassoTool = createLassoTool({ composite, drawLassoOverlay: () => _drawLassoOverlay(), syncToolClearIndicators: () => _syncToolClearIndicators(), }); const _beginLasso = _lassoTool.begin; const _continueLasso = _lassoTool.drag; const _endLasso = _lassoTool.end; // Magic wand — selection-only click handler in editor/tools/wand.js. const _wandTool = createWandTool({ activeLayer, saveState: _saveState, composite, wandHits: (x, y) => _wandHits(x, y), runMagicWand: (x, y, mode) => _runMagicWand(x, y, mode), }); // Clone-stamp tool — source-pick + stroke-start handler in // editor/tools/clone.js. Per-sample stamping still runs through the // shared stroke pipeline (`_strokeTo`) since clone-mode is detected // there from state.cloneSourceSnapshot. const _cloneTool = createCloneTool({ activeLayer, saveState: _saveState, strokeTo: (x, y) => _strokeTo(x, y), showToast: (msg) => { if (uiModule) uiModule.showToast(msg); }, }); // Transform-tool drag interactions (handle picking, rotation, resize) // in editor/tools/transform-drag.js. The dispatcher calls // `tryBegin/tryContinue/tryEnd` and short-circuits when they return true. const _transformDragTool = createTransformDragTool({ beginMove: (e) => _beginMove(e), composite, drawTransformHandles: () => _drawTransformHandles(), reapplyTransform: () => _reapplyTransform(), getTransformHandle: (x, y) => _getTransformHandle(x, y), cursorForHandle: _cursorForHandle, }); // Shared stroke pipeline (brush / eraser / inpaint) in // editor/tools/stroke.js. Clone reuses tryContinue / tryEnd via the // shared drawing flag; clone's own begin is in editor/tools/clone.js. const _strokeTool = createStrokeTool({ saveState: _saveState, strokeTo: (x, y) => _strokeTo(x, y), composite, getActiveMaskLayer: () => _getActiveMaskLayer(), activeParentLayer: () => _activeParentLayer(), ensureActiveMaskLayer: () => _ensureActiveMaskLayer(), createLayer, renderLayerPanel: () => _renderLayerPanel(), syncToolClearIndicators: () => _syncToolClearIndicators(), }); // Compute the outward-normal offset of the lasso polygon by `grow` // pixels at each vertex. Lets the Edge stroke slider visually move // the dashed outline in/out without re-running the mask raster. // Thin wrapper around the pure helper in editor/tools/lasso-mask.js // so existing callers using module state stay unchanged. function _lassoOffsetPoints(grow) { return _lassoOffsetPointsImpl(state.lassoPoints, grow); } function _drawLassoOverlay() { if (state.lassoPoints.length < 3) return; // Read live slider values so the overlay shows the actual edge that // will be committed: Edge stroke shifts the polygon outline in/out; // Feather draws a soft red halo to suggest the alpha fade. const featherEl = document.getElementById('ge-lasso-feather'); const growEl = document.getElementById('ge-lasso-grow'); const feather = featherEl ? parseInt(featherEl.value || '0', 10) : 0; const grow = growEl ? parseInt(growEl.value || '0', 10) : 0; const ringPts = grow ? _lassoOffsetPoints(grow) : state.lassoPoints; const tracePath = (pts) => { state.mainCtx.beginPath(); state.mainCtx.moveTo(pts[0].x, pts[0].y); for (let i = 1; i < pts.length; i++) state.mainCtx.lineTo(pts[i].x, pts[i].y); state.mainCtx.closePath(); }; if (feather > 0) { // Concentric outer outlines that fade out, suggesting the feather // fade band that will be applied to the mask alpha at commit. const rings = 4; for (let r = 1; r <= rings; r++) { const offset = (feather * r) / rings; tracePath(_lassoOffsetPoints(grow + offset)); state.mainCtx.strokeStyle = `rgba(255, 80, 80, ${0.4 * (1 - r / rings)})`; state.mainCtx.lineWidth = 1 / state.zoom; state.mainCtx.setLineDash([]); state.mainCtx.stroke(); } } tracePath(ringPts); state.mainCtx.strokeStyle = '#fff'; state.mainCtx.lineWidth = 1 / state.zoom; state.mainCtx.setLineDash([4 / state.zoom, 4 / state.zoom]); state.mainCtx.stroke(); state.mainCtx.setLineDash([]); state.mainCtx.fillStyle = 'rgba(255, 80, 80, 0.1)'; state.mainCtx.fill(); } function _getLassoPath(ctx) { _getLassoPathImpl(ctx, state.lassoPoints); } /** * Build a feathered selection mask from the current lasso polygon. * Implementation lives in editor/tools/lasso-mask.js — this wrapper * forwards the current `state.lassoPoints` so existing callers keep * working unchanged. */ function _buildLassoMask(w, h, offX, offY, feather, grow) { return _buildLassoMaskImpl(state.lassoPoints, w, h, offX, offY, feather, grow); } // ── Magic Wand ── // Click-fill from (cx, cy) on the active layer. Builds a binary mask of // all pixels reachable from the seed whose RGB distance is within // state.wandTolerance × 4.42 (4.42 ≈ scale factor so tolerance=100 ≈ max). // // `mode`: // 'replace' (default) — replaces any previous selection // 'add' — unions the new region with the existing selection // 'subtract' — removes the new region from the existing selection // Cached layer pixel data + dimensions for the wand. `getImageData` is // the dominant cost when live-retuning tolerance (millions of pixels → // 50–200 ms per call on a 4K canvas). Invalidated by _invalidateWandCache // whenever the active layer changes or the editor closes. // Pristine snapshot of the last Bg-Removed cutout so the Edge cleanup // sliders can live-rebuild the alpha without re-running the model. function _invalidateWandCache() { state.wandSrcCache = null; } function _getWandSource(layer) { if (state.wandSrcCache && state.wandSrcCache.layerId === layer.id && state.wandSrcCache.w === layer.canvas.width && state.wandSrcCache.h === layer.canvas.height) { return state.wandSrcCache; } const w = layer.canvas.width, h = layer.canvas.height; state.wandSrcCache = { layerId: layer.id, w, h, data: layer.ctx.getImageData(0, 0, w, h).data, }; return state.wandSrcCache; } // Click-deselect helper: returns true if (cx, cy) lands inside the // existing wand selection on the same layer. Used by the mousedown // handler to make a second click "in the selection" toggle it off. function _wandHits(cx, cy) { if (!state.wandMask || !state.wandLayerId) return false; const layer = state.layers.find(l => l.id === state.wandLayerId); if (!layer) return false; const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 }; const lx = Math.floor(cx - off.x); const ly = Math.floor(cy - off.y); if (lx < 0 || ly < 0 || lx >= state.wandMask.width || ly >= state.wandMask.height) return false; try { const px = state.wandMask.getContext('2d').getImageData(lx, ly, 1, 1).data; return px[3] > 128; } catch { return false; } } function _runMagicWand(cx, cy, mode = 'replace', opts = {}) { if (!opts.retune && !opts.deferred) { const cleanup = _showWandLoading(); requestAnimationFrame(() => { requestAnimationFrame(() => { try { _runMagicWand(cx, cy, mode, { ...opts, deferred: true }); } finally { cleanup(); } }); }); return; } const layer = activeLayer(); if (!layer || layer.locked) return; // If an active mask sub-layer is selected, the wand operates on the // MASK pixels rather than the parent layer's pixels — lets the user // click inside / outside an existing mask to select that region for // further editing. Mask canvases are doc-sized with no per-layer // offset, so the seed coords are used as-is. const activeMask = _getActiveMaskLayer(); const sourceCanvas = activeMask ? activeMask.canvas : layer.canvas; const sourceCtx = activeMask ? activeMask.ctx : layer.ctx; // Snapshot current state to undo BEFORE mutating the selection, but // skip when called via the tolerance slider (`opts.retune`) so dragging // the slider doesn't fill the undo stack with intermediate states. if (!opts.retune) _saveState(); // Remember the seed so the tolerance slider can re-run the wand live. state.wandLastSeed = { x: cx, y: cy, mode }; const off = activeMask ? { x: 0, y: 0 } : (state.layerOffsets.get(layer.id) || { x: 0, y: 0 }); const lx = Math.floor(cx - off.x); const ly = Math.floor(cy - off.y); const w = sourceCanvas.width, h = sourceCanvas.height; if (lx < 0 || ly < 0 || lx >= w || ly >= h) return; // Read pixels from the chosen source. Bypass the cache when sourcing // from a mask — masks change frequently and the cache is keyed by // parent layer id, not by mask id. const src = activeMask ? sourceCtx.getImageData(0, 0, w, h).data : _getWandSource(layer).data; // Pixel-level flood fill lives in editor/tools/flood-fill.js. // Returns a mask canvas at (w × h) with white where the fill landed. const mask = _floodFillMask(src, w, h, lx, ly, state.wandTolerance); if (!mask) return; // Merge with existing selection per `mode`. If the existing mask is // for a different layer or has different dimensions, treat as replace // since merging doesn't make sense across canvases. const compatible = state.wandMask && state.wandLayerId === layer.id && state.wandMask.width === mask.width && state.wandMask.height === mask.height; if (compatible && mode === 'add') { // Union: paint new selection on top of the existing one. state.wandMask.getContext('2d').drawImage(mask, 0, 0); } else if (compatible && mode === 'subtract') { // Difference: erase new selection from the existing one. const ec = state.wandMask.getContext('2d'); ec.save(); ec.globalCompositeOperation = 'destination-out'; ec.drawImage(mask, 0, 0); ec.restore(); } else { state.wandMask = mask; state.wandLayerId = layer.id; } composite(); _syncToolClearIndicators(); } function _showWandLoading() { const area = state.container?.querySelector('.ge-canvas-area'); if (!area) return () => {}; const overlay = document.createElement('div'); overlay.className = 'ge-wand-loading'; let spinner = null; try { spinner = spinnerModule.createWhirlpool(30); spinner.element.style.cssText = 'width:30px;height:30px;margin:0;'; overlay.appendChild(spinner.element); } catch (_) { overlay.textContent = 'Selecting...'; } area.appendChild(overlay); return () => { try { spinner?.destroy?.(); } catch {} overlay.remove(); }; } // Draw the wand selection as a translucent red overlay, mirroring the // inpaint-mask visual so users know what's selected. function _drawWandOverlay() { if (!state.wandMask || !state.mainCtx) return; const layer = state.layers.find(l => l.id === state.wandLayerId); if (!layer) return; const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 }; // Tint the white mask red, draw at the layer's offset on the main canvas. const tint = document.createElement('canvas'); tint.width = state.wandMask.width; tint.height = state.wandMask.height; const tc = tint.getContext('2d'); tc.drawImage(state.wandMask, 0, 0); tc.globalCompositeOperation = 'source-in'; tc.fillStyle = 'rgba(255, 60, 60, 1)'; tc.fillRect(0, 0, tint.width, tint.height); state.mainCtx.save(); state.mainCtx.globalAlpha = 0.4; state.mainCtx.drawImage(tint, off.x, off.y); state.mainCtx.globalAlpha = 1; state.mainCtx.restore(); } function _wandClear() { state.wandMask = null; state.wandLayerId = null; state.wandLastSeed = null; _invalidateWandCache(); composite(); _syncToolClearIndicators(); } // Hover thumbnail — generated by downscaling the layer's canvas into a // small floating panel. Lives in document.body so panel `overflow: // hidden` can't clip it. One singleton element, repositioned per hover. function _showLayerThumb(rowEl, layer) { if (!layer || !layer.canvas) return; if (!state.layerThumbEl) { state.layerThumbEl = document.createElement('div'); state.layerThumbEl.className = 'ge-layer-thumb'; document.body.appendChild(state.layerThumbEl); } const SIZE = 120; // Downscale layer onto a small canvas, preserving aspect. const lw = layer.canvas.width, lh = layer.canvas.height; const scale = Math.min(SIZE / lw, SIZE / lh); const tw = Math.max(1, Math.round(lw * scale)); const th = Math.max(1, Math.round(lh * scale)); const c = document.createElement('canvas'); c.width = tw; c.height = th; // Checker bg so transparency reads const ctx = c.getContext('2d'); const tile = 8; for (let y = 0; y < th; y += tile) for (let x = 0; x < tw; x += tile) { ctx.fillStyle = ((x / tile + y / tile) & 1) ? '#444' : '#333'; ctx.fillRect(x, y, tile, tile); } ctx.drawImage(layer.canvas, 0, 0, tw, th); state.layerThumbEl.innerHTML = ''; state.layerThumbEl.appendChild(c); // Position to the LEFT of the row so it doesn't cover other layers. const r = rowEl.getBoundingClientRect(); state.layerThumbEl.style.top = Math.max(8, r.top - 4) + 'px'; state.layerThumbEl.style.right = (window.innerWidth - r.left + 8) + 'px'; state.layerThumbEl.style.left = ''; state.layerThumbEl.style.display = 'block'; } function _hideLayerThumb() { if (state.layerThumbEl) state.layerThumbEl.style.display = 'none'; } // Shift+click on a layer row → use that layer's opaque pixels as a // wand-style selection. Lifts pixel alpha > 0 into the wand mask so the // user can immediately Bg-Remove / Erase / Copy through the layer. function _loadLayerAlphaAsSelection(layer) { if (!layer || !layer.canvas) return; const w = layer.canvas.width, h = layer.canvas.height; const src = layer.ctx.getImageData(0, 0, w, h).data; const mask = document.createElement('canvas'); mask.width = w; mask.height = h; const mctx = mask.getContext('2d'); const mdata = mctx.createImageData(w, h); for (let i = 0; i < w * h; i++) { if (src[i * 4 + 3] > 0) { mdata.data[i * 4] = 255; mdata.data[i * 4 + 1] = 255; mdata.data[i * 4 + 2] = 255; mdata.data[i * 4 + 3] = 255; } } mctx.putImageData(mdata, 0, 0); _saveState(); state.wandMask = mask; state.wandLayerId = layer.id; state.wandLastSeed = null; composite(); if (uiModule) uiModule.showToast('Layer pixels selected'); } // Invert the active selection: lasso (point list — turn into a polygon // covering the canvas with the lasso polygon as a hole) or wand (flip // the mask alpha). Wired to Ctrl+Alt+I. function _invertSelection() { if (state.wandMask && state.wandLayerId) { _saveState(); const w = state.wandMask.width, h = state.wandMask.height; const ctx = state.wandMask.getContext('2d'); const data = ctx.getImageData(0, 0, w, h); const d = data.data; for (let i = 0; i < d.length; i += 4) { const a = d[i + 3] > 128 ? 0 : 255; d[i] = 255; d[i + 1] = 255; d[i + 2] = 255; d[i + 3] = a; } ctx.putImageData(data, 0, 0); composite(); if (uiModule) uiModule.showToast('Selection inverted'); return true; } if (state.lassoPoints.length >= 3 && !state.lassoActive) { // Build polygon covering the whole canvas, with the lasso as a hole. // Easiest: convert lasso to wand mask, then invert. _saveState(); const w = state.imgWidth, h = state.imgHeight; const c = document.createElement('canvas'); c.width = w; c.height = h; const cctx = c.getContext('2d'); cctx.fillStyle = '#fff'; cctx.fillRect(0, 0, w, h); cctx.globalCompositeOperation = 'destination-out'; cctx.beginPath(); cctx.moveTo(state.lassoPoints[0].x, state.lassoPoints[0].y); for (let i = 1; i < state.lassoPoints.length; i++) cctx.lineTo(state.lassoPoints[i].x, state.lassoPoints[i].y); cctx.closePath(); cctx.fill(); state.wandMask = c; state.wandLayerId = state.activeLayerId; state.wandLastSeed = null; state.lassoPoints = []; state.lassoActive = false; composite(); if (uiModule) uiModule.showToast('Selection inverted (converted to wand)'); return true; } return false; } // Convert the wand selection into the inpaint mask, mirroring _lassoToMask. // Switches to the inpaint tool so the user sees the result right away. function _wandToMask() { if (!state.wandMask || !state.wandLayerId) return; const layer = state.layers.find(l => l.id === state.wandLayerId); if (!layer) return; const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 }; // Make the wand's parent active so the mask is attached to it, then // get-or-create a mask sub-layer on it. Repoint the global mask // plumbing at the new sub-layer's canvas/ctx. state.activeLayerId = layer.id; const mask = _ensureActiveMaskLayer(); if (!mask) return; state.maskCanvas = mask.canvas; state.maskCtx = mask.ctx; // Refine the wand mask with the panel's Feather + Edge stroke values // before merging into the inpaint mask. Grow/shrink uses the same // blur+threshold dilate/erode as the lasso path; feather blurs the // result's alpha for a soft edge. const wFeather = parseInt(document.getElementById('ge-wand-feather')?.value || '0', 10); const wGrow = parseInt(document.getElementById('ge-wand-grow')?.value || '0', 10); let refinedWand = state.wandMask; if (wGrow !== 0) { const c = document.createElement('canvas'); c.width = state.wandMask.width; c.height = state.wandMask.height; const bctx = c.getContext('2d'); bctx.filter = `blur(${Math.abs(wGrow)}px)`; bctx.drawImage(state.wandMask, 0, 0); bctx.filter = 'none'; const blurred = bctx.getImageData(0, 0, c.width, c.height).data; const out = bctx.createImageData(c.width, c.height); const od = out.data; const thr = wGrow > 0 ? 32 : 200; for (let i = 0; i < od.length; i += 4) { const a = blurred[i + 3] >= thr ? 255 : 0; od[i] = a; od[i + 1] = a; od[i + 2] = a; od[i + 3] = a; } bctx.putImageData(out, 0, 0); refinedWand = c; } if (wFeather > 0) { const c = document.createElement('canvas'); c.width = refinedWand.width; c.height = refinedWand.height; const fctx = c.getContext('2d'); fctx.filter = `blur(${wFeather}px)`; fctx.drawImage(refinedWand, 0, 0); fctx.filter = 'none'; refinedWand = c; } // Draw the refined wand mask into the inpaint mask canvas at the // layer's offset. OR-like merge: any painted pixel in the wand mask // is added to the inpaint mask (max alpha wins). Matches the lasso // path's semantics. const tmp = document.createElement('canvas'); tmp.width = state.maskCanvas.width; tmp.height = state.maskCanvas.height; const tctx = tmp.getContext('2d'); tctx.drawImage(refinedWand, off.x, off.y); const incoming = tctx.getImageData(0, 0, tmp.width, tmp.height); const cur = state.maskCtx.getImageData(0, 0, state.maskCanvas.width, state.maskCanvas.height); for (let i = 0; i < incoming.data.length; i += 4) { if (incoming.data[i + 3] > cur.data[i + 3]) { cur.data[i] = 255; cur.data[i + 1] = 255; cur.data[i + 2] = 255; cur.data[i + 3] = incoming.data[i + 3]; } } state.maskCtx.putImageData(cur, 0, 0); // Stay on the Wand tool — just bake the selection into the mask. // Clear the wand selection so a re-click starts fresh (and the red // overlay doesn't double up over the inpaint-mask red tint). state.wandMask = null; state.wandLayerId = null; state.wandLastSeed = null; composite(); _renderLayerPanel(); if (uiModule) uiModule.showToast('Selection added to mask'); } // Reveal/hide the small "X" badge on the Lasso and Wand tool buttons // based on whether each tool currently holds a selection. Called from // anywhere selection state mutates (wand click, lasso close, undo, etc.). function _syncToolClearIndicators() { // Selection state drives: // (1) the "from-selection" highlight on each layer's Add-mask btn // (2) the visibility of the post-selection refine rows (Feather + // Edge stroke) on the lasso / wand panels. // (3) the topbar Fill button — visible whenever lasso/wand/active // mask gives us a region to fill. const lassoHasSel = state.lassoPoints.length >= 3 && !state.lassoActive; const wandHasSel = !!state.wandMask; const hasMaskTarget = !!_getActiveMaskLayer(); const hasSel = lassoHasSel || wandHasSel; document.querySelectorAll('.ge-layer-mask-btn').forEach(b => { b.classList.toggle('from-selection', hasSel); }); // Fill action now lives in the Image menu — enable when there's // something fillable (selection or active mask). const fillItem = document.getElementById('ge-image-action-fill'); if (fillItem) { fillItem.disabled = !(hasSel || hasMaskTarget); fillItem.title = fillItem.disabled ? 'Make a selection or pick a mask first' : 'Fill the active selection / mask with the current color'; } // Topbar Selection button only makes sense with an active selection. const edgeWrap = document.getElementById('ge-edge-wrap'); if (edgeWrap) edgeWrap.hidden = !hasSel; const lFeather = document.getElementById('ge-lasso-refine-feather'); const lGrow = document.getElementById('ge-lasso-refine-grow'); if (lFeather) lFeather.style.display = lassoHasSel ? '' : 'none'; if (lGrow) lGrow.style.display = lassoHasSel ? '' : 'none'; const wFeather = document.getElementById('ge-wand-refine-feather'); const wGrow = document.getElementById('ge-wand-refine-grow'); if (wFeather) wFeather.style.display = wandHasSel ? '' : 'none'; if (wGrow) wGrow.style.display = wandHasSel ? '' : 'none'; if (!state.container) return; const lassoBtn = state.container.querySelector('.ge-tool-btn[data-tool="lasso"]'); const wandBtn = state.container.querySelector('.ge-tool-btn[data-tool="wand"]'); const inpaintBtn = state.container.querySelector('.ge-tool-btn[data-tool="inpaint"]'); if (lassoBtn) lassoBtn.classList.toggle('has-selection', state.lassoPoints.length >= 3 && !state.lassoActive); if (wandBtn) wandBtn.classList.toggle('has-selection', !!state.wandMask); // Inpaint no longer carries a clear-X badge; masks live as sub-layers // in the layer panel and are deleted from there. if (inpaintBtn) inpaintBtn.classList.remove('has-selection'); } function _hasMaskPixels() { if (!state.maskCanvas || !state.maskCtx) return false; try { const d = state.maskCtx.getImageData(0, 0, state.maskCanvas.width, state.maskCanvas.height).data; for (let i = 3; i < d.length; i += 4) if (d[i] > 0) return true; } catch (_) {} return false; } function _wandDeleteSelection() { if (!state.wandMask) return; const layer = state.layers.find(l => l.id === state.wandLayerId); if (!layer || layer.locked) return; _saveState(); // Use destination-out with the mask to erase the selected pixels. layer.ctx.save(); layer.ctx.globalCompositeOperation = 'destination-out'; layer.ctx.drawImage(state.wandMask, 0, 0); layer.ctx.restore(); _wandClear(); } function _wandCopyToNewLayer() { if (!state.wandMask) return; const src = state.layers.find(l => l.id === state.wandLayerId); if (!src) return; _saveState(); // Clip the source by the mask, put it on a new layer. const tmp = document.createElement('canvas'); tmp.width = src.canvas.width; tmp.height = src.canvas.height; const tCtx = tmp.getContext('2d'); tCtx.drawImage(src.canvas, 0, 0); tCtx.globalCompositeOperation = 'destination-in'; tCtx.drawImage(state.wandMask, 0, 0); const newLayer = createLayer('Wand copy', src.canvas.width, src.canvas.height); newLayer.ctx.drawImage(tmp, 0, 0); const srcOff = state.layerOffsets.get(src.id) || { x: 0, y: 0 }; state.layerOffsets.set(newLayer.id, { ...srcOff }); const idx = state.layers.findIndex(l => l.id === src.id); state.layers.splice(idx + 1, 0, newLayer); state.activeLayerId = newLayer.id; composite(); _renderLayerPanel(); _revealLayerPanel(); if (uiModule) uiModule.showToast('Copied to new layer'); } function _lassoDeleteSelection() { const layer = activeLayer(); if (!layer || state.lassoPoints.length < 3) return; const feather = parseInt(document.getElementById('ge-lasso-feather')?.value || '0'); const grow = parseInt(document.getElementById('ge-lasso-grow')?.value || '0'); _saveState(); const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 }; const w = layer.canvas.width, h = layer.canvas.height; const mask = _buildLassoMask(w, h, off.x, off.y, feather, grow); const maskData = mask.getContext('2d').getImageData(0, 0, w, h); const imgData = layer.ctx.getImageData(0, 0, w, h); for (let i = 0; i < w * h; i++) { const maskVal = maskData.data[i * 4]; // red channel if (maskVal > 0) { const fade = maskVal / 255; imgData.data[i * 4 + 3] = Math.round(imgData.data[i * 4 + 3] * (1 - fade)); } } layer.ctx.putImageData(imgData, 0, 0); state.lassoPoints = []; composite(); uiModule.showToast('Selection deleted'); } function _lassoCopyToLayer() { const layer = activeLayer(); if (!layer || state.lassoPoints.length < 3) return; const feather = parseInt(document.getElementById('ge-lasso-feather')?.value || '0'); const grow = parseInt(document.getElementById('ge-lasso-grow')?.value || '0'); _saveState(); const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 }; const w = layer.canvas.width, h = layer.canvas.height; const mask = _buildLassoMask(w, h, off.x, off.y, feather, grow); const newLayer = createLayer('Selection', state.imgWidth, state.imgHeight); // Copy layer pixels masked by the selection const srcData = layer.ctx.getImageData(0, 0, w, h); const maskData = mask.getContext('2d').getImageData(0, 0, w, h); const outData = newLayer.ctx.createImageData(w, h); for (let i = 0; i < w * h; i++) { const maskVal = maskData.data[i * 4]; if (maskVal > 0) { const fade = maskVal / 255; outData.data[i * 4] = srcData.data[i * 4]; outData.data[i * 4 + 1] = srcData.data[i * 4 + 1]; outData.data[i * 4 + 2] = srcData.data[i * 4 + 2]; outData.data[i * 4 + 3] = Math.round(srcData.data[i * 4 + 3] * fade); } } newLayer.ctx.putImageData(outData, 0, 0); state.layers.push(newLayer); state.activeLayerId = newLayer.id; state.lassoPoints = []; _renderLayerPanel(); _revealLayerPanel(); composite(); uiModule.showToast('Selection copied to new layer'); } function _lassoToMask() { if (state.lassoPoints.length < 3) return; // Get-or-create a mask sub-layer on the active parent layer and // repoint the global mask plumbing at it. const mask = _ensureActiveMaskLayer(); if (!mask) return; state.maskCanvas = mask.canvas; state.maskCtx = mask.ctx; // Fill selection into the mask with feather + grow/shrink applied. const feather = parseInt(document.getElementById('ge-lasso-feather')?.value || '0'); const grow = parseInt(document.getElementById('ge-lasso-grow')?.value || '0'); const lassoFill = _buildLassoMask(state.maskCanvas.width, state.maskCanvas.height, 0, 0, feather, grow); const maskData = lassoFill.getContext('2d').getImageData(0, 0, state.maskCanvas.width, state.maskCanvas.height); const curData = state.maskCtx.getImageData(0, 0, state.maskCanvas.width, state.maskCanvas.height); // Merge: add the new selection to existing mask for (let i = 0; i < maskData.data.length; i += 4) { const val = maskData.data[i]; if (val > curData.data[i]) { curData.data[i] = val; curData.data[i + 1] = val; curData.data[i + 2] = val; curData.data[i + 3] = val; } } state.maskCtx.putImageData(curData, 0, 0); // Stay on the Lasso tool — just bake the selection into the mask. // Keep the lasso points so the user can keep tweaking; clear the // active-shape state so the next click starts fresh if they want. state.lassoPoints = []; composite(); _renderLayerPanel(); uiModule.showToast('Selection added to mask'); } // ── Edge feather ── // Themed slider modal for filter parameters. Builds a single in-line // overlay anchored to the canvas-area's centre. `params` is an array // of `{ key, label, min, max, step, value, suffix }`. As the user // drags any slider the `onPreview(values)` callback fires for live // rendering; clicking Apply commits and resolves the returned Promise // with the final values; Cancel / Esc resolves with null. The caller // is responsible for snapshotting state BEFORE opening (so Cancel can // restore the layer's pixels). function _filterSliderPrompt(title, params, onPreview) { return new Promise((resolve) => { if (!state.container) { resolve(null); return; } const overlay = document.createElement('div'); overlay.className = 'ge-filter-overlay'; let rows = ''; for (const p of params) { // Reuse the editor's eraser-row class so the slider picks up the // standard slim red-thumb styling instead of the bare browser // default. Value chip on the same line as the label. rows += `
`; } overlay.innerHTML = `
${title}
${rows}
`; state.container.appendChild(overlay); const values = {}; for (const p of params) values[p.key] = p.value; // Initial preview render. try { onPreview(values); } catch {} overlay.querySelectorAll('input[type="range"]').forEach(inp => { inp.addEventListener('input', (e) => { const k = e.target.dataset.key; const v = parseFloat(e.target.value); values[k] = v; const lbl = overlay.querySelector(`[data-val-for="${k}"]`); const param = params.find(p => p.key === k); if (lbl) lbl.textContent = v + (param && param.suffix ? param.suffix : ''); try { onPreview(values); } catch {} }); }); const cleanup = (result) => { try { overlay.remove(); } catch {} document.removeEventListener('keydown', onKey, true); resolve(result); }; const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cleanup(null); } else if (e.key === 'Enter') { e.preventDefault(); cleanup(values); } }; document.addEventListener('keydown', onKey, true); overlay.querySelector('[data-action="apply"]').addEventListener('click', () => cleanup(values)); overlay.querySelector('[data-action="cancel"]').addEventListener('click', () => cleanup(null)); // Click outside the modal (on the dim backdrop) = cancel. overlay.addEventListener('click', (e) => { if (e.target === overlay) cleanup(null); }); }); } // Generic helper for live-preview blur filters. Saves the PRE-blur // state to the undo stack first (so Ctrl-Z reverts cleanly), snapshots // the layer for re-rendering, applies `renderer(snap, values)` into // the layer on every slider change for instant feedback. Apply keeps // the result; Cancel / Esc restores the snapshot AND pops the undo // entry we pre-saved so the canceled run leaves no trace. async function _applyLiveBlur({ title, params, label, renderer }) { const layer = activeLayer(); if (!layer || layer.locked) { if (uiModule) uiModule.showToast('Select an unlocked layer'); return; } const w = layer.canvas.width, h = layer.canvas.height; const snap = document.createElement('canvas'); snap.width = w; snap.height = h; snap.getContext('2d').drawImage(layer.canvas, 0, 0); // Save state BEFORE any preview — the undo stack now holds the // pre-blur pixels. Apply leaves it; Cancel pops it. _saveState(label); const draw = (values) => { layer.ctx.clearRect(0, 0, w, h); try { renderer(snap, values, layer.ctx); } catch (_) { layer.ctx.drawImage(snap, 0, 0); } composite(); }; const result = await _filterSliderPrompt(title, params, draw); if (result === null) { layer.ctx.clearRect(0, 0, w, h); layer.ctx.drawImage(snap, 0, 0); composite(); // Drop the snapshot we pushed — there's nothing to undo to. if (state.undoStack.length) state.undoStack.pop(); _refreshHistoryPanelIfOpen(); return; } // Final render from snapshot for a clean commit. layer.ctx.clearRect(0, 0, w, h); renderer(snap, result, layer.ctx); composite(); if (uiModule) uiModule.showToast(label + ' applied'); } function _applyGaussianBlur() { _applyLiveBlur({ title: 'Gaussian Blur', label: 'Gaussian Blur', params: [{ key: 'radius', label: 'Radius', min: 0, max: 100, step: 1, value: 6, suffix: 'px' }], renderer: _gaussianBlur, }); } function _applyZoomBlur() { _applyLiveBlur({ title: 'Zoom Blur', label: 'Zoom Blur', params: [{ key: 'strength', label: 'Strength', min: 1, max: 50, step: 1, value: 15 }], renderer: _zoomBlur, }); } function _applyMotionBlur() { _applyLiveBlur({ title: 'Motion Blur', label: 'Motion Blur', params: [ { key: 'length', label: 'Length', min: 1, max: 200, step: 1, value: 20, suffix: 'px' }, { key: 'angle', label: 'Angle', min: -180, max: 180, step: 1, value: 0, suffix: '°' }, ], renderer: _motionBlur, }); } function _applyEdgeFeather(layer, width, hardDelete) { const w = layer.canvas.width; const h = layer.canvas.height; const imgData = layer.ctx.getImageData(0, 0, w, h); _edgeFeather(imgData, width, hardDelete); layer.ctx.putImageData(imgData, 0, 0); } // ── Zoom ── function _fitZoom() { const fit = _getFitZoom(); if (!fit) return; state.zoom = fit; _applyZoom(); } function _getFitZoom() { const area = state.container.querySelector('.ge-canvas-area'); if (!area || !state.imgWidth) return null; const pad = 20; const maxW = area.clientWidth - pad * 2; const maxH = area.clientHeight - pad * 2; return Math.min(1, maxW / state.imgWidth, maxH / state.imgHeight); } function _applyZoom() { if (!state.mainCanvas) return; state.mainCanvas.style.width = (state.imgWidth * state.zoom) + 'px'; state.mainCanvas.style.height = (state.imgHeight * state.zoom) + 'px'; const label = state.container.querySelector('.ge-zoom-label'); if (label) label.textContent = Math.round(state.zoom * 100) + '%'; _syncZoomControls(); const area = state.container && state.container.querySelector('.ge-canvas-area'); if (area && area._resetPan) area._resetPan(); } function _syncZoomControls() { const fitBtn = document.getElementById('ge-zoom-fit'); const actualBtn = document.getElementById('ge-zoom-100'); const fit = _getFitZoom(); const isFit = fit !== null && Math.abs(state.zoom - fit) < 0.001; const isActual = !isFit && Math.abs(state.zoom - 1) < 0.001; if (fitBtn) { fitBtn.classList.toggle('active', isFit); fitBtn.setAttribute('aria-pressed', isFit ? 'true' : 'false'); } if (actualBtn) { actualBtn.classList.toggle('active', isActual); actualBtn.setAttribute('aria-pressed', isActual ? 'true' : 'false'); } } function _positionInpaintPanel(anchorBtn) { const panel = document.getElementById('ge-inpaint-section'); if (!panel || window.innerWidth <= 820) return; if (panel.dataset.userMoved === '1') { panel.classList.add('ge-inpaint-popover'); return; } panel.classList.add('ge-inpaint-popover'); // Anchor to the Layers header on the right panel so the popover // appears to slide out from there. The toolbar button on the left // shifts around as controls reflow, which was causing the popover // to land in different spots on each open and look "jumpy" when the // user grabbed it to move. Anchoring to the right panel — which has // a stable position — keeps the docked appearance steady. const layersHeader = document.querySelector('.ge-layers-header'); const rightPanel = document.querySelector('.ge-right-panel'); const ref = layersHeader || rightPanel; if (!ref) { // Fallback to the old toolbar-button anchor if the layers panel // isn't on screen yet. const r = anchorBtn?.getBoundingClientRect?.(); if (!r) return; requestAnimationFrame(() => { const panelW = panel.offsetWidth || 320; const panelH = panel.offsetHeight || 520; const left = Math.min(window.innerWidth - panelW - 12, Math.max(12, r.right + 10)); const top = Math.min(window.innerHeight - panelH - 12, Math.max(12, r.top)); panel.style.left = `${left}px`; panel.style.top = `${top}px`; }); return; } requestAnimationFrame(() => { const refRect = ref.getBoundingClientRect(); const panelW = panel.offsetWidth || 320; const panelH = panel.offsetHeight || 520; // Sit immediately to the left of the right panel, top-aligned with // the Layers header. 10px gap so it's clearly a separate window // and not visually fused with the panel. let left = refRect.left - panelW - 10; let top = refRect.top; // Clamp into the viewport so the popover never leaves the screen. left = Math.max(12, Math.min(window.innerWidth - panelW - 12, left)); top = Math.max(12, Math.min(window.innerHeight - panelH - 12, top)); panel.style.left = `${left}px`; panel.style.top = `${top}px`; }); } function _wireInpaintPopoverWindow() { const panel = document.getElementById('ge-inpaint-section'); if (!panel || panel.dataset.windowWired === '1') return; panel.dataset.windowWired = '1'; const closeBtn = document.getElementById('ge-inpaint-popover-close'); closeBtn?.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); panel.classList.add('dismissed'); panel.style.display = 'none'; document.getElementById('ge-controls')?.classList.remove('ge-inpaint-popover-host'); }); const head = panel.querySelector('[data-inpaint-drag]'); if (!head) return; head.addEventListener('pointerdown', (e) => { if (window.innerWidth <= 820 || e.target.closest('button')) return; e.preventDefault(); e.stopPropagation(); panel.classList.add('ge-inpaint-popover'); const startX = e.clientX; const startY = e.clientY; const r0 = panel.getBoundingClientRect(); head.setPointerCapture(e.pointerId); head.style.cursor = 'grabbing'; const onMove = (ev) => { const w = panel.offsetWidth || r0.width; const h = panel.offsetHeight || r0.height; const nx = Math.max(8, Math.min(window.innerWidth - w - 8, r0.left + ev.clientX - startX)); const ny = Math.max(8, Math.min(window.innerHeight - h - 8, r0.top + ev.clientY - startY)); panel.dataset.userMoved = '1'; panel.style.left = `${nx}px`; panel.style.top = `${ny}px`; }; const onUp = () => { try { head.releasePointerCapture(e.pointerId); } catch {} head.style.cursor = ''; head.removeEventListener('pointermove', onMove); head.removeEventListener('pointerup', onUp); }; head.addEventListener('pointermove', onMove); head.addEventListener('pointerup', onUp); }); } // ── Build DOM ── function _buildEditor(container) { container.innerHTML = ''; container.className = 'gallery-editor'; // Toolbar (left) — DOM construction lives in editor/build/toolbar.js; // the big tool-switch handler stays here so it can touch module state. const { toolbar, toolKeyMap: _toolKeyMap } = _buildToolbar({ currentTool: state.tool, onClearSelection: (which) => { if (which === 'lasso') { state.lassoPoints = []; state.lassoActive = false; composite(); } else if (which === 'wand') { _wandClear(); } _syncToolClearIndicators(); }, onSelectTool: (toolId, _btn, toolbarEl) => { // Leaving transform mode without confirm? Treat tool change as confirm. if (state.transformActive && toolId !== 'transform') _confirmTransform(); // Re-clicking the active tool toggles the mobile control sheet — // lets the user swipe-down to dismiss, then tap the tool again to // bring it back. On desktop this is a no-op visually since the // controls live in the right panel. const reactivated = state.tool === toolId; state.tool = toolId; const controls = document.getElementById('ge-controls') || document.querySelector('.ge-controls'); if (controls) { if (reactivated) controls.classList.toggle('dismissed'); else controls.classList.remove('dismissed'); } // On mobile, picking a tool that's about to SHOW its controls // panel auto-minimises the layers sheet so the controls aren't // covered. Swiping the layers handle back up restores it. const isMobile = window.innerWidth <= 820; const hasToolControls = ['brush', 'eraser', 'clone', 'inpaint'].includes(toolId); const controlsVisible = controls && !controls.classList.contains('dismissed'); if (isMobile && hasToolControls && controlsVisible) { const rp = document.querySelector('.ge-right-panel'); if (rp) { rp.classList.remove('expanded'); rp.classList.add('minimized'); } } toolbarEl.querySelectorAll('.ge-tool-btn').forEach(b => b.classList.toggle('active', b.dataset.tool === state.tool)); // Activate drag-resize handles when picking the Resize tool if (toolId === 'transform' && !state.transformActive) _startTransform(); // Show/hide brush controls. Brush, Eraser AND Clone use the // shared size+color row; Inpaint has its OWN size slider. const brushControls = document.getElementById('ge-brush-controls'); const needsBrush = ['brush', 'eraser', 'clone'].includes(toolId); if (brushControls) brushControls.style.display = needsBrush ? '' : 'none'; // Eraser and Clone don't care about color — hide the color row. const colorRow = document.getElementById('ge-color-row'); if (colorRow) colorRow.style.display = (toolId === 'eraser' || toolId === 'clone') ? 'none' : ''; const colorLabel = colorRow?.querySelector('label'); if (colorLabel) colorLabel.textContent = 'Color'; const sizeLabelEl = brushControls?.querySelector('.ge-size-slider')?.parentElement?.querySelector('label'); if (sizeLabelEl && sizeLabelEl.firstChild && sizeLabelEl.firstChild.nodeType === Node.TEXT_NODE) { sizeLabelEl.firstChild.nodeValue = (toolId === 'eraser') ? 'Brush Size ' : 'Size '; } // Per-tool stroke-modifier sections (opacity / flow / softness). const brushSection = document.getElementById('ge-brush-section'); if (brushSection) brushSection.style.display = toolId === 'brush' ? '' : 'none'; const cloneSection = document.getElementById('ge-clone-section'); if (cloneSection) cloneSection.style.display = toolId === 'clone' ? '' : 'none'; const lassoSection = document.getElementById('ge-lasso-section'); if (lassoSection) lassoSection.style.display = state.tool === 'lasso' ? '' : 'none'; const wandSection = document.getElementById('ge-wand-section'); if (wandSection) wandSection.style.display = state.tool === 'wand' ? '' : 'none'; const inpaintSection = document.getElementById('ge-inpaint-section'); if (inpaintSection) { if (state.tool === 'inpaint') { if (reactivated) inpaintSection.classList.toggle('dismissed'); else inpaintSection.classList.remove('dismissed'); inpaintSection.style.display = inpaintSection.classList.contains('dismissed') ? 'none' : ''; const inpaintOpen = !inpaintSection.classList.contains('dismissed'); controls?.classList.toggle('ge-inpaint-popover-host', inpaintOpen && window.innerWidth > 820); if (inpaintOpen) _positionInpaintPanel(_btn); } else { controls?.classList.remove('ge-inpaint-popover-host'); inpaintSection.classList.remove('dismissed'); inpaintSection.style.display = 'none'; } } // Entering inpaint mode: make sure the active parent layer has a // mask sub-layer, and point the global mask plumbing at it. Also // force the global mask-visibility flag back on — a previous // Generate cleared it, but on re-entry the user expects to see // their mask again. if (state.tool === 'inpaint') { // First inpaint entry per session: bump the brush size to the // mask-friendly default (other tools keep their own size). if (!state.inpaintBrushInitialised) { state.brushSize = _INPAINT_DEFAULT_BRUSH; state.inpaintBrushInitialised = true; const inp = document.getElementById('ge-inpaint-brush-slider'); if (inp) { const pos = Math.round(Math.log(Math.max(1, state.brushSize)) / Math.log(800) * 1000); inp.value = String(pos); const lbl = document.getElementById('ge-inpaint-brush-label'); if (lbl) lbl.textContent = `${state.brushSize}px`; } } // If the active parent already carries one or more masks, reuse // the most-recent one instead of creating a new "Mask 2" / // "Mask 3" every time the user re-enters inpaint. const parent = _activeParentLayer(); if (parent && parent.masks && parent.masks.length) { if (!parent.activeMaskId) { parent.activeMaskId = parent.masks[parent.masks.length - 1].id; } const m = _getActiveMaskLayer(); if (m) { state.maskCanvas = m.canvas; state.maskCtx = m.ctx; } } else { const mask = _ensureActiveMaskLayer(); if (mask) { state.maskCanvas = mask.canvas; state.maskCtx = mask.ctx; // Reflect the freshly-created mask sub-row in the panel. _renderLayerPanel(); } } if (!state.maskVisible) { state.maskVisible = true; const maskBtn = document.getElementById('ge-mask-vis'); if (maskBtn) { maskBtn.innerHTML = ''; maskBtn.title = 'Hide mask'; maskBtn.classList.add('visible'); } } } const eraserSection = document.getElementById('ge-eraser-section'); if (eraserSection) eraserSection.style.display = state.tool === 'eraser' ? '' : 'none'; const sharpenSection = document.getElementById('ge-sharpen-section'); if (sharpenSection) sharpenSection.style.display = state.tool === 'sharpen' ? '' : 'none'; const rembgSection = document.getElementById('ge-rembg-section'); if (rembgSection) { const show = state.tool === 'rembg'; rembgSection.style.display = show ? '' : 'none'; if (show) _checkRembgInstalled(); } const importSection = document.getElementById('ge-import-section'); if (importSection) importSection.style.display = state.tool === 'import' ? '' : 'none'; const harmonizeSection = document.getElementById('ge-harmonize-section'); if (harmonizeSection) harmonizeSection.style.display = state.tool === 'harmonize' ? '' : 'none'; const upscaleSection = document.getElementById('ge-upscale-section'); if (upscaleSection) upscaleSection.style.display = state.tool === 'upscale' ? '' : 'none'; const styleSection = document.getElementById('ge-style-section'); if (styleSection) styleSection.style.display = state.tool === 'style' ? '' : 'none'; // Toggle cursor — hide native cursor for tools that draw via our // own circle overlay (brush/eraser/inpaint/lasso); for other tools // pick a cursor that matches the tool's affordance. const useCircle = state.tool === 'brush' || state.tool === 'eraser' || state.tool === 'inpaint' || state.tool === 'lasso' || state.tool === 'clone'; if (state.mainCanvas) { // Custom SVG cursor for the Move tool — white fill with black // stroke so it reads on both light and dark canvases. const moveCursorSvg = `data:image/svg+xml;utf8,${encodeURIComponent( '' )}`; let cursor = 'crosshair'; if (state.tool === 'move') cursor = `url("${moveCursorSvg}") 12 12, move`; else if (state.tool === 'transform') cursor = 'default'; else if (useCircle) cursor = 'crosshair'; state.mainCanvas.style.cursor = cursor; } if (state.cursorEl) state.cursorEl.style.display = useCircle ? '' : 'none'; composite(); }, }); // Top bar — static DOM lives in editor/build/topbar.js; all click // handlers below wire to the IDs baked into the markup. const topBar = _buildTopbar(); container.appendChild(topBar); // Editor body (toolbar + canvas + panel) const editorBody = document.createElement('div'); editorBody.className = 'ge-editor-body'; editorBody.appendChild(toolbar); // Canvas area (center) const canvasArea = document.createElement('div'); canvasArea.className = 'ge-canvas-area'; state.mainCanvas = document.createElement('canvas'); state.mainCanvas.className = 'ge-main-canvas'; state.mainCtx = state.mainCanvas.getContext('2d'); // Initial cursor matches the default tool (move) so the user sees the // four-arrow icon as soon as the editor opens. Uses a filled white // arrow with black stroke for readability on light AND dark canvases. if (state.tool === 'move') { const svg = `data:image/svg+xml;utf8,${encodeURIComponent( '' )}`; state.mainCanvas.style.cursor = `url("${svg}") 12 12, move`; } else { state.mainCanvas.style.cursor = 'crosshair'; } canvasArea.appendChild(state.mainCanvas); // Transform overlay — separate canvas positioned over the main canvas // with extra margin so the resize/rotation handles can render OUTSIDE // the image bounds. Sized + zoomed in sync with the main canvas via // _syncTransformOverlay(). The overlay is pointer-events:none so it // doesn't intercept clicks; hit-testing still happens in image coords. state.transformOverlay = document.createElement('canvas'); state.transformOverlay.className = 'ge-transform-overlay'; state.transformOverlayCtx = state.transformOverlay.getContext('2d'); canvasArea.appendChild(state.transformOverlay); // Keep the transform handles glued to the photo while the canvas-area // scrolls (the overlay is anchored to the canvas's live rect, so a // re-draw on scroll re-reads its position). canvasArea.addEventListener('scroll', () => { if (state.transformActive) _drawTransformHandles(); }, { passive: true }); // Canvas events (mouse + touch + pinch-zoom + pan) — full // implementation in editor/canvas-events.js. wireCanvasEvents({ canvasArea, beginDraw: _beginDraw, continueDraw: _continueDraw, endDraw: _endDraw, updateBrushCursor: (e) => _updateBrushCursor(e), syncZoomControls: () => _syncZoomControls(), }); editorBody.appendChild(canvasArea); // Right panel (controls + layers + resize handle) — full // implementation in editor/build/right-panel.js. const { rightPanel, controls, layerPanel } = buildRightPanel({ controlsHTML: _controlsHTML, layerPanelHTML: _layerPanelHTML, }); editorBody.appendChild(rightPanel); container.appendChild(editorBody); _wireInpaintPopoverWindow(); // Slider UX (expand-while-using, floating bubble, click-to-type) — // full implementation in editor/slider-ux.js. wireSliderUx({ registerDocClickAway: _registerDocClickAway }); // Shortcuts cheatsheet popover — full implementation in // editor/shortcuts-popover.js. (Dead `_makeShortcutsDraggable` // helper for the old centered-modal version was dropped.) const _shortcutsPopover = createShortcutsPopover(); const _toggleShortcuts = _shortcutsPopover.toggleShortcuts; document.getElementById('ge-shortcuts-btn')?.addEventListener('click', () => _toggleShortcuts()); // Dismiss-listeners for the inpaint popup are attached lazily by // _showInpaintPrompt() and removed by _dismissInpaintPrompt(), so the // active-edit path doesn't pay for them on every event. (Listening on // mousedown/wheel/touchstart/input/change at capture phase, even with // a fast `closest()` check, added up to noticeable lag during heavy // brush use.) // Wire up controls controls.querySelector('.ge-color-picker').addEventListener('input', (e) => { state.color = e.target.value; }); // Swap the editor's native color inputs for the in-house HSV picker // we built in the theme system — eyedropper, suggestions, recents, // no native OS dialog. Each picker keeps its existing `input` event // wiring so callers just keep reading `e.target.value`. controls.querySelectorAll('.ge-color-picker').forEach(attachColorPicker); controls.querySelectorAll('.ge-color-picker').forEach(el => { // Set the initial swatch background so it reflects the starting value. el.value = el.value; }); // Hide brush controls initially (default tool is Move) const initBrushCtrl = document.getElementById('ge-brush-controls'); if (initBrushCtrl) initBrushCtrl.style.display = 'none'; // Brush-size slider is exponential — slider position 0..1000 maps to // brush size 1..800 via Math.pow(800, pos/1000). This gives fine // control at small sizes (where precision matters most) and bigger // jumps at the high end (where +/-50 px is barely visible anyway). // We expose two sliders (global brush-controls + inpaint section) and // keep them in sync via _brushSizeSync. function _brushSizeSync(source) { const globalLabel = controls.querySelector('.ge-size-label'); const globalInput = controls.querySelector('.ge-size-slider'); const inpaintLabel = document.getElementById('ge-inpaint-brush-label'); const inpaintInput = document.getElementById('ge-inpaint-brush-slider'); const pos = Math.round(Math.log(Math.max(1, state.brushSize)) / Math.log(800) * 1000); if (globalLabel) globalLabel.textContent = state.brushSize + 'px'; if (inpaintLabel) inpaintLabel.textContent = state.brushSize + 'px'; if (globalInput && source !== globalInput) globalInput.value = String(pos); if (inpaintInput && source !== inpaintInput) inpaintInput.value = String(pos); } function _wireBrushSlider(el) { if (!el) return; el.addEventListener('input', (e) => { const pos = parseInt(e.target.value, 10); state.brushSize = Math.max(1, Math.round(Math.pow(800, pos / 1000))); _brushSizeSync(e.target); }); } _wireBrushSlider(controls.querySelector('.ge-size-slider')); _wireBrushSlider(document.getElementById('ge-inpaint-brush-slider')); // Topbar wiring (undo/redo/history, Save dropdown, zoom buttons, // Export/Download/Project, Edge popup, cross-dropdown coordination) — // full implementation in editor/wire-topbar.js. wireTopbar({ undo, redo, toggleHistoryPanel: _toggleHistoryPanel, fitZoom: () => _fitZoom(), applyZoom: () => _applyZoom(), exportToGallery, downloadPNG, saveProject: () => _saveProject(), loadProjectPrompt: () => _loadProjectPrompt(), activeLayer, saveState: _saveState, applyEdgeFeather: _applyEdgeFeather, composite, registerDocClickAway: _registerDocClickAway, uiModule, }); // Fill — visible only when a selection or active mask exists. Pours // the current colour into whichever target is live: // - active mask sub-layer → fills the layer's pixels clipped by // the mask (uses mask alpha as a stencil). // - lasso closed → fills the polygon area on the active layer. // - wand selection → fills the wand mask area on the active layer. // Fill — invoked from the Image menu's "Fill selection / mask" item. // Pours the current colour into whichever target is live: // - active mask sub-layer → fills the layer's pixels clipped by // the mask (uses mask alpha as a stencil). // - lasso closed → fills the polygon area on the active layer. // - wand selection → fills the wand mask area on the active layer. function _doFillSelection() { const layer = activeLayer(); if (!layer || layer.locked) return; const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 }; const w = layer.canvas.width; const h = layer.canvas.height; const mask = _getActiveMaskLayer(); const hasLasso = state.lassoPoints.length >= 3 && !state.lassoActive; const stencil = document.createElement('canvas'); stencil.width = w; stencil.height = h; const sctx = stencil.getContext('2d'); if (mask) { sctx.drawImage(mask.canvas, -off.x, -off.y); } else if (hasLasso) { const feather = parseInt(document.getElementById('ge-lasso-feather')?.value || '0'); const grow = parseInt(document.getElementById('ge-lasso-grow')?.value || '0'); sctx.drawImage(_buildLassoMask(w, h, off.x, off.y, feather, grow), 0, 0); } else if (state.wandMask) { sctx.drawImage(state.wandMask, 0, 0); } else { return; } _saveState('Fill selection'); sctx.globalCompositeOperation = 'source-in'; sctx.fillStyle = state.color; sctx.fillRect(0, 0, w, h); sctx.globalCompositeOperation = 'source-over'; layer.ctx.drawImage(stencil, 0, 0); composite(); _renderLayerPanel(); if (uiModule) uiModule.showToast('Filled'); } // AI model selectors (Gen, Inpaint, per-tool) — full // implementation in editor/ai-models.js. wireAIModelSelectors({ container, apiBase: API_BASE, openCookbookForImg2img: () => _openCookbookForImg2img(), }); document.getElementById('ge-save').addEventListener('click', async () => { if (!state.imageId) { await exportToGallery(); return; } const endBusy = _saveButtonBusy('Saving…'); let blob = null; let savedOk = false; const t0 = performance.now(); try { // Encode directly from the flattened canvas via toBlob() to avoid // the dataURL round-trip (which doubles peak memory). Pick JPEG for // photo sources so 24MP uploads don't balloon to 200MB+ PNG — // critical when the editor is accessed over Tailscale Funnel etc. const flat = flatten(); const ext = (state.originalExt || 'png').toLowerCase(); const isJpeg = ext === 'jpg' || ext === 'jpeg'; const mime = isJpeg ? 'image/jpeg' : 'image/png'; const quality = isJpeg ? 0.92 : undefined; blob = await new Promise((resolve, reject) => { flat.toBlob(b => b ? resolve(b) : reject(new Error('Canvas encode failed')), mime, quality); }); const fd = new FormData(); fd.append('image', blob, `edited.${isJpeg ? 'jpg' : 'png'}`); const resp = await fetch(`${API_BASE}/api/gallery/${state.imageId}/replace`, { method: 'POST', credentials: 'same-origin', body: fd, }); if (!resp.ok) { let detail = ''; try { const j = await resp.json(); detail = j.detail || j.error || ''; } catch {} throw new Error(`HTTP ${resp.status}${detail ? `: ${detail}` : ''}`); } const totalMs = Math.round(performance.now() - t0); if (uiModule) uiModule.showToast(`Saved over original (${(blob.size / 1024 / 1024).toFixed(1)}MB · ${(totalMs / 1000).toFixed(1)}s)`, 4000); window.dispatchEvent(new CustomEvent('gallery-refresh')); savedOk = true; } catch (e) { console.error('[save] error:', e); const sizeMB = blob ? ` (${(blob.size / 1024 / 1024).toFixed(1)}MB)` : ''; let msg = e?.message || 'unknown'; if (e?.name === 'TypeError' || /fetch|network|load failed/i.test(msg)) { msg = `network dropped${sizeMB} — try "Save as copy" or check connection`; } else { msg += sizeMB; } if (uiModule) uiModule.showToast('Failed to save: ' + msg, 6000); } finally { endBusy(); if (savedOk) _flashSaveButtonOk(); } }); // Topbar overflow + canvas-size badge — full implementation in // editor/wire-topbar-overflow.js. wireTopbarOverflow({ container, registerDocClickAway: _registerDocClickAway }); // Topbar dropdown menus (Image, Filter, Resize) + the resize-canvas // helpers — full implementation in editor/wire-topbar-menus.js. The // returned `_resizeCustomPrompt` is consumed by the keyboard // shortcuts module (Ctrl+Shift+T). const { resizeCustomPrompt: _resizeCustomPrompt } = wireTopbarMenus({ closeOtherTopbarMenus: _closeOtherTopbarMenus, registerDocClickAway: _registerDocClickAway, saveState: _saveState, composite, fitZoom: () => _fitZoom(), promptCanvasSize: (opts) => _promptCanvasSize(opts), doFillSelection: () => _doFillSelection(), rotateAllLayers: (deg) => _rotateAllLayers(deg), flipAllLayers: (axis) => _flipAllLayers(axis), applyGaussianBlur: () => _applyGaussianBlur(), applyZoomBlur: () => _applyZoomBlur(), uiModule, }); // Inpaint side-panel controls (Feather/Strength previews, post-gen // edge tuner, mask vis/invert/clear, paint-erase toggle, mask tint // pickers) — full implementation in editor/wire-inpaint-controls.js. wireInpaintControls({ composite, applyInpaintFeather: _applyInpaintFeather, syncToolClearIndicators: () => _syncToolClearIndicators(), attachColorPicker, uiModule, }); // AI inpaint (Generate / Remove / Outpaint) — full implementation // in editor/ai-inpaint.js. wireInpaintButtons({ buildMergedMaskCanvas: () => _buildMergedMaskCanvas(), dilateMask: _dilateMask, applyInpaintFeather: _applyInpaintFeather, getSelectedAIEndpoint: (type) => _getSelectedAIEndpoint(type), ensureActiveMaskLayer: () => _ensureActiveMaskLayer(), saveState: _saveState, createLayer, composite, renderLayerPanel: () => _renderLayerPanel(), spinnerModule, uiModule, }); // Per-tool Opacity / Flow / Softness sliders (Eraser / Brush / // Clone) — full implementation in editor/stroke-tool-sliders.js. wireStrokeToolSliders(); // Sharpen + Bg Remove + edge cleanup — full implementation in // editor/ai-rembg.js. Returns the selection-hint-mask builder so // the wand-rembg button (in the wand controls section) can reuse it. const { buildSelectionHintMask: _buildSelectionHintMask } = wireRembgAndSharpen({ applyImageTool: _applyImageTool, openCookbookForDependency: (pkg) => _openCookbookForDependency(pkg), composite, renderLayerPanel: () => _renderLayerPanel(), uiModule, }); // Image import (topbar / panel File / Clipboard / Gallery picker) — // full implementation in editor/wire-import.js. Returns the shared // handleImportedImage sink so drag-drop wires through the same path. const { handleImportedImage: _handleImportedImage } = wireImport({ container, saveState: _saveState, createLayer, composite, renderLayerPanel: () => _renderLayerPanel(), uiModule, }); // Harmonize / Canvas Upscale / AI Upscale / Style Transfer + // Add-Empty-Layer — full implementation in editor/ai-tools-misc.js. const { addEmptyLayer: _addEmptyLayer } = wireAIToolsMisc({ apiBase: API_BASE, buildLayerBodyMask: _buildLayerBodyMask, buildSeamMask: _buildSeamMask, applyImageTool: _applyImageTool, flatten, saveState: _saveState, fitZoom: () => _fitZoom(), composite, createLayer, renderLayerPanel: () => _renderLayerPanel(), spinnerModule, uiModule, }); // (Merge dropdown removed — Merge Down / Merge All / Flatten Copy // are now three inline icon buttons in the layers header next to // + Add. Their individual click handlers below already bind by id.) // Lasso + Magic Wand panel controls — full implementation in // editor/wire-selection-controls.js. wireSelectionControls({ composite, invertSelection: _invertSelection, lassoDeleteSelection: _lassoDeleteSelection, lassoCopyToLayer: _lassoCopyToLayer, lassoToMask: _lassoToMask, runMagicWand: (x, y, mode, opts) => _runMagicWand(x, y, mode, opts), wandClear: _wandClear, wandDeleteSelection: _wandDeleteSelection, wandCopyToNewLayer: _wandCopyToNewLayer, wandToMask: _wandToMask, buildSelectionHintMask: _buildSelectionHintMask, applyImageTool: _applyImageTool, uiModule, }); // Merge / Flatten buttons (layer-panel footer) — full // implementation in editor/wire-merge-buttons.js. wireMergeButtons({ saveState: _saveState, createLayer, renderLayerPanel: () => _renderLayerPanel(), composite, uiModule, }); // Capture-phase Escape interceptor — runs BEFORE any bubble-phase // handler (gallery, keyboard-shortcuts module, etc.) so cancelling a // crop / lasso / transform inside the editor can't ever bubble up and // accidentally close the gallery modal. document.addEventListener('keydown', (e) => { if (!state.editorOpen) return; // Esc on the shortcuts overlay closes it; takes priority over the // other modal cancels so the cheatsheet feels responsive AND so the // gallery's own Esc handler doesn't fire and close gallery instead. if (e.key === 'Escape' && _shortcutsPopover.isOpen()) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); _toggleShortcuts(false); return; } // Enter accepts an active crop (same as the Apply button). Skip when // typing in a field — the crop W/H inputs handle their own Enter, and // we don't want to hijack Enter elsewhere. if (e.key === 'Enter' && state.cropRect && !state.cropping && !state.cropMoving) { const t = e.target; if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return; e.preventDefault(); e.stopPropagation(); _applyCrop(); return; } if (e.key !== 'Escape') return; // Escape is disabled inside Gallery Edit. It must not close the // editor, close Gallery, or cancel active editor state. e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); }, true); // Keyboard shortcuts — full implementation in // editor/keyboard-shortcuts.js. wireKeyboardShortcuts({ toolbar, toolKeyMap: _toolKeyMap, composite, saveState: _saveState, undo, redo, toggleShortcuts: _toggleShortcuts, confirmTransform: _confirmTransform, cancelTransform: _cancelTransform, startTransform: _startTransform, resizeCustomPrompt: _resizeCustomPrompt, addEmptyLayer: _addEmptyLayer, brushSizeSync: _brushSizeSync, invertSelection: _invertSelection, wandDeleteSelection: _wandDeleteSelection, wandCopyToNewLayer: _wandCopyToNewLayer, lassoDeleteSelection: _lassoDeleteSelection, lassoCopyToLayer: _lassoCopyToLayer, lassoToMask: _lassoToMask, buildLassoMask: _buildLassoMask, drawLassoOverlay: _drawLassoOverlay, activeLayer, uiModule, }); container.setAttribute('tabindex', '0'); // Paste + drag-and-drop image import — full implementation in // editor/clipboard-and-drop.js. wireClipboardAndDrop({ container, saveState: _saveState, createLayer, renderLayerPanel: () => _renderLayerPanel(), composite, handleImportedImage: (img) => _handleImportedImage(img), uiModule, }); } // ── Layer panel rendering ── // Layer-panel renderer — implementation in editor/layer-panel.js. // Wrap to the legacy name so the dozens of `_renderLayerPanel()` call // sites scattered across the file keep working unchanged. const _layerPanelRenderer = createLayerPanelRenderer({ composite, saveState: _saveState, showLayerThumb: (row, layer) => _showLayerThumb(row, layer), hideLayerThumb: () => _hideLayerThumb(), loadLayerAlphaAsSelection: (layer) => _loadLayerAlphaAsSelection(layer), openFxPopup: (layer, anchor) => _openFxPopup(layer, anchor), editAdjLayer: (layer, adj, anchor) => _editAdjLayer(layer, adj, anchor), createLayer, lassoToMask: () => _lassoToMask(), wandToMask: () => _wandToMask(), getActiveMaskLayer: () => _getActiveMaskLayer(), syncFxPanelToActiveLayerIfPresent: () => _syncFxPanelToActiveLayerIfPresent(), dragSortModule, uiModule, }); function _renderLayerPanel() { return _layerPanelRenderer.render(); } function _revealLayerPanel() { requestAnimationFrame(() => { const panel = state.container?.querySelector?.('.ge-right-panel') || document.querySelector('.ge-right-panel'); if (!panel) return; panel.classList.remove('minimized'); panel.classList.add('expanded'); }); } // ── Flatten / Export ── function flatten() { const out = document.createElement('canvas'); out.width = state.imgWidth; out.height = state.imgHeight; const ctx = out.getContext('2d'); for (const layer of state.layers) { if (!layer.visible) continue; ctx.globalAlpha = layer.opacity; const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 }; ctx.drawImage(layer.canvas, off.x, off.y); } ctx.globalAlpha = 1; return out; } // Build the union of all "foreground" visible-layer alphas (binary). // "Background" = the BOTTOMMOST visible layer (Harmonize's colour-match // reference). Everything visible ABOVE it = foreground that goes into // the body mask. Independent of the `isBase` flag, so reordering layers // (or hiding the original photo after a bg-remove) doesn't break the // semantics. // Harmonize-pipeline mask builders live in editor/harmonize-masks.js. // Thin wrappers translate module state into the pure helpers. function _harmonizeLayerList() { return state.layers.map(l => ({ visible: l.visible, id: l.id, canvas: l.canvas, offset: state.layerOffsets.get(l.id) || { x: 0, y: 0 }, })); } function _buildLayerUnionAlpha() { return _layerUnionAlphaImpl(state.imgWidth, state.imgHeight, _harmonizeLayerList()); } function _buildSeamMask(featherPx = 12) { return _seamMaskImpl(state.imgWidth, state.imgHeight, _harmonizeLayerList(), featherPx); } function _buildLayerBodyMask(featherPx = 12) { return _layerBodyMaskImpl(state.imgWidth, state.imgHeight, _harmonizeLayerList(), featherPx); } export function exportPNG() { return flatten().toDataURL('image/png'); } // Briefly turn the Save button green with a checkmark so the user can't // miss a successful save (the toast alone is easy to miss on remote // connections where focus drifts during the upload). function _flashSaveButtonOk() { const btn = document.getElementById('ge-save-menu-btn'); if (!btn) return; const origHTML = btn.innerHTML; const origBg = btn.style.background; btn.style.background = '#3aa75a'; btn.style.color = '#fff'; btn.innerHTML = 'Saved'; setTimeout(() => { btn.style.background = origBg; btn.style.color = ''; btn.innerHTML = origHTML; }, 1800); } // Show whirlpool + label on the visible "Save ▾" topbar button while a // save operation runs. Returns a function to call when done (or in finally). function _saveButtonBusy(label) { const btn = document.getElementById('ge-save-menu-btn'); if (!btn) return () => {}; const origHTML = btn.innerHTML; const origWidth = btn.offsetWidth; btn.disabled = true; btn.style.minWidth = origWidth + 'px'; btn.innerHTML = ''; let sp = null; try { sp = spinnerModule.create('', 'clean', 'whirlpool'); btn.appendChild(sp.createElement()); const txt = document.createElement('span'); txt.className = 'ge-btn-busy-label'; txt.textContent = label || 'Saving…'; btn.appendChild(txt); sp.start(); } catch { btn.textContent = label || 'Saving…'; } return () => { try { sp && sp.stop && sp.stop(); } catch {} btn.disabled = false; btn.innerHTML = origHTML; btn.style.minWidth = ''; }; } export async function exportToGallery() { const endBusy = _saveButtonBusy('Saving copy…'); let blob = null; let savedOk = false; const t0 = performance.now(); try { // toBlob() avoids the 2x peak memory of dataURL → fetch → blob. JPEG // re-encode for camera photos keeps uploads small enough to make it // through remote tunnels. const flat = flatten(); const ext = (state.originalExt || 'png').toLowerCase(); const isJpeg = ext === 'jpg' || ext === 'jpeg'; const mime = isJpeg ? 'image/jpeg' : 'image/png'; const quality = isJpeg ? 0.92 : undefined; blob = await new Promise((resolve, reject) => { flat.toBlob(b => b ? resolve(b) : reject(new Error('Canvas encode failed')), mime, quality); }); const formData = new FormData(); formData.append('file', blob, `edited.${isJpeg ? 'jpg' : 'png'}`); const saveRes = await fetch(`${API_BASE}/api/gallery/upload`, { method: 'POST', credentials: 'same-origin', body: formData, }); if (!saveRes.ok) { const errBody = await saveRes.text().catch(() => ''); throw new Error(`HTTP ${saveRes.status}: ${errBody.substring(0, 120)}`); } const totalMs = Math.round(performance.now() - t0); window.dispatchEvent(new CustomEvent('gallery-refresh')); if (uiModule) uiModule.showToast(`Saved copy to gallery (${(blob.size / 1024 / 1024).toFixed(1)}MB · ${(totalMs / 1000).toFixed(1)}s)`, 4000); savedOk = true; if (state.draftId) { _clearDraftServer(state.draftId); state.draftId = null; } } catch (e) { console.error('[save-as-copy] error:', e); const sizeMB = blob ? ` (${(blob.size / 1024 / 1024).toFixed(1)}MB)` : ''; let msg = e?.message || 'unknown'; if (e?.name === 'TypeError' || /fetch|network|load failed/i.test(msg)) { msg = `network dropped${sizeMB} — check connection`; } else { msg += sizeMB; } if (uiModule) uiModule.showToast('Save failed: ' + msg, 6000); } finally { endBusy(); if (savedOk) _flashSaveButtonOk(); } } // Open the Cookbook modal scoped to img2img-capable models so the user // can serve one in a few clicks. Falls back to plain Cookbook if the // filter hook isn't available. // Open Cookbook on its Dependencies tab and highlight a specific // package row. Used for "rembg not installed" → install path. function _openCookbookForDependency(pkgName) { // Use cookbookModule.open({ tab: 'Dependencies' }) so the intent is // honored after Cookbook's async render. The old path clicked the // sidebar button + polled for the modal, but Cookbook's _renderRecipes // runs AFTER an awaited _syncFromServer, so depsTab.click() often // raced and the user landed on Download. const cookbook = window.cookbookModule; if (!cookbook || typeof cookbook.open !== 'function') { // Fall back to the old click-then-poll path if the module isn't // on window for some reason. const btn = document.getElementById('tool-cookbook-btn'); if (btn) btn.click(); else if (uiModule) uiModule.showToast(`Open Cookbook to install ${pkgName}`, 6000); return; } cookbook.open({ tab: 'Dependencies' }); // Now wait for the Dependencies group to render, switch the server // selector to Local, and highlight the package row. const cb = document.getElementById('cookbook-modal'); if (cb) cb.style.zIndex = 260; const tryServer = (attempt = 0) => { const serverSel = document.getElementById('hwfit-deps-server'); if (!serverSel) { if (attempt < 25) return setTimeout(() => tryServer(attempt + 1), 80); return; } if (serverSel.value !== 'local') { serverSel.value = 'local'; serverSel.dispatchEvent(new Event('change', { bubbles: true })); } }; tryServer(); const tryHighlight = (a2 = 0) => { const rows = document.querySelectorAll('[data-pkg-name]'); if (!rows.length) { if (a2 < 40) return setTimeout(() => tryHighlight(a2 + 1), 100); return; } const row = Array.from(rows).find(r => (r.dataset.pkgName || '').toLowerCase() === pkgName.toLowerCase()); if (row) { row.scrollIntoView({ block: 'center' }); row.classList.add('cookbook-pkg-flash'); setTimeout(() => row.classList.remove('cookbook-pkg-flash'), 2000); } }; tryHighlight(); } // Async check whether `rembg` is installed on the Odysseus server. // Toggles the "install rembg" notice + the Bg Remove run button. The // `/api/cookbook/packages` endpoint is cheap (importlib calls only). async function _checkRembgInstalled() { const noticeEl = document.getElementById('ge-rembg-dep-missing'); const runRow = document.getElementById('ge-rembg-run-row'); if (!noticeEl || !runRow) return; // Use cached result if we already checked this editor session. if (state.rembgInstalledCache !== null) { noticeEl.style.display = state.rembgInstalledCache ? 'none' : ''; runRow.style.display = state.rembgInstalledCache ? '' : 'none'; return; } try { const r = await fetch('/api/cookbook/packages', { credentials: 'same-origin' }); if (!r.ok) throw new Error('packages query failed'); const data = await r.json(); const pkg = (data.packages || []).find(p => (p.name || '').toLowerCase() === 'rembg'); state.rembgInstalledCache = pkg ? !!pkg.installed : null; } catch (e) { state.rembgInstalledCache = null; // unknown — fall back to silent } if (state.rembgInstalledCache === false) { noticeEl.style.display = ''; runRow.style.display = 'none'; } else { noticeEl.style.display = 'none'; runRow.style.display = ''; } } function _openCookbookForImg2img() { // Try multiple openers in order — the sidebar button may be hidden on // mobile so we fall back to the rail button, then to modalManager. let opened = false; const btn = document.getElementById('tool-cookbook-btn'); const railBtn = document.getElementById('rail-cookbook'); if (btn && btn.offsetParent !== null) { btn.click(); opened = true; } else if (railBtn) { railBtn.click(); opened = true; } else { try { modalManager.restore('cookbook-modal'); opened = true; } catch {} } if (opened) { // Two-stage navigation: 1) wait for modal mount, 2) click Serve tab, // 3) after the serve tag chips render, click the "image" one. const tryServe = (attempt = 0) => { const cb = document.getElementById('cookbook-modal'); const serveTab = cb ? cb.querySelector('.cookbook-tab[data-backend="Serve"]') : null; // Retry until BOTH the modal mounts AND its tab bar has rendered. // Cookbook builds its body html after the modal opens, so we need // to wait a bit longer than just "modal exists". if (!cb || !serveTab) { if (attempt < 40) return setTimeout(() => tryServe(attempt + 1), 80); return; } cb.style.zIndex = 260; serveTab.click(); // Now wait for the serve-tags container to populate (it lazy-loads // after the cached-models fetch resolves) and click the image chip. const tryImageFilter = (a2 = 0) => { const tags = document.getElementById('serve-tags'); if (!tags || !tags.querySelector('.memory-cat-chip')) { if (a2 < 20) return setTimeout(() => tryImageFilter(a2 + 1), 100); return; } const imgChip = Array.from(tags.querySelectorAll('.memory-cat-chip')) .find(c => /^image$/i.test(c.dataset.serveTag || '') || /image/i.test(c.textContent || '')); if (imgChip) imgChip.click(); }; tryImageFilter(); }; tryServe(); return; } if (uiModule) uiModule.showToast('Open Cookbook from the sidebar to serve an img2img model', 6000); } export function downloadPNG() { const dataUrl = exportPNG(); const a = document.createElement('a'); a.href = dataUrl; a.download = 'edited-image.png'; a.click(); } // Save the entire layered editor state as a JSON project file. Each // layer is encoded as a base64 PNG so transparency / partial alpha // survives the round-trip. Use Load Project to restore. function _saveProject() { if (!state.layers.length) { if (uiModule) uiModule.showToast('Nothing to save'); return; } const project = { v: 1, type: 'odysseus-gallery-editor-project', imgWidth: state.imgWidth, imgHeight: state.imgHeight, activeLayerId: state.activeLayerId, nextLayerId: state.nextLayerId, layers: state.layers.map(l => ({ id: l.id, name: l.name, visible: l.visible, opacity: l.opacity, locked: l.locked, canvasW: l.canvas.width, canvasH: l.canvas.height, offset: { ...(state.layerOffsets.get(l.id) || { x: 0, y: 0 }) }, dataUrl: l.canvas.toDataURL('image/png'), })), }; const json = JSON.stringify(project); const blob = new Blob([json], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'project.geproj.json'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); if (uiModule) uiModule.showToast('Project saved', 3000); } // Open-file picker for Load Project. Restores layers + canvas size. function _loadProjectPrompt() { const inp = document.createElement('input'); inp.type = 'file'; inp.accept = 'application/json,.json'; inp.addEventListener('change', async () => { const file = inp.files && inp.files[0]; if (!file) return; try { const text = await file.text(); const proj = JSON.parse(text); if (proj.type !== 'odysseus-gallery-editor-project') { if (uiModule) uiModule.showToast('Not a project file', 5000); return; } await _restoreDraft(proj); composite(); _renderLayerPanel(); _fitZoom(); if (uiModule) uiModule.showToast('Project loaded', 3000); } catch (e) { if (uiModule) uiModule.showToast('Load failed: ' + (e.message || e), 6000); } }); inp.click(); } // ── Public API ── // Styled in-app prompt for canvas size — replaces the browser's // native prompt() which doesn't follow the app theme. Returns a Promise // resolving to {w, h} on submit, or null on cancel. Optional opts: // title, okLabel, initialW, initialH. function _promptCanvasSize(opts) { opts = opts || {}; const title = opts.title || 'New canvas'; const okLabel = opts.okLabel || 'Create'; const initialW = opts.initialW || 1024; const initialH = opts.initialH || 1024; return new Promise(resolve => { let overlay = document.getElementById('ge-canvas-size-overlay'); if (!overlay) { overlay = document.createElement('div'); overlay.id = 'ge-canvas-size-overlay'; overlay.className = 'modal'; overlay.innerHTML = _canvasSizePromptHTML(); document.body.appendChild(overlay); } overlay.style.display = ''; overlay.classList.remove('hidden'); const wInput = document.getElementById('ge-canvas-prompt-w'); const hInput = document.getElementById('ge-canvas-prompt-h'); const okBtn = document.getElementById('ge-canvas-prompt-ok'); const cancelBtn = document.getElementById('ge-canvas-prompt-cancel'); const titleEl = document.getElementById('ge-canvas-prompt-title'); if (titleEl) titleEl.textContent = title; if (okBtn) okBtn.textContent = okLabel; wInput.value = String(initialW); hInput.value = String(initialH); setTimeout(() => { wInput.focus(); wInput.select(); }, 0); function cleanup(result) { overlay.style.display = 'none'; okBtn.removeEventListener('click', onOk); cancelBtn.removeEventListener('click', onCancel); overlay.removeEventListener('click', onBackdrop); document.removeEventListener('keydown', onKey); resolve(result); } function onOk() { const dims = _parseCanvasSizePrompt(wInput.value, hInput.value, initialW, initialH); if (!dims) { uiModule.showToast('Invalid size'); return; } cleanup(dims); } function onCancel() { cleanup(null); } function onBackdrop(e) { if (e.target === overlay) cleanup(null); } function onKey(e) { if (e.key === 'Enter') { e.preventDefault(); onOk(); } if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cleanup(null); } } okBtn.addEventListener('click', onOk); cancelBtn.addEventListener('click', onCancel); overlay.addEventListener('click', onBackdrop); document.addEventListener('keydown', onKey); }); } function _parseCanvasSizePrompt(widthText, heightText, initialW = 1024, initialH = 1024) { const parseWhole = (value) => { const text = String(value || '').trim(); if (!/^\d+$/.test(text)) return null; const n = Number(text); return Number.isSafeInteger(n) && n >= 1 && n <= 8192 ? n : null; }; const parseRatio = (value) => { const m = String(value || '').trim().match(/^(\d+(?:\.\d+)?)\s*(?:x|×|:|\/)\s*(\d+(?:\.\d+)?)$/i); if (!m) return null; const rw = Number(m[1]); const rh = Number(m[2]); if (!Number.isFinite(rw) || !Number.isFinite(rh) || rw <= 0 || rh <= 0) return null; const w = Math.max(1, Math.min(8192, Math.round(initialW))); const h = Math.max(1, Math.min(8192, Math.round(w * rh / rw))); return { w, h }; }; const ratioDims = parseRatio(widthText) || parseRatio(heightText); if (ratioDims) return ratioDims; const w = parseWhole(widthText); const h = parseWhole(heightText); if (!w || !h) return null; return { w, h }; } // imageUrl=null + presetSize={w,h} → skips the size prompt and creates a // blank canvas at the given dimensions (used by template tiles in the // gallery's Edit-tab landing). `displayName` is optional — when provided, // the Edit tab in the gallery is renamed to "Edit: ". // Shared loading-overlay mount/unmount — used by the image-load path AND // the draft-restore paths so every "we're waiting on something" moment // in the editor surfaces the same whirlpool + label instead of a blank // canvas that looks broken. function _mountEditorLoading(label, dims) { if (!state.container) return; const area = state.container.querySelector('.ge-canvas-area'); _unmountEditorLoading(); // Cover the WHOLE editor (toolbar + canvas + panel), not just the canvas area // — otherwise the toolbar/old content shows above the overlay at the top while // a past project loads, which looks half-rendered. const el = document.createElement('div'); el.className = 'ge-loading-overlay ge-loading-overlay-full'; // Aspect-ratio placeholder so the user sees the shape of the canvas they're // about to land in. Sized to the canvas area but centered in the overlay. let placeholder = null; if (dims && dims.w > 0 && dims.h > 0 && area) { placeholder = document.createElement('div'); placeholder.className = 'ge-canvas-placeholder'; const areaRect = area.getBoundingClientRect(); const maxW = Math.max(0, areaRect.width - 32); const maxH = Math.max(0, areaRect.height - 32); const ratio = dims.w / dims.h; let w = maxW; let h = w / ratio; if (h > maxH) { h = maxH; w = h * ratio; } placeholder.style.width = w + 'px'; placeholder.style.height = h + 'px'; el.appendChild(placeholder); } const inner = document.createElement('div'); inner.className = 'ge-loading-inner'; inner.innerHTML = `${label || 'Loading…'}`; el.appendChild(inner); // Mount on the editor BODY (toolbar + canvas + panel) — it sits below the // gallery's search/select bar, so the cover doesn't bleed up over those. const _mountTarget = state.container.querySelector('.ge-editor-body') || state.container; _mountTarget.appendChild(el); try { const sp = spinnerModule.create('', 'clean', 'whirlpool'); inner.insertBefore(sp.createElement(), inner.firstChild); sp.start(); el._spinner = sp; } catch {} el._placeholder = placeholder; state.editorLoadingEl = el; } function _unmountEditorLoading() { if (!state.editorLoadingEl) return; try { state.editorLoadingEl._spinner?.destroy(); } catch {} try { state.editorLoadingEl._placeholder?.remove(); } catch {} try { state.editorLoadingEl.remove(); } catch {} state.editorLoadingEl = null; } export function openEditor(imageUrl, imageId, presetSize, displayName, draftId) { _setEditTabLabel(displayName || (presetSize ? 'New canvas' : 'Untitled')); state.imageId = imageId || null; // Track original file extension so save-over-original can re-encode in the // same format. JPEG re-encoding cuts upload size 5-10x for camera photos, // which matters over remote tunnels (Tailscale Funnel etc.). try { const m = (imageUrl || '').match(/\.([a-z0-9]{2,5})(?:\?|$)/i); state.originalExt = m ? m[1].toLowerCase() : 'png'; } catch { state.originalExt = 'png'; } state.draftId = draftId || null; state.draftName = displayName || (presetSize ? `New ${presetSize.w}×${presetSize.h}` : 'Untitled'); state.editorOpen = true; state.layers = []; state.undoStack = []; state.redoStack = []; state.layerOffsets.clear(); state.nextLayerId = 1; state.tool = 'move'; state.cropRect = null; state.lassoPoints = []; state.lassoActive = false; window.__galleryEditLive = true; if (state.persistTimer) { clearTimeout(state.persistTimer); state.persistTimer = null; } state.persistDirty = false; state.container = document.getElementById('gallery-editor-container'); if (!state.container) { console.error('[openEditor] #gallery-editor-container not found in DOM — editor cannot open'); if (uiModule) uiModule.showError('Editor container missing'); return; } state.container.style.display = 'flex'; try { _buildEditor(state.container); } catch (e) { console.error('[openEditor] _buildEditor threw:', e); if (uiModule) uiModule.showError('Editor failed to build: ' + (e?.message || 'unknown')); return; } function _initCanvas(w, h) { state.imgWidth = w; state.imgHeight = h; state.mainCanvas.width = w; state.mainCanvas.height = h; state.maskCanvas = document.createElement('canvas'); state.maskCanvas.width = w; state.maskCanvas.height = h; state.maskCtx = state.maskCanvas.getContext('2d'); } if (!imageUrl && draftId) { // Re-open a saved draft by its server-side id — covers the // "Resume" buttons on the Edit-tab landing. _mountEditorLoading('Loading draft…', presetSize || null); // Bail if the user closes the editor while the async load is in // flight — without this guard, the .then() callbacks fire after // closeEditor and re-mount the spinner / draw into a dead canvas, // leaving "stuck" preview artefacts on the next open. return _loadDraftById(draftId) .then(d => { if (!state.editorOpen) return; if (!d) { _unmountEditorLoading(); if (uiModule) uiModule.showToast('Draft not found'); closeEditor(); return; } state.draftId = d.id; state.draftName = d.name || 'Untitled'; _setEditTabLabel(state.draftName); state.imageId = d.source_image_id || null; return _restoreDraft(d).then(() => { if (!state.editorOpen) return; composite(); _renderLayerPanel(); _fitZoom(); const sizeLabel = document.getElementById('ge-canvas-size'); if (sizeLabel) sizeLabel.textContent = `${state.imgWidth}×${state.imgHeight}`; _unmountEditorLoading(); if (uiModule) uiModule.showToast('Resumed draft'); }); }) .catch(err => { if (!state.editorOpen) return; _unmountEditorLoading(); console.warn('[ge] draft load failed', err); if (uiModule) uiModule.showToast('Failed to load draft'); closeEditor(); }); } if (!imageUrl) { // Empty canvas — use preset size if supplied, otherwise show the // styled prompt. Asynchronous: we promise-chain so callers can await // openEditor() and still rely on isEditorOpen() afterwards. const _finishBlank = (w, h) => { _initCanvas(w, h); // White-filled Background so the canvas is visible, then a separate // transparent Edit layer on top — keeps user's work isolated from // the underlying canvas, the standard editor pattern. const bgLayer = createLayer('Background', w, h); bgLayer.ctx.fillStyle = '#ffffff'; bgLayer.ctx.fillRect(0, 0, w, h); const editLayer = createLayer('Edit', w, h); state.layers.push(bgLayer); state.layers.push(editLayer); state.activeLayerId = editLayer.id; composite(); _renderLayerPanel(); _fitZoom(); // First persist creates the server-side row (blank-canvas drafts). _schedulePersist(); }; if (presetSize && presetSize.w > 0 && presetSize.h > 0) { _finishBlank(presetSize.w, presetSize.h); return; } return _promptCanvasSize().then(size => { if (!size) { closeEditor(); return; } _finishBlank(size.w, size.h); }); } // Try to restore a previously-persisted draft for this image — that // way closing the gallery / editor mid-edit doesn't lose progress. // (Server-backed: look up by source_image_id.) _mountEditorLoading('Looking up draft…'); _findDraftForImage(imageId).then(_draft => { if (!state.editorOpen) return; if (!_draft) return null; state.draftId = _draft.id; state.draftName = _draft.name || displayName || 'Untitled'; const innerLabel = state.editorLoadingEl?.querySelector('.ge-loading-text'); if (innerLabel) innerLabel.textContent = 'Resuming draft…'; return _restoreDraft(_draft).then(() => { if (!state.editorOpen) return null; // If the draft was broken/empty (0 layers reconstructed), fall // through to loading the source image as a normal edit. Without // this guard the editor would sit empty and the user would be // stuck with no way to recover. if (state.layers.length === 0) { console.warn('[openEditor] draft restored but produced 0 layers — falling back to source image'); return null; } composite(); _renderLayerPanel(); _fitZoom(); const sizeLabel = document.getElementById('ge-canvas-size'); if (sizeLabel) sizeLabel.textContent = `${state.imgWidth}×${state.imgHeight}`; _unmountEditorLoading(); if (uiModule) uiModule.showToast('Resumed previous edit'); return 'restored'; }); }).then(restored => { if (!state.editorOpen) return; if (restored) return; _loadSourceImage(); }).catch(err => { if (!state.editorOpen) return; _unmountEditorLoading(); console.warn('[openEditor] draft lookup failed', err); _loadSourceImage(); }); function _loadSourceImage() { // Loading overlay — whirlpool + "Loading" label while the source image // downloads / decodes. Especially important for multi-MB photos where // the canvas would otherwise sit blank for several seconds with no // feedback. If a draft-lookup overlay is already mounted, reuse it. if (!state.editorLoadingEl) _mountEditorLoading('Loading…'); else { const inner = state.editorLoadingEl.querySelector('.ge-loading-text'); if (inner) inner.textContent = 'Loading…'; } const _removeLoading = () => _unmountEditorLoading(); // Load image — single layer named "Photo" (no extra Edit layer; the // user can add one manually if they want isolated edits). const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => { if (!state.editorOpen) return; _initCanvas(img.naturalWidth, img.naturalHeight); const photoLayer = createLayer('Photo', state.imgWidth, state.imgHeight); photoLayer.ctx.drawImage(img, 0, 0); photoLayer.isBase = true; state.layers.push(photoLayer); state.activeLayerId = photoLayer.id; composite(); _renderLayerPanel(); _fitZoom(); _removeLoading(); _schedulePersist(); }; img.onerror = (e) => { console.error('[_loadSourceImage] onerror — failed to load', imageUrl, e); _removeLoading(); if (uiModule) uiModule.showToast('Failed to load image'); closeEditor(); }; img.src = imageUrl; } } // Update the gallery's Edit tab label to reflect what's currently open. // Pass null to reset to plain "Edit". Only mutates the inner label span // so the SVG icon next to it survives the update. function _setEditTabLabel(name) { const tab = document.getElementById('gallery-editor-tab'); if (!tab) return; const labelEl = tab.querySelector('.gallery-tab-label') || tab; if (!name) { labelEl.textContent = 'Edit'; tab.classList.remove('has-edit'); return; } const trimmed = name.length > 24 ? name.slice(0, 22) + '…' : name; labelEl.textContent = `Edit: ${trimmed}`; tab.classList.add('has-edit'); } export function closeEditor() { const editorMounted = _galleryEditMounted(); if ((state.editorOpen || editorMounted) && !window.__galleryAllowCloseEditor) { try { uiModule.showToast('Close the edit tab first'); } catch {} return false; } // Flush any pending debounced persist + fire one final save so closing // the editor mid-stroke doesn't lose work. The call is fire-and-forget; // the server commit lands shortly after the modal hides. if (state.persistTimer) { clearTimeout(state.persistTimer); state.persistTimer = null; } if (state.layers.length) { try { _persistDraft(); } catch {} } _setEditTabLabel(null); _unmountEditorLoading(); state.editorOpen = false; // Drop every document-level click-away handler registered by this // openEditor invocation. Without this, dropdown closers accumulated // across reopens (six handlers × N opens). while (state.editorDocClickHandlers.length) { const h = state.editorDocClickHandlers.pop(); try { document.removeEventListener('click', h); } catch {} } if (state.cursorEl) { state.cursorEl.remove(); state.cursorEl = null; } // Tear down all floating popups + the dock so closing the editor // doesn't leave stale chips/panels behind on top of the gallery. try { _closeFxMenu(); } catch {} try { _closeAdjPopup(); } catch {} try { _closeHistoryPanel(); } catch {} try { const dock = document.getElementById('ge-fx-dock'); if (dock) dock.remove(); } catch {} try { document.querySelectorAll('.ge-inpaint-popup, .ge-fx-popup, .ge-adj-popup').forEach(el => { if (el._escHandler) { document.removeEventListener('keydown', el._escHandler, true); } // v2 review HIGH-2/3: unregister any modalManager entry left over // from FX-popup / History-panel minimise so _state and _LABELS // don't grow unboundedly across editor opens. if (el._modalId) { try { modalManager.unregister(el._modalId); } catch {} } el.remove(); }); } catch {} // Belt-and-suspenders: scrub any minimized-dock chip + modalManager // entry whose id matches our ephemeral popups (in case the DOM node // was already removed when the user dragged the chip to trash). try { const dock = document.getElementById('minimized-dock'); if (dock) { dock.querySelectorAll('[data-modal-id^="ge-fx-popup-"], [data-modal-id="ge-history-panel-min"]').forEach(c => { const mid = c.dataset.modalId; try { modalManager.unregister(mid); } catch {} c.remove(); }); } } catch {} if (state.container) { state.container.style.display = 'none'; state.container.innerHTML = ''; } state.layers = []; state.undoStack = []; state.redoStack = []; state.layerOffsets.clear(); state.mainCanvas = null; state.mainCtx = null; state.maskCanvas = null; state.maskCtx = null; state.imageId = null; state.container = null; window.__galleryEditLive = false; return true; } export function isEditorOpen() { return state.editorOpen; } const galleryEditorModule = { openEditor, closeEditor, isEditorOpen, exportPNG, exportToGallery, downloadPNG, }; export default galleryEditorModule;