| |
| |
|
|
|
|
| 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;
|
|
|
|
|
|
|
|
|
| const _TRANSFORM_OVERLAY_MARGIN = 60;
|
|
|
|
|
|
|
|
|
| function _drawTransformHandles() { _drawTransformHandlesImpl(_TRANSFORM_OVERLAY_MARGIN); }
|
| function _getTransformHandle(x, y) { return _getTransformHandleImpl(x, y); }
|
| function _syncTransformOverlay() { _syncTransformOverlayImpl(_TRANSFORM_OVERLAY_MARGIN); }
|
|
|
|
|
|
|
| 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);
|
| }
|
|
|
|
|
|
|
|
|
|
|
| function _registerDocClickAway(handler) {
|
| document.addEventListener('click', handler);
|
| state.editorDocClickHandlers.push(handler);
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| const MAX_HISTORY = 20;
|
|
|
| |
| |
|
|
| function _getSelectedAIEndpoint(type) {
|
| let raw = '';
|
| if (type === 'inpaint') {
|
| raw = document.getElementById('ge-ai-inpaint')?.value || '';
|
| } else if (type) {
|
|
|
|
|
| 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) };
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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…';
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| });
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
|
|
|
|
|
|
|
| masks: [],
|
| activeMaskId: null,
|
|
|
|
|
|
|
|
|
| adjustments: {
|
| brightness: 1,
|
| contrast: 1,
|
| saturation: 1,
|
| hue: 0,
|
|
|
|
|
| levels: { inBlack: 0, inWhite: 255, gamma: 1.0, outBlack: 0, outWhite: 255 },
|
|
|
|
|
| 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;
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| function _activeParentLayer() {
|
| return state.layers.find(l => l.id === state.activeLayerId) || state.layers[state.layers.length - 1] || null;
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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;
|
| }
|
|
|
|
|
|
|
|
|
| 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;
|
| }
|
|
|
|
|
|
|
| function _hasAnyMasks() {
|
| for (const l of state.layers) {
|
| if (l.masks && l.masks.length) return true;
|
| }
|
| return false;
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
| function _buildMergedMaskCanvas() {
|
| return _buildMergedMaskCanvasImpl(state.layers, state.imgWidth, state.imgHeight);
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| function _renderLayerPixelAdjustments(layer) {
|
| return _renderLayerPixelAdjustmentsImpl(layer, _adjustmentsKey(layer.adjustments));
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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;
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
| function _fillEnclosedMaskRegions() {
|
| if (!state.maskCanvas || !state.maskCtx) return;
|
| const w = state.maskCanvas.width, h = state.maskCanvas.height;
|
| if (w * h > 4096 * 4096) return;
|
| const img = state.maskCtx.getImageData(0, 0, w, h);
|
| const d = img.data;
|
|
|
|
|
|
|
| const visited = new Uint8Array(w * h);
|
| const stack = [];
|
|
|
| for (let i = 0, j = 0; i < d.length; i += 4, j++) {
|
| if (d[i + 3] > 0) visited[j] = 2;
|
| }
|
|
|
| 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); }
|
|
|
| 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); } }
|
| }
|
|
|
| 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);
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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';
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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); }
|
|
|
|
|
|
|
|
|
|
|
| function composite() {
|
| if (!state.mainCtx) return;
|
| state.mainCtx.clearRect(0, 0, state.mainCanvas.width, state.mainCanvas.height);
|
|
|
| _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 };
|
|
|
|
|
|
|
| const source = _renderLayerWithAdjLayers(layer);
|
| state.mainCtx.drawImage(source, off.x, off.y);
|
| state.mainCtx.globalAlpha = 1;
|
| }
|
|
|
|
|
|
|
|
|
| if (state.maskVisible) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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) {
|
|
|
|
|
| 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;
|
| }
|
| }
|
|
|
| if (state.transformActive) _drawTransformHandles();
|
| else if (state.transformOverlay) state.transformOverlay.style.display = 'none';
|
|
|
|
|
|
|
|
|
| if (!state.lassoActive && state.lassoPoints.length >= 3) _drawLassoOverlay();
|
|
|
|
|
|
|
|
|
| if (state.cropRect && !state.cropping) _drawCropOverlay();
|
|
|
| if (state.activeSnapGuides && state.activeSnapGuides.length) _drawSnapGuides();
|
|
|
| if (state.wandMask && state.wandLayerId && state.wandMaskVisible) _drawWandOverlay();
|
|
|
|
|
|
|
|
|
| _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();
|
| }
|
|
|
|
|
|
|
|
|
| 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([]);
|
| }
|
|
|
|
|
|
|
|
|
|
|
| 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 => {
|
|
|
|
|
|
|
| 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 (_) { }
|
| 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 }) },
|
|
|
|
|
|
|
| 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) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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); }
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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;
|
|
|
|
|
| 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),
|
| });
|
|
|
|
|
| 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 (_) { }
|
| }
|
|
|
|
|
|
|
|
|
|
|
| function _restoreDraft(draft) {
|
| return new Promise((resolve) => {
|
|
|
| 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);
|
| });
|
| }
|
|
|
|
|
|
|
| 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) {
|
|
|
|
|
|
|
| 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;
|
|
|
|
|
|
|
|
|
|
|
| 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) {
|
|
|
|
|
| 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 });
|
|
|
|
|
| layer.adjLayers = s.adjLayers ? JSON.parse(JSON.stringify(s.adjLayers)) : [];
|
| if (s.isBase !== undefined) layer.isBase = s.isBase;
|
|
|
|
|
|
|
| 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);
|
| }
|
|
|
| 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;
|
| }
|
|
|
|
|
| {
|
| const m = _getActiveMaskLayer();
|
| if (m) { state.maskCanvas = m.canvas; state.maskCtx = m.ctx; }
|
| }
|
|
|
| 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();
|
|
|
|
|
|
|
| if (dimsChanged) _fitZoom();
|
|
|
|
|
| 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();
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| const _historyPanel = createHistoryPanel({ undo, redo });
|
| const _jumpToHistory = _historyPanel.jumpToHistory;
|
| const _toggleHistoryPanel = _historyPanel.toggleHistoryPanel;
|
| const _refreshHistoryPanelIfOpen = _historyPanel.refreshHistoryPanelIfOpen;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| function _beginDraw(e) {
|
|
|
|
|
| const layer = activeLayer() || _activeParentLayer();
|
|
|
|
|
| if (_transformDragTool.tryBegin(e)) return;
|
|
|
|
|
|
|
| if (state.tool === 'wand') return _wandTool.click(e);
|
|
|
|
|
| if (state.tool !== 'inpaint' && (!layer || layer.locked)) return;
|
|
|
| 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);
|
|
|
|
|
|
|
| if (state.tool === 'clone') return _cloneTool.begin(e);
|
|
|
|
|
|
|
| _strokeTool.tryBegin(e);
|
| }
|
|
|
| function _continueDraw(e) {
|
|
|
|
|
|
|
| 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';
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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;
|
| }
|
|
|
|
|
| _strokeTool.tryContinue(e);
|
| }
|
|
|
| function _endDraw() {
|
|
|
| if (_transformDragTool.tryEnd()) return;
|
| if (state.lassoActive) return _endLasso();
|
| if (state.moving) return _endMove();
|
| if (state.cropping || state.cropMoving) return _endCrop();
|
|
|
|
|
| _strokeTool.tryEnd();
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| const _strokePipeline = createStrokePipeline({
|
|
|
|
|
|
|
|
|
|
|
| activeLayer: () => activeLayer() || _activeParentLayer(),
|
| getActiveMaskLayer: () => _getActiveMaskLayer(),
|
| composite,
|
| });
|
| const _strokeTo = _strokePipeline.strokeTo;
|
| const _cloneStrokeTo = _strokePipeline.cloneStrokeTo;
|
|
|
|
|
|
|
|
|
| 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);
|
| }
|
|
|
|
|
|
|
| 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') {
|
|
|
|
|
|
|
| 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';
|
| }
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| const _moveTool = createMoveTool({ activeLayer, saveState: _saveState, composite });
|
| const _beginMove = _moveTool.begin;
|
| const _continueMove = _moveTool.drag;
|
| const _endMove = _moveTool.end;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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();
|
|
|
| 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());
|
|
|
|
|
| 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);
|
|
|
| [wInput, hInput].forEach(inp => {
|
| inp.addEventListener('keydown', (e) => { if (e.key === 'Enter') _applyCrop(); });
|
| });
|
|
|
|
|
|
|
| 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';
|
|
|
|
|
|
|
| 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();
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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;
|
|
|
|
|
|
|
|
|
| const _lassoTool = createLassoTool({
|
| composite,
|
| drawLassoOverlay: () => _drawLassoOverlay(),
|
| syncToolClearIndicators: () => _syncToolClearIndicators(),
|
| });
|
| const _beginLasso = _lassoTool.begin;
|
| const _continueLasso = _lassoTool.drag;
|
| const _endLasso = _lassoTool.end;
|
|
|
|
|
| const _wandTool = createWandTool({
|
| activeLayer,
|
| saveState: _saveState,
|
| composite,
|
| wandHits: (x, y) => _wandHits(x, y),
|
| runMagicWand: (x, y, mode) => _runMagicWand(x, y, mode),
|
| });
|
|
|
|
|
|
|
|
|
|
|
| const _cloneTool = createCloneTool({
|
| activeLayer,
|
| saveState: _saveState,
|
| strokeTo: (x, y) => _strokeTo(x, y),
|
| showToast: (msg) => { if (uiModule) uiModule.showToast(msg); },
|
| });
|
|
|
|
|
|
|
|
|
| const _transformDragTool = createTransformDragTool({
|
| beginMove: (e) => _beginMove(e),
|
| composite,
|
| drawTransformHandles: () => _drawTransformHandles(),
|
| reapplyTransform: () => _reapplyTransform(),
|
| getTransformHandle: (x, y) => _getTransformHandle(x, y),
|
| cursorForHandle: _cursorForHandle,
|
| });
|
|
|
|
|
|
|
|
|
| const _strokeTool = createStrokeTool({
|
| saveState: _saveState,
|
| strokeTo: (x, y) => _strokeTo(x, y),
|
| composite,
|
| getActiveMaskLayer: () => _getActiveMaskLayer(),
|
| activeParentLayer: () => _activeParentLayer(),
|
| ensureActiveMaskLayer: () => _ensureActiveMaskLayer(),
|
| createLayer,
|
| renderLayerPanel: () => _renderLayerPanel(),
|
| syncToolClearIndicators: () => _syncToolClearIndicators(),
|
| });
|
|
|
|
|
|
|
|
|
|
|
|
|
| function _lassoOffsetPoints(grow) {
|
| return _lassoOffsetPointsImpl(state.lassoPoints, grow);
|
| }
|
|
|
| function _drawLassoOverlay() {
|
| if (state.lassoPoints.length < 3) return;
|
|
|
|
|
|
|
| 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) {
|
|
|
|
|
| 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);
|
| }
|
|
|
| |
| |
| |
| |
| |
|
|
| function _buildLassoMask(w, h, offX, offY, feather, grow) {
|
| return _buildLassoMaskImpl(state.lassoPoints, w, h, offX, offY, feather, grow);
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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;
|
| }
|
|
|
|
|
|
|
|
|
| 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;
|
|
|
|
|
|
|
|
|
|
|
| const activeMask = _getActiveMaskLayer();
|
| const sourceCanvas = activeMask ? activeMask.canvas : layer.canvas;
|
| const sourceCtx = activeMask ? activeMask.ctx : layer.ctx;
|
|
|
|
|
|
|
| if (!opts.retune) _saveState();
|
|
|
| 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;
|
|
|
|
|
|
|
| const src = activeMask
|
| ? sourceCtx.getImageData(0, 0, w, h).data
|
| : _getWandSource(layer).data;
|
|
|
|
|
| const mask = _floodFillMask(src, w, h, lx, ly, state.wandTolerance);
|
| if (!mask) return;
|
|
|
|
|
|
|
| const compatible = state.wandMask && state.wandLayerId === layer.id &&
|
| state.wandMask.width === mask.width && state.wandMask.height === mask.height;
|
| if (compatible && mode === 'add') {
|
|
|
| state.wandMask.getContext('2d').drawImage(mask, 0, 0);
|
| } else if (compatible && mode === 'subtract') {
|
|
|
| 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();
|
| };
|
| }
|
|
|
|
|
|
|
| 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 };
|
|
|
| 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();
|
| }
|
|
|
|
|
|
|
|
|
| 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;
|
|
|
| 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;
|
|
|
| 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);
|
|
|
| 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';
|
| }
|
|
|
|
|
|
|
|
|
| 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');
|
| }
|
|
|
|
|
|
|
|
|
| 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) {
|
|
|
|
|
| _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;
|
| }
|
|
|
|
|
|
|
| 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 };
|
|
|
|
|
|
|
| state.activeLayerId = layer.id;
|
| const mask = _ensureActiveMaskLayer();
|
| if (!mask) return;
|
| state.maskCanvas = mask.canvas;
|
| state.maskCtx = mask.ctx;
|
|
|
|
|
|
|
|
|
| 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;
|
| }
|
|
|
|
|
|
|
|
|
| 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);
|
|
|
|
|
|
|
| state.wandMask = null;
|
| state.wandLayerId = null;
|
| state.wandLastSeed = null;
|
| composite();
|
| _renderLayerPanel();
|
| if (uiModule) uiModule.showToast('Selection added to mask');
|
| }
|
|
|
|
|
|
|
|
|
| function _syncToolClearIndicators() {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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);
|
| });
|
|
|
|
|
| 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';
|
| }
|
|
|
| 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);
|
|
|
|
|
| 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();
|
|
|
| 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();
|
|
|
| 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];
|
| 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);
|
|
|
|
|
| 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;
|
|
|
|
|
| const mask = _ensureActiveMaskLayer();
|
| if (!mask) return;
|
| state.maskCanvas = mask.canvas;
|
| state.maskCtx = mask.ctx;
|
|
|
|
|
| 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);
|
|
|
| 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);
|
|
|
|
|
|
|
|
|
| state.lassoPoints = [];
|
| composite();
|
| _renderLayerPanel();
|
| uiModule.showToast('Selection added to mask');
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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) {
|
|
|
|
|
|
|
| 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;
|
|
|
| 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));
|
|
|
| overlay.addEventListener('click', (e) => { if (e.target === overlay) cleanup(null); });
|
| });
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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);
|
|
|
|
|
| _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();
|
|
|
| if (state.undoStack.length) state.undoStack.pop();
|
| _refreshHistoryPanelIfOpen();
|
| return;
|
| }
|
|
|
| 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);
|
| }
|
|
|
|
|
|
|
| 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');
|
|
|
|
|
|
|
|
|
|
|
|
|
| const layersHeader = document.querySelector('.ge-layers-header');
|
| const rightPanel = document.querySelector('.ge-right-panel');
|
| const ref = layersHeader || rightPanel;
|
| if (!ref) {
|
|
|
|
|
| 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;
|
|
|
|
|
|
|
| let left = refRect.left - panelW - 10;
|
| let top = refRect.top;
|
|
|
| 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);
|
| });
|
| }
|
|
|
|
|
|
|
| function _buildEditor(container) {
|
| container.innerHTML = '';
|
| container.className = 'gallery-editor';
|
|
|
|
|
|
|
| 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) => {
|
|
|
| if (state.transformActive && toolId !== 'transform') _confirmTransform();
|
|
|
|
|
|
|
|
|
| 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');
|
| }
|
|
|
|
|
|
|
| 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));
|
|
|
| if (toolId === 'transform' && !state.transformActive) _startTransform();
|
|
|
|
|
| const brushControls = document.getElementById('ge-brush-controls');
|
| const needsBrush = ['brush', 'eraser', 'clone'].includes(toolId);
|
| if (brushControls) brushControls.style.display = needsBrush ? '' : 'none';
|
|
|
| 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 ';
|
| }
|
|
|
| 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';
|
| }
|
| }
|
|
|
|
|
|
|
|
|
|
|
| if (state.tool === 'inpaint') {
|
|
|
|
|
| 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`;
|
| }
|
| }
|
|
|
|
|
|
|
| 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;
|
|
|
| _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';
|
|
|
|
|
|
|
| const useCircle = state.tool === 'brush' || state.tool === 'eraser' || state.tool === 'inpaint' || state.tool === 'lasso' || state.tool === 'clone';
|
| if (state.mainCanvas) {
|
|
|
|
|
| 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();
|
| },
|
| });
|
|
|
|
|
| const topBar = _buildTopbar();
|
| container.appendChild(topBar);
|
|
|
|
|
| const editorBody = document.createElement('div');
|
| editorBody.className = 'ge-editor-body';
|
| editorBody.appendChild(toolbar);
|
|
|
|
|
| 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');
|
|
|
|
|
|
|
| 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);
|
|
|
|
|
|
|
|
|
|
|
|
|
| state.transformOverlay = document.createElement('canvas');
|
| state.transformOverlay.className = 'ge-transform-overlay';
|
| state.transformOverlayCtx = state.transformOverlay.getContext('2d');
|
| canvasArea.appendChild(state.transformOverlay);
|
|
|
|
|
|
|
| canvasArea.addEventListener('scroll', () => {
|
| if (state.transformActive) _drawTransformHandles();
|
| }, { passive: true });
|
|
|
|
|
|
|
| wireCanvasEvents({
|
| canvasArea,
|
| beginDraw: _beginDraw,
|
| continueDraw: _continueDraw,
|
| endDraw: _endDraw,
|
| updateBrushCursor: (e) => _updateBrushCursor(e),
|
| syncZoomControls: () => _syncZoomControls(),
|
| });
|
|
|
| editorBody.appendChild(canvasArea);
|
|
|
|
|
|
|
| const { rightPanel, controls, layerPanel } = buildRightPanel({
|
| controlsHTML: _controlsHTML,
|
| layerPanelHTML: _layerPanelHTML,
|
| });
|
| editorBody.appendChild(rightPanel);
|
| container.appendChild(editorBody);
|
| _wireInpaintPopoverWindow();
|
|
|
|
|
|
|
| wireSliderUx({ registerDocClickAway: _registerDocClickAway });
|
|
|
|
|
|
|
|
|
| const _shortcutsPopover = createShortcutsPopover();
|
| const _toggleShortcuts = _shortcutsPopover.toggleShortcuts;
|
| document.getElementById('ge-shortcuts-btn')?.addEventListener('click', () => _toggleShortcuts());
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| controls.querySelector('.ge-color-picker').addEventListener('input', (e) => { state.color = e.target.value; });
|
|
|
|
|
|
|
|
|
| controls.querySelectorAll('.ge-color-picker').forEach(attachColorPicker);
|
| controls.querySelectorAll('.ge-color-picker').forEach(el => {
|
|
|
| el.value = el.value;
|
| });
|
|
|
| const initBrushCtrl = document.getElementById('ge-brush-controls');
|
| if (initBrushCtrl) initBrushCtrl.style.display = 'none';
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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'));
|
|
|
|
|
|
|
| wireTopbar({
|
| undo, redo,
|
| toggleHistoryPanel: _toggleHistoryPanel,
|
| fitZoom: () => _fitZoom(),
|
| applyZoom: () => _applyZoom(),
|
| exportToGallery, downloadPNG,
|
| saveProject: () => _saveProject(),
|
| loadProjectPrompt: () => _loadProjectPrompt(),
|
| activeLayer,
|
| saveState: _saveState,
|
| applyEdgeFeather: _applyEdgeFeather,
|
| composite,
|
| registerDocClickAway: _registerDocClickAway,
|
| uiModule,
|
| });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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');
|
| }
|
|
|
|
|
|
|
| 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 {
|
|
|
|
|
|
|
|
|
| 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();
|
| }
|
| });
|
|
|
|
|
|
|
| wireTopbarOverflow({ container, registerDocClickAway: _registerDocClickAway });
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| });
|
|
|
|
|
|
|
|
|
| wireInpaintControls({
|
| composite,
|
| applyInpaintFeather: _applyInpaintFeather,
|
| syncToolClearIndicators: () => _syncToolClearIndicators(),
|
| attachColorPicker,
|
| uiModule,
|
| });
|
|
|
|
|
|
|
| wireInpaintButtons({
|
| buildMergedMaskCanvas: () => _buildMergedMaskCanvas(),
|
| dilateMask: _dilateMask,
|
| applyInpaintFeather: _applyInpaintFeather,
|
| getSelectedAIEndpoint: (type) => _getSelectedAIEndpoint(type),
|
| ensureActiveMaskLayer: () => _ensureActiveMaskLayer(),
|
| saveState: _saveState,
|
| createLayer,
|
| composite,
|
| renderLayerPanel: () => _renderLayerPanel(),
|
| spinnerModule,
|
| uiModule,
|
| });
|
|
|
|
|
|
|
| wireStrokeToolSliders();
|
|
|
|
|
|
|
|
|
| const { buildSelectionHintMask: _buildSelectionHintMask } = wireRembgAndSharpen({
|
| applyImageTool: _applyImageTool,
|
| openCookbookForDependency: (pkg) => _openCookbookForDependency(pkg),
|
| composite,
|
| renderLayerPanel: () => _renderLayerPanel(),
|
| uiModule,
|
| });
|
|
|
|
|
|
|
|
|
| const { handleImportedImage: _handleImportedImage } = wireImport({
|
| container,
|
| saveState: _saveState,
|
| createLayer,
|
| composite,
|
| renderLayerPanel: () => _renderLayerPanel(),
|
| uiModule,
|
| });
|
|
|
|
|
|
|
| const { addEmptyLayer: _addEmptyLayer } = wireAIToolsMisc({
|
| apiBase: API_BASE,
|
| buildLayerBodyMask: _buildLayerBodyMask,
|
| buildSeamMask: _buildSeamMask,
|
| applyImageTool: _applyImageTool,
|
| flatten,
|
| saveState: _saveState,
|
| fitZoom: () => _fitZoom(),
|
| composite,
|
| createLayer,
|
| renderLayerPanel: () => _renderLayerPanel(),
|
| spinnerModule,
|
| uiModule,
|
| });
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| });
|
|
|
|
|
|
|
| wireMergeButtons({
|
| saveState: _saveState,
|
| createLayer,
|
| renderLayerPanel: () => _renderLayerPanel(),
|
| composite,
|
| uiModule,
|
| });
|
|
|
|
|
|
|
|
|
|
|
| document.addEventListener('keydown', (e) => {
|
| if (!state.editorOpen) return;
|
|
|
|
|
|
|
| if (e.key === 'Escape' && _shortcutsPopover.isOpen()) {
|
| e.preventDefault();
|
| e.stopPropagation();
|
| e.stopImmediatePropagation();
|
| _toggleShortcuts(false);
|
| return;
|
| }
|
|
|
|
|
|
|
| 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;
|
|
|
|
|
| e.preventDefault();
|
| e.stopPropagation();
|
| e.stopImmediatePropagation();
|
| }, true);
|
|
|
|
|
|
|
| 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');
|
|
|
|
|
|
|
| wireClipboardAndDrop({
|
| container,
|
| saveState: _saveState,
|
| createLayer,
|
| renderLayerPanel: () => _renderLayerPanel(),
|
| composite,
|
| handleImportedImage: (img) => _handleImportedImage(img),
|
| uiModule,
|
| });
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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');
|
| });
|
| }
|
|
|
|
|
|
|
| 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;
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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');
|
| }
|
|
|
|
|
|
|
|
|
| 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);
|
| }
|
|
|
|
|
|
|
| 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 {
|
|
|
|
|
|
|
| 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();
|
| }
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
| function _openCookbookForDependency(pkgName) {
|
|
|
|
|
|
|
|
|
|
|
| const cookbook = window.cookbookModule;
|
| if (!cookbook || typeof cookbook.open !== 'function') {
|
|
|
|
|
| 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' });
|
|
|
|
|
| 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 function _checkRembgInstalled() {
|
| const noticeEl = document.getElementById('ge-rembg-dep-missing');
|
| const runRow = document.getElementById('ge-rembg-run-row');
|
| if (!noticeEl || !runRow) return;
|
|
|
| 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;
|
| }
|
| if (state.rembgInstalledCache === false) {
|
| noticeEl.style.display = '';
|
| runRow.style.display = 'none';
|
| } else {
|
| noticeEl.style.display = 'none';
|
| runRow.style.display = '';
|
| }
|
| }
|
|
|
| function _openCookbookForImg2img() {
|
|
|
|
|
| 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) {
|
|
|
|
|
| const tryServe = (attempt = 0) => {
|
| const cb = document.getElementById('cookbook-modal');
|
| const serveTab = cb ? cb.querySelector('.cookbook-tab[data-backend="Serve"]') : null;
|
|
|
|
|
|
|
| if (!cb || !serveTab) {
|
| if (attempt < 40) return setTimeout(() => tryServe(attempt + 1), 80);
|
| return;
|
| }
|
| cb.style.zIndex = 260;
|
| serveTab.click();
|
|
|
|
|
| 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();
|
| }
|
|
|
|
|
|
|
|
|
| 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);
|
| }
|
|
|
|
|
| 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();
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 };
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| function _mountEditorLoading(label, dims) {
|
| if (!state.container) return;
|
| const area = state.container.querySelector('.ge-canvas-area');
|
| _unmountEditorLoading();
|
|
|
|
|
|
|
| const el = document.createElement('div');
|
| el.className = 'ge-loading-overlay ge-loading-overlay-full';
|
|
|
|
|
| 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);
|
|
|
|
|
| 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;
|
|
|
|
|
|
|
| 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) {
|
|
|
|
|
| _mountEditorLoading('Loading draft…', presetSize || null);
|
|
|
|
|
|
|
|
|
| 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) {
|
|
|
|
|
|
|
| const _finishBlank = (w, h) => {
|
| _initCanvas(w, h);
|
|
|
|
|
|
|
| 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();
|
|
|
| _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);
|
| });
|
| }
|
|
|
|
|
|
|
|
|
| _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 (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() {
|
|
|
|
|
|
|
|
|
|
|
| if (!state.editorLoadingEl) _mountEditorLoading('Loading…');
|
| else {
|
| const inner = state.editorLoadingEl.querySelector('.ge-loading-text');
|
| if (inner) inner.textContent = 'Loading…';
|
| }
|
| const _removeLoading = () => _unmountEditorLoading();
|
|
|
|
|
|
|
| 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;
|
| }
|
| }
|
|
|
|
|
|
|
|
|
| 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;
|
| }
|
|
|
|
|
|
|
| if (state.persistTimer) { clearTimeout(state.persistTimer); state.persistTimer = null; }
|
| if (state.layers.length) {
|
| try { _persistDraft(); } catch {}
|
| }
|
| _setEditTabLabel(null);
|
| _unmountEditorLoading();
|
| state.editorOpen = false;
|
|
|
|
|
|
|
| while (state.editorDocClickHandlers.length) {
|
| const h = state.editorDocClickHandlers.pop();
|
| try { document.removeEventListener('click', h); } catch {}
|
| }
|
| if (state.cursorEl) { state.cursorEl.remove(); state.cursorEl = null; }
|
|
|
|
|
| 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);
|
| }
|
|
|
|
|
|
|
| if (el._modalId) {
|
| try { modalManager.unregister(el._modalId); } catch {}
|
| }
|
| el.remove();
|
| });
|
| } catch {}
|
|
|
|
|
|
|
| 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;
|
|
|