3799 lines
157 KiB
JavaScript
3799 lines
157 KiB
JavaScript
/**
|
||
* 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 "<base_url>::<model_id>" 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="<name>".
|
||
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 = `
|
||
<div class="ge-canvas-loading-spinner"></div>
|
||
<div class="ge-canvas-loading-msg"></div>
|
||
`;
|
||
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 = `
|
||
<input type="number" class="ge-crop-w" min="1" max="20000" value="${Math.round(state.cropRect.w)}" title="Width">
|
||
<span class="ge-crop-x">×</span>
|
||
<input type="number" class="ge-crop-h" min="1" max="20000" value="${Math.round(state.cropRect.h)}" title="Height">
|
||
<button class="ge-crop-apply-btn">Apply</button>
|
||
`;
|
||
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 += `
|
||
<div class="ge-filter-row ge-eraser-row">
|
||
<label>${p.label} <span class="ge-filter-row-value" data-val-for="${p.key}">${p.value}${p.suffix || ''}</span></label>
|
||
<input type="range" data-key="${p.key}" min="${p.min}" max="${p.max}" step="${p.step || 1}" value="${p.value}" />
|
||
</div>
|
||
`;
|
||
}
|
||
overlay.innerHTML = `
|
||
<div class="ge-filter-modal">
|
||
<div class="ge-filter-modal-head">${title}</div>
|
||
${rows}
|
||
<div class="ge-filter-modal-actions">
|
||
<button type="button" class="ge-btn ge-btn-sm" data-action="cancel">Cancel</button>
|
||
<button type="button" class="ge-btn ge-btn-sm ge-btn-primary" data-action="apply">Apply</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
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 = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
|
||
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(
|
||
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="white" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 L9 5 H11 V11 H5 V9 L2 12 L5 15 V13 H11 V19 H9 L12 22 L15 19 H13 V13 H19 V15 L22 12 L19 9 V11 H13 V5 H15 Z"/></svg>'
|
||
)}`;
|
||
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(
|
||
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="white" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 L9 5 H11 V11 H5 V9 L2 12 L5 15 V13 H11 V19 H9 L12 22 L15 19 H13 V13 H19 V15 L22 12 L19 9 V11 H13 V5 H15 Z"/></svg>'
|
||
)}`;
|
||
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 = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-3px;margin-right:4px;"><polyline points="20 6 9 17 4 12"/></svg>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: <name>".
|
||
// 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 = `<span class="ge-loading-text">${label || 'Loading…'}</span>`;
|
||
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;
|