feat(editor): harden photo editor security and interactions

This commit is contained in:
Alexandre Teixeira
2026-10-28 17:37:10 +00:00
parent 0b8176b11e
commit c237a1cb9c
68 changed files with 3630 additions and 426 deletions
+366 -134
View File
@@ -7,7 +7,7 @@ import dragSortModule from './dragSort.js';
import spinnerModule from './spinner.js';
import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
import modalManager from './modalManager.js';
import { canvasCoords as _canvasCoords } from './editor/canvas-coords.js';
import { canvasCoords as _canvasCoords, invalidateCanvasCoordsCache } from './editor/canvas-coords.js';
import { pickTopmostLayerAt as _pickTopmostLayerAt } from './editor/layer-hit-test.js';
import { drawCheckerboard as _drawCheckerboard } from './editor/checkerboard.js';
import { dilateMask as _dilateMask, applyInpaintFeather as _applyInpaintFeather } from './editor/mask-utils.js';
@@ -854,11 +854,13 @@ function _setAiProgress(frac, label) {
if (lbl) lbl.textContent = label || '';
}
function _startAiProgress(requestId) {
const endpointId = arguments[1] || '';
_stopAiProgress();
if (!requestId) return;
const tick = async () => {
try {
const r = await fetch(`/api/image/edit-progress?request_id=${encodeURIComponent(requestId)}`, { credentials: 'same-origin' });
const epQuery = endpointId ? `&endpoint_id=${encodeURIComponent(endpointId)}` : '';
const r = await fetch(`/api/image/edit-progress?request_id=${encodeURIComponent(requestId)}` + epQuery, { credentials: 'same-origin' });
if (!r.ok) return;
const p = await r.json();
const st = p.stage || '';
@@ -1113,6 +1115,68 @@ function _matchAiCommandSuggestions(query) {
.map(hit => hit.item);
}
// On phones the layers panel is a bottom sheet that can cover the editor
// footer (zoom / setup). Float the footer just above the sheet's top edge so
// its controls stay tappable; restore it to normal flow when the sheet is
// collapsed or on desktop.
let _lastFooterDelta = 0;
const _placeMobileFooter = () => {
const footer = document.querySelector('.ge-editor-footer');
if (!footer) return;
const area = document.querySelector('.ge-canvas-area');
const _clear = () => {
footer.classList.remove('ge-footer-floating');
footer.style.position = ''; footer.style.left = ''; footer.style.right = '';
footer.style.bottom = ''; footer.style.zIndex = '';
document.body.classList.remove('ge-footer-docked');
state._footerDocked = false;
if (area && (area.style.marginBottom || area.style.height || area.style.flex || area.style.minHeight)) {
area.style.marginBottom = ''; area.style.height = ''; area.style.flex = ''; area.style.minHeight = '';
_lastFooterDelta = 0;
// The sheet may have squeezed fit view to a few pixels. Restore fit
// after releasing that constraint, while retaining an explicit zoom.
if (state.zoomViewMode === 'fit') {
try { _fitZoom(); } catch (_) {}
}
}
};
if (window.innerWidth > 700) { _clear(); return; }
const sheet = document.querySelector('.ge-right-panel');
if (!sheet || sheet.classList.contains('minimized') || sheet.classList.contains('expanded')) { _clear(); return; }
const r = sheet.getBoundingClientRect();
const vh = window.innerHeight;
if (r.height <= 0 || r.top >= vh || r.top <= 140) { _clear(); return; }
footer.classList.add('ge-footer-floating');
footer.style.position = 'fixed';
footer.style.left = '0';
footer.style.right = '0';
footer.style.zIndex = '60';
// Dock the footer flush to the sheet top: no seam / dead space between them.
footer.style.bottom = Math.max(0, Math.round(vh - r.top)) + 'px';
document.body.classList.add('ge-footer-docked');
// Dock the canvas image flush too: with the dock padding off, the image
// fills the region so its bottom meets the footer top (no dead space).
state._footerDocked = true;
// Size the canvas region so it ends exactly where the footer begins:
// canvas bottom == footer top == sheet top. (Its mobile min-height:40vh
// would otherwise keep it running under the footer and the sheet.) The
// ge-footer-docked class also hides the sheet grab handle.
if (area) {
const footerH = footer.getBoundingClientRect().height || 0;
const desiredBottom = Math.round(r.top - footerH);
const want = Math.max(0, Math.round(desiredBottom - area.getBoundingClientRect().top));
if (want !== _lastFooterDelta) {
area.style.flex = want ? '0 0 auto' : '';
area.style.minHeight = want ? '0px' : '';
area.style.height = want ? want + 'px' : '';
_lastFooterDelta = want;
if (state.zoomViewMode === 'fit') {
try { _fitZoom(); } catch (_) {}
}
}
}
};
function _wireAiCommandBox() {
const wrap = document.getElementById('ge-ai-command');
const toggle = document.getElementById('ge-ai-command-toggle');
@@ -1368,60 +1432,6 @@ function _wireAiCommandBox() {
input.addEventListener('click', _trackLift);
wrap.addEventListener('focusin', _trackLift);
requestAnimationFrame(() => requestAnimationFrame(_liftQuickEdit));
// On phones the layers panel is a bottom sheet that can cover the editor
// footer (zoom / setup). Float the footer just above the sheet's top edge so
// its controls stay tappable; restore it to normal flow when the sheet is
// collapsed or on desktop.
let _lastFooterDelta = 0;
const _placeMobileFooter = () => {
const footer = document.querySelector('.ge-editor-footer');
if (!footer) return;
const area = document.querySelector('.ge-canvas-area');
const _clear = () => {
footer.classList.remove('ge-footer-floating');
footer.style.position = ''; footer.style.left = ''; footer.style.right = '';
footer.style.bottom = ''; footer.style.zIndex = '';
document.body.classList.remove('ge-footer-docked');
state._footerDocked = false;
if (area && (area.style.marginBottom || area.style.height || area.style.flex || area.style.minHeight)) {
area.style.marginBottom = ''; area.style.height = ''; area.style.flex = ''; area.style.minHeight = '';
_lastFooterDelta = 0;
}
};
if (window.innerWidth > 700) { _clear(); return; }
const sheet = document.querySelector('.ge-right-panel');
if (!sheet || sheet.classList.contains('minimized')) { _clear(); return; }
const r = sheet.getBoundingClientRect();
const vh = window.innerHeight;
if (r.height <= 0 || r.top >= vh) { _clear(); return; }
footer.classList.add('ge-footer-floating');
footer.style.position = 'fixed';
footer.style.left = '0';
footer.style.right = '0';
footer.style.zIndex = '60';
// Dock the footer flush to the sheet top: no seam / dead space between them.
footer.style.bottom = Math.max(0, Math.round(vh - r.top)) + 'px';
document.body.classList.add('ge-footer-docked');
// Dock the canvas image flush too: with the dock padding off, the image
// fills the region so its bottom meets the footer top (no dead space).
state._footerDocked = true;
// Size the canvas region so it ends exactly where the footer begins:
// canvas bottom == footer top == sheet top. (Its mobile min-height:40vh
// would otherwise keep it running under the footer and the sheet.) The
// ge-footer-docked class also hides the sheet grab handle.
if (area) {
const footerH = footer.getBoundingClientRect().height || 0;
const desiredBottom = Math.round(r.top - footerH);
const want = Math.max(0, Math.round(desiredBottom - area.getBoundingClientRect().top));
if (want !== _lastFooterDelta) {
area.style.flex = want ? '0 0 auto' : '';
area.style.minHeight = want ? '0px' : '';
area.style.height = want ? want + 'px' : '';
_lastFooterDelta = want;
try { _fitZoom(); } catch (_) {}
}
}
};
// The layers sheet expands/collapses (and its peek height grows as layers
// render) independently of the keyboard; watch it so the bar and the footer
// keep clear of the sheet's top edge as it moves.
@@ -2592,8 +2602,8 @@ function _wireAiCommandBox() {
// done vs retry. The caller enforces a single retry (max 1).
const _vlJudgeAfter = async (prompt, before, after) => {
if (!before || !after) return { done: true };
let ep = 'http://100.69.120.65:8097/v1';
let model = 'Qwen3.5-9B-MLX-8bit';
let ep = '';
let model = 'Qwen3.5-9B';
try {
const info = _getSelectedQuickEditAgentModel();
if (info && info.endpoint) ep = info.endpoint;
@@ -2637,6 +2647,7 @@ function _wireAiCommandBox() {
{ type: 'image_url', image_url: { url: after } },
] }],
};
if (!ep) return { done: true };
try {
const resp = await fetch(ep.replace(/\/$/, '') + '/chat/completions', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
@@ -3295,6 +3306,7 @@ const _adjPopupSystem = createAdjPopupSystem({
saveState: _saveState,
renderLayerPanel: () => _renderLayerPanel(),
getAdjustmentSource: layer => _adjustmentSourceCanvas(layer),
schedulePersist: () => _schedulePersist(),
});
const _closeFxPopup = _adjPopupSystem.closeFxPopup;
const _ensureAdjustments = _adjPopupSystem.ensureAdjustments;
@@ -3948,13 +3960,13 @@ function _finishComposite(render, documentCanvas) {
if (state.transformActive) _drawTransformHandles();
else if (state.transformOverlay) state.transformOverlay.style.display = 'none';
if (state.marqueeActive && state.marqueeRect) _drawMarqueeOverlay();
_penTool.draw();
if (state.tool === 'pen' || state.penPoints?.length) _penTool.draw();
if (state.cropRect && !state.cropping) _drawCropOverlay();
_syncCropFrame();
if (state.cropRect) _syncCropFrame();
if (state.activeSnapGuides && state.activeSnapGuides.length) _drawSnapGuides();
_precisionGuides?.drawDocumentOverlay(state.mainCtx);
if (!state.drawing) _precisionGuides?.drawDocumentOverlay(state.mainCtx);
_refreshSelectionOverlay();
_syncToolClearIndicators();
if (!state.drawing) _syncToolClearIndicators();
}
function composite() {
@@ -4333,7 +4345,14 @@ function _saveState(label, options = {}) {
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 {
state.documentRevision = (state.documentRevision || 0) + 1;
state.persistDirty = true;
_setDraftStatus('Unsaved', 'dirty');
if (!_draftSavingDisabled()) {
_schedulePersist();
}
} catch (e) { console.error('[gallery] schedulePersist:', e); }
try { _refreshHistoryPanelIfOpen(); } catch (e) { console.error('[gallery] refreshHistoryPanel:', e); }
}
@@ -4367,20 +4386,47 @@ function _writeActiveEditorSession() {
function _setDraftStatus(label, stateName = '') {
const el = document.getElementById('ge-draft-status');
if (!el) return;
const normalizedState = stateName === 'saved' ? 'saved' : 'dirty';
const normalizedLabel = normalizedState === 'saved' ? 'Saved' : 'Unsaved';
const validStates = ['saved', 'dirty', 'pending', 'saving', 'error', 'paused'];
let normalizedState = stateName;
if (!validStates.includes(normalizedState)) {
if (stateName === 'saved' || label === 'Saved') normalizedState = 'saved';
else if (stateName === 'error' || /fail|error/i.test(label || '')) normalizedState = 'error';
else if (stateName === 'paused' || /paused/i.test(label || '')) normalizedState = 'paused';
else if (stateName === 'saving' || label === 'Saving…' || label === 'Saving...') normalizedState = 'saving';
else if (stateName === 'pending' || /saving soon/i.test(label || '')) normalizedState = 'pending';
else normalizedState = 'dirty';
}
const defaultLabels = {
saved: 'Saved',
dirty: 'Unsaved',
pending: 'Saving soon…',
saving: 'Saving…',
error: 'Save failed',
paused: 'Save paused',
};
const normalizedLabel = label || defaultLabels[normalizedState] || 'Unsaved';
el.textContent = normalizedLabel;
el.dataset.state = stateName;
el.dataset.state = normalizedState;
const iconHost = el.closest('#ge-save-menu-btn');
const iconKind = (normalizedState === 'error' || normalizedState === 'paused')
? 'error'
: (normalizedState === 'saved' ? 'saved' : 'dirty');
// The icons are <svg>; SVGElement has no `hidden` property, so set the attribute.
iconHost?.querySelectorAll('.ge-save-state-icon').forEach(icon => {
icon.hidden = !icon.classList.contains(`ge-save-state-${normalizedState}`);
icon.toggleAttribute('hidden', !icon.classList.contains(`ge-save-state-${iconKind}`));
});
const saveButton = document.getElementById('ge-save-menu-btn');
if (saveButton) {
saveButton.title = normalizedState === 'error' ? 'Draft autosave needs attention' : `Draft status: ${normalizedLabel}`;
saveButton.setAttribute('aria-label', normalizedLabel);
if (normalizedState === 'error' || normalizedState === 'paused') {
saveButton.title = state.persistErrorMessage || `Draft autosave failed: ${normalizedLabel}`;
saveButton.setAttribute('aria-label', `Draft autosave failed: ${normalizedLabel}`);
} else {
saveButton.title = `Draft status: ${normalizedLabel}`;
saveButton.setAttribute('aria-label', `Draft status: ${normalizedLabel}`);
}
}
}
try { window.__geSetDraftStatus = _setDraftStatus; } catch (_) {}
function _clearActiveEditorSession() {
try { sessionStorage.removeItem(ACTIVE_EDITOR_SESSION_KEY); } catch (_) {}
@@ -4403,11 +4449,20 @@ function _draftSavingDisabled() {
function _schedulePersist() {
if (_draftSavingDisabled()) return;
if (!state.editorOpen || !state.layers.length) return;
if (state.persistTimer) clearTimeout(state.persistTimer);
_setDraftStatus('Saving soon…', 'pending');
if (state.persistTimer) { clearTimeout(state.persistTimer); state.persistTimer = null; }
if (state.persistRetryTimer) { clearTimeout(state.persistRetryTimer); state.persistRetryTimer = null; }
if (state.pendingStatusTimer) { clearTimeout(state.pendingStatusTimer); state.pendingStatusTimer = null; }
// The first save establishes the recovery anchor. Start it immediately
// when no server draft exists; subsequent edits can stay debounced.
const delay = state.draftId ? PERSIST_DEBOUNCE_MS : 0;
if (delay > 0) {
state.pendingStatusTimer = setTimeout(() => {
state.pendingStatusTimer = null;
if (state.editorOpen) _setDraftStatus('Saving soon…', 'pending');
}, Math.min(200, Math.floor(delay / 2)));
} else {
_setDraftStatus('Saving soon…', 'pending');
}
state.persistTimer = setTimeout(() => { state.persistTimer = null; _persistDraft(); }, delay);
}
@@ -4591,6 +4646,8 @@ async function _persistDraft() {
// Coalesce concurrent saves — if one's already in-flight, mark dirty
// and let the running call kick off another when it returns.
if (state.persistInFlight) { state.persistDirty = true; _setDraftStatus('Saving…', 'saving'); return; }
const currentRevision = state.documentRevision || 0;
state.persistingRevision = currentRevision;
const sessionToken = state.editorSessionToken;
const draftIdAtStart = state.draftId || null;
const draftNameAtStart = state.draftName || 'Untitled';
@@ -4618,13 +4675,10 @@ async function _persistDraft() {
};
const serializedBody = JSON.stringify(body);
if (serializedBody.length > _EDITOR_PROJECT_MAX_BYTES) {
const message = 'Draft autosave paused: this layered document exceeds the 256 MB safety limit. Export or flatten a copy before closing.';
if (isCurrentSession()) {
if (state.persistErrorMessage !== message && uiModule) uiModule.showToast(message, 9000);
state.persistErrorMessage = message;
_setDraftStatus('Save paused', 'error');
}
return;
_setDraftStatus('Save paused', 'error');
const err = new Error('Draft autosave paused: this layered document exceeds the 256 MB safety limit. Export or flatten a copy before closing.');
err.status = 413;
throw err;
}
const responseError = async (res, method) => {
let detail = '';
@@ -4667,29 +4721,51 @@ async function _persistDraft() {
};
const savePromise = doSave();
state.persistInFlight = savePromise
.catch((e) => {
console.warn('[ge] draft save failed', e);
if (!isCurrentSession()) return;
const message = e?.status === 413
? 'Draft autosave paused: the document exceeds the configured storage limit.'
: `Draft autosave failed: ${e?.message || 'server error'}`;
if (state.persistErrorMessage !== message && uiModule) uiModule.showToast(message, 7000);
state.persistErrorMessage = message;
_setDraftStatus('Save failed', 'error');
})
.then((out) => {
if (isCurrentSession()) state.persistInFlight = null;
if (!isCurrentSession()) return;
state.savedRevision = Math.max(state.savedRevision || 0, currentRevision);
state.persistRetryCount = 0;
if (out && out.id) {
state.draftId = out.id;
_writeActiveEditorSession();
}
state.persistErrorMessage = null;
if (!state.persistErrorMessage) _setDraftStatus('Saved', 'saved');
if ((state.documentRevision || 0) > state.savedRevision || state.persistDirty) {
state.persistDirty = true;
_setDraftStatus('Unsaved', 'dirty');
} else {
_setDraftStatus('Saved', 'saved');
}
if (state.persistDirty) {
state.persistDirty = false;
_schedulePersist();
}
})
.catch((e) => {
console.warn('[ge] draft save failed', e);
if (!isCurrentSession()) return;
state.persistDirty = true;
const isPaused = e?.status === 413;
const message = isPaused
? (e?.message || 'Draft autosave paused: the document exceeds the configured storage limit.')
: `Draft autosave failed: ${e?.message || 'server error'}`;
if (state.persistErrorMessage !== message && uiModule) uiModule.showToast(message, isPaused ? 9000 : 7000);
state.persistErrorMessage = message;
_setDraftStatus(isPaused ? 'Save paused' : 'Save failed', isPaused ? 'paused' : 'error');
if (!isPaused && state.editorOpen) {
state.persistRetryCount = Math.min((state.persistRetryCount || 0) + 1, 5);
const retryDelay = Math.min(1000 * Math.pow(2, state.persistRetryCount), 15000);
if (state.persistRetryTimer) clearTimeout(state.persistRetryTimer);
state.persistRetryTimer = setTimeout(() => {
state.persistRetryTimer = null;
if (state.editorOpen && (state.persistDirty || (state.documentRevision || 0) > (state.savedRevision || 0))) {
_persistDraft();
}
}, retryDelay);
}
})
.finally(() => {
if (isCurrentSession()) state.persistInFlight = null;
});
return state.persistInFlight;
}
@@ -5381,7 +5457,24 @@ async function _confirmRasterizeLayer(layer) {
return true;
}
let _lastCursorRect = null;
let _lastCursorRectTime = 0;
function _invalidateCoordsCaches() {
invalidateCanvasCoordsCache();
_lastCursorRect = null;
_lastCursorRectTime = 0;
}
function _beginDraw(e) {
if (state.mainCanvas) {
// Prime canvas rect and cursor rect once before stroke begins
// to avoid synchronous layout reads during pointermove.
_canvasCoords(e, state.mainCanvas);
const now = performance.now();
_lastCursorRect = state.mainCanvas.getBoundingClientRect();
_lastCursorRectTime = now;
}
// Move-tool auto-select (Photoshop parity): when the Auto-Select option is
// on — or Ctrl/Cmd is held for a one-off — pick the topmost visible,
// position-unlocked layer under the pointer before the drag. If the picked
@@ -5428,13 +5521,11 @@ function _beginDraw(e) {
}
if (_pixelPaintTools.has(state.tool) && !_getActiveMaskLayer() && !state.quickMaskActive &&
['text', 'shape', 'placed'].includes(layer?.kind)) {
// Paint tools need pixels: rasterize the text/shape/placed layer silently
// (it stays undoable) so the very first stroke or pour works, instead of
// bouncing the user through a rasterize dialog that can end up behind the
// canvas where it cannot be clicked. If rasterizing is not possible, fall
// back to the prompt.
if (!_rasterizeLayerNow(layer)) { void _offerRasterizeForTool(); return; }
// fall through — `layer` is a pixel layer now, so the tool runs below.
// Paint tools need pixels: prompt for confirmation before destructive rasterization.
// If user cancels, editable text/shape/placed layer is preserved intact.
// If user accepts, layer is converted with an undo boundary so it can be reverted.
void _confirmRasterizeLayer(layer);
return;
}
// Transform-tool drag (handle grab or move-fallback) — handler in
// editor/tools/transform-drag.js.
@@ -5480,6 +5571,13 @@ function _beginDraw(e) {
_strokeTool.tryBegin(e);
}
let _cursorRaf = null;
let _pendingCursorEvent = null;
const _cursorOverlayTools = new Set([
'eraser', 'pixel', 'inpaint', 'lasso', 'brush', 'clone', 'heal',
'smudge', 'dodge', 'burn', 'liquify', 'quickselect', 'objectselect'
]);
function _continueDraw(e) {
if (state.tool === 'pen') return _penTool.drag(e);
if (state.tool === 'path') return _pathTool.drag(e);
@@ -5496,10 +5594,40 @@ function _continueDraw(e) {
// when it's actually over the canvas, otherwise hide it.
const overCanvas = state.mainCanvas && e.target === state.mainCanvas;
if (state.tool === 'eyedropper' && overCanvas) _eyedropperTool.preview(e);
if (['eraser', 'pixel', 'inpaint', 'lasso', 'brush', 'clone', 'heal', 'smudge', 'dodge', 'burn', 'liquify', 'quickselect', 'objectselect'].includes(state.tool) && state.mainCanvas) {
if (overCanvas) _updateBrushCursor(e);
else if (state.cursorEl) state.cursorEl.style.display = 'none';
if (_cursorOverlayTools.has(state.tool) && state.mainCanvas) {
const syncCursor = () => {
if (!overCanvas) {
if (state.cursorEl) state.cursorEl.style.display = 'none';
return;
}
if (state.drawing) {
_pendingCursorEvent = e;
if (!_cursorRaf) {
_cursorRaf = requestAnimationFrame(() => {
_cursorRaf = null;
if (_pendingCursorEvent) {
_updateBrushCursor(_pendingCursorEvent);
_pendingCursorEvent = null;
}
});
}
} else {
_updateBrushCursor(e);
}
};
// Mid-stroke, move the cursor only after the stroke has sampled the canvas
// rect: a style write first forces a synchronous style + layout pass of the
// whole editor on every pointer move, which made long strokes lag.
if (state.drawing) {
try { return _continueDrawTools(e); } finally { syncCursor(); }
}
syncCursor();
}
return _continueDrawTools(e);
}
function _continueDrawTools(e) {
if (state.drawing) return _strokeTool.tryContinue(e);
// Transform-tool hover-cursor + handle drag — handler in
// editor/tools/transform-drag.js. Returns true when the drag is
// consuming the event (rotation / resize); the hover-cursor pass
@@ -5514,17 +5642,13 @@ function _continueDraw(e) {
if (state.tool === 'quickselect') return _quickSelectTool.drag(e);
if (state.tool === 'objectselect') return _objectSelectTool.drag(e);
if (state.tool === 'patch') return _patchTool.drag(e);
if (!state.drawing) {
if (state.moving) return _continueMove(e);
if (state.cropping || state.cropMoving) return _continueCrop(e);
return;
}
// In-progress stroke (brush / eraser / inpaint / clone) — handler in
// editor/tools/stroke.js.
_strokeTool.tryContinue(e);
if (state.moving) return _continueMove(e);
if (state.cropping || state.cropMoving) return _continueCrop(e);
}
function _endDraw(e) {
if (_cursorRaf) { cancelAnimationFrame(_cursorRaf); _cursorRaf = null; }
if (_pendingCursorEvent) { _updateBrushCursor(_pendingCursorEvent); _pendingCursorEvent = null; }
if (_cursorLoupeVisible) _setCursorLoupe(false);
if (state.tool === 'pen') return _penTool.end();
if (state.tool === 'path') return _pathTool.end(e);
@@ -5543,6 +5667,7 @@ function _endDraw(e) {
// Stroke end (brush / eraser / inpaint / clone) — handler in
// editor/tools/stroke.js.
_strokeTool.tryEnd(e);
_invalidateCoordsCaches();
}
// Floating popup that appears after an inpaint stroke so the user can
@@ -5579,19 +5704,33 @@ const _endStroke = _strokePipeline.endStroke;
// ── Brush cursor overlay ──
function _updateBrushCursor(e) {
if (!state.mainCanvas) return;
const rect = state.mainCanvas.getBoundingClientRect();
const now = performance.now();
let rect;
if (_lastCursorRect && (state.drawing || (now - _lastCursorRectTime < 32))) {
rect = _lastCursorRect;
} else {
rect = state.mainCanvas.getBoundingClientRect();
_lastCursorRect = rect;
_lastCursorRectTime = now;
}
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) return;
if (!state.cursorEl) {
let layer = document.getElementById('ge-cursor-layer');
if (!layer) {
layer = document.createElement('div');
layer.id = 'ge-cursor-layer';
layer.style.cssText = 'position:fixed;inset:0;pointer-events:none;contain:strict;z-index:2147483647;';
document.body.appendChild(layer);
}
state.cursorEl = document.createElement('div');
state.cursorEl.className = 'ge-brush-cursor';
document.body.appendChild(state.cursorEl);
layer.appendChild(state.cursorEl);
}
// Lasso uses the feather radius (or a sensible default) so the circle
@@ -5613,14 +5752,23 @@ function _updateBrushCursor(e) {
// or responsive layout changes the canvas independently of nominal zoom.
const diameterX = basePx * (rect.width / Math.max(1, state.mainCanvas.width));
const diameterY = basePx * (rect.height / Math.max(1, state.mainCanvas.height));
state.cursorEl.style.width = diameterX + 'px';
state.cursorEl.style.height = diameterY + 'px';
if (state._lastCursorDiameterX !== diameterX || state._lastCursorDiameterY !== diameterY) {
state._lastCursorDiameterX = diameterX;
state._lastCursorDiameterY = diameterY;
state.cursorEl.style.width = diameterX + 'px';
state.cursorEl.style.height = diameterY + 'px';
}
state.cursorEl.classList.toggle('ge-brush-cursor--pixel', pixelBrush);
state.cursorEl.style.left = (clientX - diameterX / 2) + 'px';
state.cursorEl.style.top = (clientY - diameterY / 2) + 'px';
state.cursorEl.style.display = 'block';
state.cursorEl.dataset.clientX = String(clientX);
state.cursorEl.dataset.clientY = String(clientY);
state.cursorEl.style.transform = `translate3d(${clientX - diameterX / 2}px, ${clientY - diameterY / 2}px, 0)`;
if (state.cursorEl.style.display !== 'block') {
state.cursorEl.style.display = 'block';
}
state.cursorClientX = clientX;
state.cursorClientY = clientY;
if (!state.drawing) {
state.cursorEl.dataset.clientX = String(clientX);
state.cursorEl.dataset.clientY = String(clientY);
}
if (state.tool === 'inpaint') {
// Visual cue for paint vs erase mode. Ctrl+Alt held mid-hover also
// flips the cursor so the user sees the effective mode before they
@@ -5641,9 +5789,13 @@ function _updateBrushCursor(e) {
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 borderColor = state.tool === 'eraser' ? 'rgba(255,255,255,0.6)' : state.color;
if (state._lastCursorBorderColor !== borderColor) {
state._lastCursorBorderColor = borderColor;
state.cursorEl.style.borderColor = borderColor;
state.cursorEl.style.background = 'transparent';
state.cursorEl.style.borderStyle = 'solid';
}
}
}
@@ -9476,6 +9628,7 @@ function _getFitZoom() {
}
function _applyZoom({ resetPan = false } = {}) {
_invalidateCoordsCaches();
if (!state.mainCanvas) return;
state.mainCanvas.style.width = (state.imgWidth * state.zoom) + 'px';
state.mainCanvas.style.height = (state.imgHeight * state.zoom) + 'px';
@@ -9485,6 +9638,9 @@ function _applyZoom({ resetPan = false } = {}) {
const area = state.container && state.container.querySelector('.ge-canvas-area');
if (resetPan && area && area._resetPan) area._resetPan();
_syncSelectionOverlay();
// The transform overlay is sized and placed for the zoom it was drawn at;
// a refit (e.g. the phone Layers sheet collapsing) must redraw it.
if (state.transformActive) _drawTransformHandles();
_precisionGuides?.redrawRulers();
}
@@ -10337,7 +10493,10 @@ function _buildEditor(container) {
if (e) _eyedropperTool.preview(e);
},
syncZoomControls: () => _syncZoomControls(),
onViewportChange: () => _precisionGuides?.redrawRulers(),
onViewportChange: () => {
_precisionGuides?.redrawRulers();
if (state.transformActive) _drawTransformHandles();
},
onTwoFingerTap: () => undo(),
onThreeFingerTap: () => redo(),
});
@@ -10405,7 +10564,7 @@ function _buildEditor(container) {
composite();
},
});
const { rightPanel, controls, layerPanel, dock } = buildRightPanel({
const { rightPanel, controls, mobilePanels, layerPanel, dock } = buildRightPanel({
controlsHTML: _controlsHTML,
layerPanelHTML: _layerPanelHTML,
extraPanels: [
@@ -10434,6 +10593,7 @@ function _buildEditor(container) {
});
editorBody.appendChild(rightPanel);
panelVisibility.apply();
panelVisibility.subscribe(_invalidateCoordsCaches);
window.gePanelVisibility = panelVisibility;
// Remember the workspace/window layout the user last chose and restore it on
// open, so the editor comes back the way they left it. A fresh session has no
@@ -10502,16 +10662,72 @@ function _buildEditor(container) {
_preflight?.maybeOpen();
} catch (_) {}
try { _initControlKit(container); } catch (_) {}
// Tool options live in the options bar (Photoshop), not the dock. On
// desktop the controls panel is moved under the contextual strip so the
// dock is reserved for Color / Adjustments / Layers; on mobile
// right-panel.js already re-parents it into a bottom sheet.
if (window.innerWidth > 700 && controls) {
optionsBar.appendChild(controls);
controls.classList.add('ge-controls--in-options');
// The "..." overflow affordance is shown/hidden per tool inside
// renderOptionsBar (it only reveals the secondary brush rows).
}
let _lastLayoutDesktop = null;
const _syncResponsiveLayout = (force = false) => {
_invalidateCoordsCaches();
const isDesktop = window.innerWidth > 700;
if (!force && isDesktop === _lastLayoutDesktop) return;
_lastLayoutDesktop = isDesktop;
const footer = container.querySelector('.ge-editor-footer');
const hdr = container.querySelector('.ge-layers-header');
const fh = container.querySelector('.ge-footer-history');
const fz = container.querySelector('.ge-footer-zoom');
const setupBtn = document.getElementById('ge-setup-btn');
const aiBtn = document.getElementById('ge-ai-settings-btn');
const optionsBar = container.querySelector('.ge-options-bar');
if (isDesktop) {
// Reparent controls into optionsBar
if (controls && optionsBar && controls.parentElement !== optionsBar) {
optionsBar.appendChild(controls);
}
controls?.classList.add('ge-controls--in-options');
controls?.classList.remove('dismissed');
optionsBar?.classList.add('ge-options-bar--has-controls');
// Right panel dock mode
rightPanel?.classList.add('ge-right-panel--dock');
if (dock?.dock) dock.dock.style.display = '';
if (mobilePanels) mobilePanels.style.display = 'none';
const panelsBtn = layerPanel?.querySelector('.ge-layers-panels-btn');
if (panelsBtn) panelsBtn.style.display = 'none';
// Clear mobile footer docking on desktop
if (fh && footer && fh.parentElement !== footer) {
footer.insertBefore(fh, footer.firstChild);
}
_placeMobileFooter();
} else {
// Reparent controls into editor container (mobile sheet)
if (controls && controls.parentElement !== container) {
container.appendChild(controls);
}
controls?.classList.remove('ge-controls--in-options');
optionsBar?.classList.remove('ge-options-bar--has-controls');
// Right panel mobile sheet mode
rightPanel?.classList.remove('ge-right-panel--dock');
if (dock?.dock) dock.dock.style.display = 'none';
if (mobilePanels) mobilePanels.style.display = '';
const panelsBtn = layerPanel?.querySelector('.ge-layers-panels-btn');
if (panelsBtn) panelsBtn.style.display = '';
if (fh && hdr && fh.parentElement !== hdr) {
hdr.appendChild(fh);
}
// Dock footer flush to sheet on mobile
_placeMobileFooter();
}
};
_syncResponsiveLayout(true);
try {
const _mobileMql = window.matchMedia('(max-width: 700px)');
_mobileMql.addEventListener('change', () => _syncResponsiveLayout(true));
} catch (_) {}
window.addEventListener('resize', () => _syncResponsiveLayout(false), { passive: true });
// Crop options: aspect-ratio presets (Free / Original / 1:1 / 4:5 / 16:9).
// Choosing one locks new crop drags to the ratio and refits an existing
// crop rect (centred, clamped to the canvas). Wired after the controls
@@ -11931,7 +12147,13 @@ function _buildEditor(container) {
const flat = _aiEditSourceCanvas();
const image = flat.toDataURL('image/png').split(',')[1];
const requestId = 'qe-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
_startAiProgress(requestId);
const qeModel = _getSelectedQuickEditModel();
const endpointId = payload._endpoint || qeModel.endpoint || '';
if (endpointId) {
_startAiProgress(requestId, endpointId);
} else {
_startAiProgress(requestId);
}
const body = JSON.stringify({ image, request_id: requestId, ...payload });
// The image servers occasionally reject a request transiently (503 while a
// model swaps, 502/504 gateways, dropped connections). Retry those a couple
@@ -13497,6 +13719,11 @@ export function openEditor(imageUrl, imageId, presetSize, displayName, draftId)
state.draftName = displayName || (presetSize ? `New ${presetSize.w}×${presetSize.h}` : 'Untitled');
state.editorSessionToken += 1;
state.editorOpen = true;
state.documentRevision = 0;
state.persistingRevision = 0;
state.savedRevision = 0;
state.persistRetryCount = 0;
if (state.persistRetryTimer) { clearTimeout(state.persistRetryTimer); state.persistRetryTimer = null; }
_writeActiveEditorSession();
state.layers = [];
state.selectedLayerIds = [];
@@ -13547,6 +13774,8 @@ export function openEditor(imageUrl, imageId, presetSize, displayName, draftId)
_stopSelectionAnimation();
window.__galleryEditLive = true;
if (state.persistTimer) { clearTimeout(state.persistTimer); state.persistTimer = null; }
if (state.persistRetryTimer) { clearTimeout(state.persistRetryTimer); state.persistRetryTimer = null; }
if (state.pendingStatusTimer) { clearTimeout(state.pendingStatusTimer); state.pendingStatusTimer = null; }
state.persistDirty = false;
state.persistErrorMessage = null;
state.persistInFlight = null;
@@ -13793,6 +14022,8 @@ export function closeEditor(options = {}) {
// the editor mid-stroke doesn't lose work. The call is fire-and-forget;
// the server commit lands shortly after the modal hides.
if (state.persistTimer) { clearTimeout(state.persistTimer); state.persistTimer = null; }
if (state.persistRetryTimer) { clearTimeout(state.persistRetryTimer); state.persistRetryTimer = null; }
if (state.pendingStatusTimer) { clearTimeout(state.pendingStatusTimer); state.pendingStatusTimer = null; }
if (state.layers.length) {
try { _persistDraft(); } catch {}
}
@@ -13831,6 +14062,7 @@ export function closeEditor(options = {}) {
try { document.removeEventListener('click', h); } catch {}
}
if (state.cursorEl) { state.cursorEl.remove(); state.cursorEl = null; }
try { document.getElementById('ge-cursor-layer')?.remove(); } catch {}
// Tear down all floating popups + the dock so closing the editor
// doesn't leave stale chips/panels behind on top of the gallery.
try { _closeFxMenu(); } catch {}