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
+99 -17
View File
@@ -111,7 +111,9 @@
flex: 0 0 auto;
flex-wrap: nowrap;
row-gap: 4px;
min-height: 40px;
height: 44px;
min-height: 44px;
max-height: 44px;
padding: 4px 10px;
box-sizing: border-box;
border-bottom: 1px solid var(--border);
@@ -206,9 +208,19 @@
pushed to the right edge. */
flex: 1 1 auto;
}
/* When transform section is active in controls, suppress any underlying tool section
so transform controls (Cancel, Apply, X, Y, W, H) have the full width and are not
overlapped or crowded by tool options (e.g. marquee invert/erase buttons). */
.ge-controls.ge-transform-active > div:not(#ge-transform-section),
.ge-controls:has(#ge-transform-section:not([style*="display:none"]):not([style*="display: none"])) > div:not(#ge-transform-section),
.ge-controls--in-options:has(#ge-transform-section:not([style*="display:none"]):not([style*="display: none"])) > div:not(#ge-transform-section) {
display: none !important;
}
/* The Text tool's option set is too wide for the thin strip: let the bar grow
and wrap onto extra rows instead of clipping the trailing fields. */
.ge-options-bar.ge-options-tall {
height: auto;
max-height: none;
flex-wrap: wrap;
align-items: center;
overflow-y: visible;
@@ -254,7 +266,7 @@
right after the caret instead of leaving a wide gap. */
.ge-controls--in-options #ge-brush-controls { flex: 0 0 auto; }
.ge-controls--in-options .ge-control-row {
margin: 0;
margin: 0 !important;
flex: 0 0 auto;
display: flex;
flex-direction: row;
@@ -488,8 +500,12 @@
}
.ge-opt-empty { font-size: 11px; opacity: 0.5; }
.ge-options-empty .ge-options-sep { display: none; }
/* An editor opened at phone width keeps its tool controls in their own bottom
sheet, so the strip has nothing to show. One opened wider than 700px moves
the controls into this strip; hiding it then would strand every tool control
(tablet widths of 701-820px, or a window narrowed after opening). */
@media (max-width: 820px) {
.ge-options-bar { display: none; }
.ge-options-bar:not(:has(.ge-controls--in-options)) { display: none; }
}
/* Document tab strip (Photoshop-style) — names the open document and
@@ -4198,7 +4214,11 @@ body.ge-editor-open textarea.ge-direct-text-editor {
flex-direction: column;
gap: 2px;
}
.ge-save-state-icon { display: none; }
.ge-save-state-icon { display: inline-block; vertical-align: middle; }
.ge-save-state-icon[hidden] { display: none !important; }
.ge-save-state-saved { color: var(--fg); }
.ge-save-state-dirty { color: var(--accent, var(--red)); }
.ge-save-state-error { color: var(--danger, #ef4444); }
.ge-save-label { white-space: nowrap; min-width: 0; }
/* Save button: glyph on its own row, then the SAVE action label and the
live draft-status chip share the second row. */
@@ -6341,6 +6361,10 @@ button .spinner-whirlpool {
box-shadow: 0 0 0 1px rgba(0,0,0,0.72);
display: none;
box-sizing: border-box;
contain: strict;
will-change: transform;
top: 0;
left: 0;
}
/* Pixel brush: a hard square footprint instead of a feathered circle. */
.ge-brush-cursor.ge-brush-cursor--pixel {
@@ -6622,6 +6646,7 @@ button .spinner-whirlpool {
}
.ge-right-panel.expanded {
transform: translateY(0);
z-index: 100;
}
/* Minimized: panel slides almost fully off-screen — only a thin
handle strip remains visible so the user can swipe back up. */
@@ -8134,7 +8159,12 @@ input.ge-ctl-check:focus-visible {
}
.ge-setup-btn:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
@media (max-width: 700px) {
/* Cover the phone viewport rather than the small, scrollable canvas area;
the layers sheet otherwise hides the checklist's lower controls. */
.ge-preflight { position: fixed; }
.ge-preflight-card { padding: 13px 14px; }
.ge-preflight-foot { flex-wrap: wrap; }
.ge-preflight-start { white-space: nowrap; }
}
/* ── Quick Edit — premium look ────────────────────────────────────────── */
@@ -8296,17 +8326,37 @@ input.ge-ctl-check:focus-visible {
gap: 6px;
padding: 3px 6px;
overflow: hidden;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
flex-wrap: nowrap;
white-space: nowrap;
min-width: 0;
}
.ge-editor-footer::-webkit-scrollbar {
display: none;
}
.ge-editor-footer .ge-status-tool,
.ge-editor-footer .ge-status-model,
.ge-editor-footer .ge-canvas-size {
display: none;
}
.ge-footer-history { margin-right: 2px; }
.ge-footer-zoom { gap: 3px; min-width: 0; }
.ge-footer-history {
margin-right: 2px;
flex-shrink: 0;
}
.ge-footer-zoom {
gap: 3px;
min-width: 0;
flex-shrink: 0;
margin-left: 0;
}
.ge-footer-zoom > .ge-btn,
.ge-footer-history > .ge-btn { min-width: 26px; }
.ge-setup-btn {
flex-shrink: 0;
}
/* Float the footer above the peek/expanded layers sheet so its zoom/setup
controls stay tappable (position/left/right/bottom set in JS). */
.ge-editor-footer.ge-footer-floating {
@@ -8317,13 +8367,42 @@ input.ge-ctl-check:focus-visible {
}
}
/* Phones: the floating zoom/Setup footer collides with the peek/expanded
layers sheet and the Quick Edit card, so remove it entirely on phones.
Undo/Redo/History already live in the layers-sheet header, and pinch-zoom
replaces the zoom buttons. */
/* Mobile layers header layout */
@media (max-width: 700px) {
.ge-editor-footer {
display: none !important;
.ge-layers-header {
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
flex-wrap: nowrap;
white-space: nowrap;
gap: 4px;
padding-right: 8px;
}
.ge-layers-header::-webkit-scrollbar {
display: none;
}
.ge-layers-header .ge-footer-zoom {
display: inline-flex;
align-items: center;
gap: 3px;
flex-shrink: 0;
}
.ge-layers-header .ge-footer-zoom > .ge-btn,
.ge-layers-header .ge-footer-history > .ge-btn {
min-width: 28px;
min-height: 28px;
}
.ge-layers-header .ge-setup-btn {
flex-shrink: 0;
min-height: 28px;
padding: 3px 8px;
font-size: 11px;
border-radius: 6px;
border: 1px solid var(--border);
background: color-mix(in srgb, var(--panel) 85%, var(--fg) 15%);
color: var(--fg);
cursor: pointer;
}
/* The Quick Edit card lifts over the layers sheet / keyboard; keep it above
every other editor asset (sheet headers, tool rail, popups) while lifted. */
@@ -8579,6 +8658,7 @@ body.ge-editor-open .toast.toast-transform-hint {
/* Keep the floating editor footer from stacking on the Quick Edit card. The
card owns the bottom of the canvas while it is open, so the footer (zoom /
Setup / AI settings) hides until it closes. */
body.ge-qe-open .ge-editor-footer.ge-footer-floating,
body:has(.ge-ai-command:not(.ge-ai-command-collapsed)) .ge-editor-footer.ge-footer-floating {
display: none;
}
@@ -8657,16 +8737,14 @@ body.ge-editor-open .toast.toast-transform-hint {
}
}
/* Phone menu bar: tighten the Photoshop menus and keep the Save control
icon-only so the twelve menus + Import + Save are not crowded. */
/* Phone menu bar: tighten the Photoshop menus. The compact topbar already
drops the Save action label; the draft status stays visible because it is
the save button's only announced (role="status") save state. */
@media (max-width: 700px) {
.ge-topbar-left .ge-stacked-btn {
font-size: 11px;
padding: 0 6px !important;
}
.ge-topbar.ge-topbar-compact #ge-draft-status {
display: none;
}
}
/* Phone editor: dock the zoom/Setup footer flush to the Layers sheet. The
@@ -9675,12 +9753,15 @@ body.ge-editor-open .tour-hint {
}
/* The dropdowns clip overflow to draw the custom scroll thumb; release the
clip while a flyout is open/hovered so it can extend past the menu edge. */
.ge-filter-menu.ge-flyout-open,
.ge-filter-menu:has(.ge-menu-parent.open),
.ge-filter-menu:has(.ge-menu-parent:hover),
.ge-filter-menu:has(.ge-submenu:hover),
.ge-image-menu.ge-flyout-open,
.ge-image-menu:has(.ge-menu-parent.open),
.ge-image-menu:has(.ge-menu-parent:hover),
.ge-image-menu:has(.ge-submenu:hover),
.ge-selection-menu.ge-flyout-open,
.ge-selection-menu:has(.ge-menu-parent.open),
.ge-selection-menu:has(.ge-menu-parent:hover),
.ge-selection-menu:has(.ge-submenu:hover) {
@@ -9689,6 +9770,7 @@ body.ge-editor-open .tour-hint {
mask-image: none;
}
/* The Window menu's Workspace flyout needs the same clip release. */
.ge-window-menu.ge-flyout-open,
.ge-window-menu:has(.ge-menu-parent.open),
.ge-window-menu:has(.ge-menu-parent:hover),
.ge-window-menu:has(.ge-submenu:hover) {
+21 -7
View File
@@ -72,7 +72,12 @@ function hexToHsv(hex) { const { r, g, b } = hexToRgb(hex); return rgbToHsv(r, g
// ── Storage ───────────────────────────────────────────────────────────
function getRecents() {
try { return JSON.parse(localStorage.getItem(LS_RECENT) || '[]'); }
try {
const values = JSON.parse(localStorage.getItem(LS_RECENT) || '[]');
return Array.isArray(values)
? values.filter(value => typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value)).slice(0, MAX_RECENT)
: [];
}
catch { return []; }
}
@@ -85,17 +90,26 @@ function addRecent(hex) {
}
// ── Suggestions based on current color (5 harmony swatches) ──────────
function computeSuggestions() {
function suggestionsForHsv(h, s, v) {
// Complement, analogous ±30°, split-complement (+150), tone shift
return [
{ hex: hsvToHex(_h + 180, _s, _v), label: 'Complement' },
{ hex: hsvToHex(_h + 30, _s, _v), label: 'Analogous +30°' },
{ hex: hsvToHex(_h - 30, _s, _v), label: 'Analogous -30°' },
{ hex: hsvToHex(_h + 150, _s, _v), label: 'Split-complement' },
{ hex: hsvToHex(_h, _s, clamp(_v > 50 ? _v - 30 : _v + 30, 10, 95)), label: 'Tone shift' },
{ hex: hsvToHex(h + 180, s, v), label: 'Complement' },
{ hex: hsvToHex(h + 30, s, v), label: 'Analogous +30°' },
{ hex: hsvToHex(h - 30, s, v), label: 'Analogous -30°' },
{ hex: hsvToHex(h + 150, s, v), label: 'Split-complement' },
{ hex: hsvToHex(h, s, clamp(v > 50 ? v - 30 : v + 30, 10, 95)), label: 'Tone shift' },
];
}
function computeSuggestions() { return suggestionsForHsv(_h, _s, _v); }
export function getRecentColors() { return getRecents(); }
export function recordRecentColor(hex) { addRecent(hex); }
export function suggestColorsFor(hex) {
const { h, s, v } = hexToHsv(hex);
return suggestionsForHsv(h, s, v);
}
// ── Popover build ─────────────────────────────────────────────────────
function buildPopover() {
const p = document.createElement('div');
+68 -55
View File
@@ -82,10 +82,9 @@ export function resolveQuickEditAgentModel() {
return { agent, endpoint, label: ((opt && opt.textContent) || '').trim() };
}
// Quick Edit agent (planner) model options. Kept small + explicit; the local
// Qwen3.5-9B VL is the default. `value` encodes "<endpoint>::<model>".
// Quick Edit agent (planner) model options. Kept small + explicit;
// dynamically populated from /api/editor/agent-models.
export const QUICK_EDIT_AGENT_OPTIONS = [
{ value: 'http://100.69.120.65:8097/v1::Qwen/Qwen3.5-9B', label: 'Qwen3.5-9B VL · local' },
{ value: 'default', label: 'Server default (hosted planner)' },
];
@@ -167,12 +166,29 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
const aiCommandAgentSelect = document.getElementById('ge-ai-command-agent-model');
if (aiInpaintSelect) aiInpaintSelect.addEventListener('change', () => updateFooterEditModel());
if (aiCommandSelect) aiCommandSelect.addEventListener('change', () => updateFooterEditModel());
// The Quick Edit agent (planner) picker is static for now (local VL default,
// server default option); restore/save its choice from localStorage.
const populateAgentSelect = () => {
// The Quick Edit agent (planner) picker: dynamically populates from
// /api/editor/agent-models; restore/save choice from localStorage.
const populateAgentSelect = async () => {
if (!aiCommandAgentSelect) return;
const prevVal = aiCommandAgentSelect.value;
let agentOptions = [...QUICK_EDIT_AGENT_OPTIONS];
try {
const res = await fetch(`${apiBase}/api/editor/agent-models`);
if (res.ok) {
const data = await res.json();
const serverModels = data.models || [];
for (const m of serverModels) {
const optVal = `${m.source === 'local' ? 'local' : (m.endpoint_id ? String(m.endpoint_id) : (m.base_url || ''))}::${m.id}`;
const label = m.label || m.id;
if (!agentOptions.some(o => o.value === optVal)) {
agentOptions.push({ value: optVal, label });
}
}
}
} catch (_) {}
aiCommandAgentSelect.innerHTML = '';
for (const opt of QUICK_EDIT_AGENT_OPTIONS) {
for (const opt of agentOptions) {
const el = document.createElement('option');
el.value = opt.value;
el.textContent = opt.label;
@@ -182,6 +198,8 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
const saved = localStorage.getItem('ge-ai-command-agent-model');
if (saved && [...aiCommandAgentSelect.options].some(o => o.value === saved)) {
aiCommandAgentSelect.value = saved;
} else if (prevVal && [...aiCommandAgentSelect.options].some(o => o.value === prevVal)) {
aiCommandAgentSelect.value = prevVal;
}
} catch {}
if (!aiCommandAgentSelect._agentWired) {
@@ -196,16 +214,17 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
// select nor the inpaint select nor any per-tool select).
if (!aiGenSelect && !aiInpaintSelect && !aiCommandSelect && !aiCommandAgentSelect &&
!document.querySelector('select.ge-tool-model')) return;
populateAgentSelect();
void populateAgentSelect();
async function loadAIModels(opts = {}) {
try {
const selectBaseUrl = opts.selectBaseUrl || '';
const prevGenValue = aiGenSelect?.value || '';
const prevInpaintValue = aiInpaintSelect?.value || '';
const res = await fetch(`${apiBase}/api/model-endpoints`);
const res = await fetch(`${apiBase}/api/editor/image-models`);
if (!res.ok) throw new Error(`Could not load image models (${res.status})`);
const endpoints = await res.json();
const data = await res.json();
const modelsList = data.models || [];
if (aiGenSelect) aiGenSelect.innerHTML = '<option value="">None</option>';
if (aiInpaintSelect) aiInpaintSelect.innerHTML = '<option value="">Auto</option>';
if (aiCommandSelect) aiCommandSelect.innerHTML = '<option value="">Auto (default)</option>';
@@ -215,59 +234,52 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
let selectedGen = null;
let selectedInpaint = null;
const loaded = [];
for (const ep of endpoints) {
if (!ep.is_enabled) continue;
const hasListedModels = Array.isArray(ep.models) && ep.models.length;
const models = hasListedModels ? sortModelIds(ep.models) : [''];
const isImageEndpoint = (ep.model_type || '').toLowerCase() === 'image';
// Image/inpaint endpoints can be called by URL even when their
// /models cache is still empty, so don't strand a freshly served
// Cookbook model as "(offline)" in the editor picker.
const epUsable = !!ep.online || isImageEndpoint;
for (const modelId of models) {
const caps = modelCaps(modelId || ep.name, ep.name, ep.model_type);
if (!caps.gen && !caps.inpaint) continue;
// Encode "<base_url>::<model_id>" so the value carries both pieces.
const value = `${ep.base_url}::${modelId}`;
const shortModel = modelId ? String(modelId).split('/').pop() : (ep.name || ep.base_url);
const epHint = modelId && ep.name && ep.name !== modelId ? ` · ${ep.name}` : '';
const label = `${shortModel}${epHint}${epUsable ? '' : ' (offline)'}`;
loaded.push({ value, label, gen: caps.gen, inpaint: caps.inpaint, usable: epUsable, endpoint: ep.base_url, model: modelId });
if (caps.gen && aiGenSelect) {
for (const m of modelsList) {
const modelId = m.id || '';
const caps = modelCaps(modelId, m.provider, m.kind);
const canGen = Boolean(caps.gen || m.kind === 'generate');
const canInpaint = Boolean(caps.inpaint || m.kind === 'edit');
if (!canGen && !canInpaint) continue;
// Use opaque endpoint_id if available; fallback to provider name or base_url for admin
const endpointKey = m.endpoint_id ? String(m.endpoint_id) : (m.base_url || '');
const value = endpointKey ? `${endpointKey}::${modelId}` : modelId;
const shortModel = modelId ? String(modelId).split('/').pop() : (m.label || m.provider);
const epHint = m.provider && m.provider !== modelId && m.provider !== 'image' ? ` · ${m.provider}` : '';
const label = `${shortModel}${epHint}`;
const epUsable = true;
loaded.push({ value, label, gen: canGen, inpaint: canInpaint, usable: epUsable, endpoint: endpointKey, model: modelId });
if (canGen && aiGenSelect) {
const opt = document.createElement('option');
opt.value = value;
opt.textContent = label;
opt.disabled = !epUsable;
aiGenSelect.appendChild(opt);
if (epUsable && !firstGen) firstGen = value;
if (epUsable && selectBaseUrl && (endpointKey === selectBaseUrl || m.base_url === selectBaseUrl) && !selectedGen) selectedGen = value;
}
if (canInpaint && aiInpaintSelect) {
const opt = document.createElement('option');
opt.value = value;
opt.textContent = label;
opt.disabled = !epUsable;
aiInpaintSelect.appendChild(opt);
if (epUsable && selectBaseUrl && (endpointKey === selectBaseUrl || m.base_url === selectBaseUrl) && !selectedInpaint) selectedInpaint = value;
}
if (canInpaint || canGen) {
for (const ts of perToolSelects) {
const opt = document.createElement('option');
opt.value = value;
opt.textContent = label;
opt.disabled = !epUsable;
aiGenSelect.appendChild(opt);
if (epUsable && !firstGen) firstGen = value;
if (epUsable && selectBaseUrl && ep.base_url === selectBaseUrl && !selectedGen) selectedGen = value;
ts.appendChild(opt);
}
if (caps.inpaint && aiInpaintSelect) {
if (aiCommandSelect) {
const opt = document.createElement('option');
opt.value = value;
opt.textContent = label;
opt.disabled = !epUsable;
aiInpaintSelect.appendChild(opt);
if (epUsable && selectBaseUrl && ep.base_url === selectBaseUrl && !selectedInpaint) selectedInpaint = value;
}
// Per-tool selectors get every img2img-capable entry. Both
// caps.inpaint AND caps.gen models work for harmonize /
// style / upscale (anything that can do img2img).
if (caps.inpaint || caps.gen) {
for (const ts of perToolSelects) {
const opt = document.createElement('option');
opt.value = value;
opt.textContent = label;
opt.disabled = !epUsable;
ts.appendChild(opt);
}
if (aiCommandSelect) {
const opt = document.createElement('option');
opt.value = value;
opt.textContent = label;
opt.disabled = !epUsable;
aiCommandSelect.appendChild(opt);
}
aiCommandSelect.appendChild(opt);
}
}
}
@@ -374,7 +386,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
wireServe(aiInpaintSelect);
wireServe(aiCommandSelect);
document.querySelectorAll('select.ge-tool-model').forEach(wireServe);
populateAgentSelect();
void populateAgentSelect();
updateFooterEditModel();
_aiModelsStatus = { ready: false, models: [], error: String(e?.message || e), at: Date.now() };
try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {}
@@ -387,6 +399,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
return;
}
loadAIModels({ selectBaseUrl: e.detail?.baseUrl || '' });
void populateAgentSelect();
};
window.addEventListener('ge:model-endpoints-updated', onModelEndpointsUpdated);
// Re-fetch the model list when the user opens the inpaint dropdown,
+10 -7
View File
@@ -181,7 +181,7 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = []
// entries in a compact `<select>` overlay reachable from a "Panels"
// button in the layers header, without touching the peek/expand model.
let mobilePanels = null;
if (!useDock && Array.isArray(extraPanels) && extraPanels.length) {
if (Array.isArray(extraPanels) && extraPanels.length) {
const mDock = buildDock({
entries: extraPanels,
persistKey: 'ge-dock-active',
@@ -218,7 +218,9 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = []
overlay.hidden = !overlay.hidden;
});
hdr.appendChild(panelsBtn);
if (useDock) panelsBtn.style.display = 'none';
}
if (useDock) overlay.style.display = 'none';
mobilePanels = overlay;
}
@@ -226,7 +228,7 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = []
// The panels are only re-parented, so every existing selector and
// listener keeps working; switching tabs just toggles `hidden`.
let dock = null;
if (useDock) { rightPanel.classList.add('ge-right-panel--dock');
if (Array.isArray(extraPanels) && extraPanels.length) {
dock = buildDock({
entries: [
...(Array.isArray(extraPanels) ? extraPanels : []),
@@ -239,11 +241,12 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = []
onCloseGroup,
});
rightPanel.appendChild(dock.dock);
// Layers stays pinned below the dock so the layer list is always
// visible while editing (Photoshop-style), regardless of the active
// dock tab. Only the tool options + studio panels live in tabs.
rightPanel.appendChild(layerPanel);
if (!useDock) dock.dock.style.display = 'none';
}
if (useDock) {
rightPanel.classList.add('ge-right-panel--dock');
}
rightPanel.appendChild(layerPanel);
// Horizontal drag handle on the LEFT edge of the right panel — drag
// left to widen, right to narrow. Persists chosen width in
@@ -286,5 +289,5 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = []
} catch {}
});
return { rightPanel, controls, layerPanel, panelResize, dock };
return { rightPanel, controls, layerPanel, panelResize, dock, mobilePanels };
}
+7 -1
View File
@@ -242,7 +242,13 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
: soft >= 70
? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="6.5" fill="currentColor" opacity="0.3"/><circle cx="12" cy="12" r="6.5"/></svg>'
: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><circle cx="12" cy="12" r="6.5" fill="currentColor" opacity="0.65"/><circle cx="12" cy="12" r="6.5"/></svg>';
presetItem.insertAdjacentHTML('beforeend', `<span class="ge-tool-icon">${presetIcon}</span><span class="ge-tool-flyout-label">${preset.name}</span>`);
const icon = document.createElement('span');
icon.className = 'ge-tool-icon';
icon.innerHTML = presetIcon;
const label = document.createElement('span');
label.className = 'ge-tool-flyout-label';
label.textContent = preset.name;
presetItem.append(icon, label);
presetItem.addEventListener('click', (ev) => {
ev.stopPropagation();
setInner(face, 'brush');
+1
View File
@@ -535,6 +535,7 @@ export function buildTopbar() {
<span class="ge-stacked-glyph">
<svg hidden class="ge-save-state-icon ge-save-state-dirty" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 3h12l4 4v14H4z"/><path d="M8 3v6h8V3"/><path d="m16 3 6 6m0-6-6 6" stroke="var(--fg)" stroke-width="3"/></svg>
<svg class="ge-save-state-icon ge-save-state-saved" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
<svg hidden class="ge-save-state-icon ge-save-state-error" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
</span>
<span class="ge-stacked-label ge-save-action-label">SAVE</span>
<span class="ge-stacked-label ge-save-label" id="ge-draft-status" role="status" aria-live="polite">Saved</span>
+22 -1
View File
@@ -8,8 +8,29 @@
* @param {HTMLCanvasElement} canvas
* @returns {{x: number, y: number}}
*/
import { state } from './state.js';
let _cachedCanvas = null;
let _cachedRect = null;
let _cachedRectTime = 0;
export function invalidateCanvasCoordsCache() {
_cachedCanvas = null;
_cachedRect = null;
_cachedRectTime = 0;
}
export function canvasCoords(e, canvas) {
const rect = canvas.getBoundingClientRect();
const now = performance.now();
let rect;
if (_cachedCanvas === canvas && _cachedRect && (state.drawing || (now - _cachedRectTime < 64))) {
rect = _cachedRect;
} else {
rect = canvas.getBoundingClientRect();
_cachedCanvas = canvas;
_cachedRect = rect;
_cachedRectTime = now;
}
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const clientX = e.touches && e.touches.length ? e.touches[0].clientX : e.clientX;
+11 -1
View File
@@ -44,6 +44,7 @@
* }} ctx
*/
import { state } from './state.js';
import { invalidateCanvasCoordsCache } from './canvas-coords.js';
import {
applyCanvasPan,
isDirectPanIntent,
@@ -58,6 +59,12 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
canvasWindowBindings?.abort();
canvasWindowBindings = new AbortController();
const { signal } = canvasWindowBindings;
// Invalidate coordinate cache on layout, scroll, resize or orientation shifts
window.addEventListener('resize', invalidateCanvasCoordsCache, { signal, passive: true });
window.addEventListener('scroll', invalidateCanvasCoordsCache, { signal, passive: true });
window.addEventListener('orientationchange', invalidateCanvasCoordsCache, { signal, passive: true });
canvasArea.addEventListener('scroll', invalidateCanvasCoordsCache, { passive: true });
let suppressMouseUntil = 0;
// Tool-aware right-click menu on the image and the surrounding area.
wireCanvasContextMenu(canvasArea, { signal });
@@ -90,7 +97,7 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
window.addEventListener('mousemove', (e) => {
if (Date.now() < suppressMouseUntil) return;
// Only shield the raw-coordinate selection/crop rects from chrome.
if (isUiChrome(e) && directGestureActive()) return;
if (directGestureActive() && isUiChrome(e)) return;
continueDraw(e);
}, { signal });
window.addEventListener('mouseup', (e) => {
@@ -240,11 +247,14 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
const newZoom = Math.max(0.1, Math.min(5, multiStartZoom * ratio));
if (Math.abs(newZoom - state.zoom) > 0.001) {
state.zoom = newZoom;
// A pinch is an explicit zoom; layout refits must not undo it.
state.zoomViewMode = 'scale';
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?.();
invalidateCanvasCoordsCache();
onViewportChange?.();
}
const dx = info.cx - multiStartCenter.x;
+2 -1
View File
@@ -77,7 +77,8 @@ export function wireClipboardAndDrop({
// inside the gallery modal's role="dialog" modal-content, so that would
// swallow every paste (including the internal Clipboard + Ctrl+V).
const target = e.target;
const insideEditor = target && container && container.contains(target);
// A paste dispatched at window has a non-Node target; contains() would throw.
const insideEditor = target instanceof Node && !!container && container.contains(target);
if (insideEditor && (target.isContentEditable || target.closest?.('input, textarea, select'))) return;
const imageItem = Array.from(e.clipboardData?.items || []).find(item => item.type.startsWith('image/'));
+18 -5
View File
@@ -50,7 +50,7 @@ import {
import { drawHistogram } from './histogram.js';
import { parseCubeLut, generateCubeLut } from './cube-lut.js';
export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, getAdjustmentSource = null }) {
export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, getAdjustmentSource = null, schedulePersist = null }) {
function suppressLayerGhostTap() {
window.__geSuppressLayerTapUntil = Date.now() + 650;
}
@@ -511,6 +511,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
composite();
renderLayerPanel();
closeAdjPopup();
schedulePersist?.();
});
}
@@ -535,8 +536,14 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
if (!p.channels[channel]) p.channels[channel] = defaultAdjParams('levels').channels[channel];
return p.channels[channel];
};
// Params come from project files and drafts, which only validate the
// adjustment type; coerce anything interpolated into markup below.
const finite = value => (Number.isFinite(Number(value)) ? Number(value) : 0);
const hexColor = (value, fallback) => (/^#[0-9a-f]{6}$/i.test(value) ? value : fallback);
const revertIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>';
const sliderRow = (key, label, min, max, value, suffix, defaultValue) => `
const sliderRow = (key, label, min, max, rawValue, suffix, defaultValue) => {
const value = finite(rawValue);
return `
<div class="ge-adj-row" data-adj-key="${key}"${defaultValue != null ? ` data-default="${defaultValue}"` : ''}>
<label>${label}</label>
<input type="range" min="${min}" max="${max}" value="${value}" data-key="${key}" />
@@ -544,6 +551,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
<button class="ge-adj-revert" type="button" title="Reset this slider" data-revert-key="${key}">${revertIcon}</button>
</div>
`;
};
if (type === 'brightness-contrast') {
const bSlider = Math.round((p.brightness - 1) * 100);
const cSlider = Math.round((p.contrast - 1) * 100);
@@ -692,7 +700,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
});
} else if (type === 'color-balance') {
// Color-tinted slider ends so the user sees what direction does what.
const cbRow = (key, leftCol, rightCol, label, value) => `
const cbRow = (key, leftCol, rightCol, label, rawValue, value = finite(rawValue)) => `
<div class="ge-adj-row ge-adj-cb-row" data-adj-key="${key}">
<span class="ge-adj-cb-dot" style="background:${leftCol}"></span>
<input type="range" min="-100" max="100" value="${value}" data-key="${key}" />
@@ -749,6 +757,9 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
buildAdjBody(layer, type, body, popEl);
});
} else if (type === 'gradient-map') {
const gradientDefaults = defaultAdjParams('gradient-map');
p.shadows = hexColor(p.shadows, gradientDefaults.shadows);
p.highlights = hexColor(p.highlights, gradientDefaults.highlights);
body.innerHTML = `
<div class="ge-gradient-map-preview" style="--ge-gradient-start:${p.shadows};--ge-gradient-end:${p.highlights}"></div>
<div class="ge-gradient-color-row">
@@ -789,7 +800,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
body.innerHTML = `
<div class="ge-adj-row">
<label>Color</label>
<input type="color" data-photo-key="color" value="${p.color || '#ec8a00'}">
<input type="color" data-photo-key="color" value="${hexColor(p.color, '#ec8a00')}">
</div>
${sliderRow('density', 'Density', 0, 100, Math.round(p.density ?? 25), '%')}
<label class="ge-adj-toggle-row"><span>Preserve luminosity</span><input type="checkbox" data-photo-preserve${p.preserve_luminosity ? ' checked' : ''}><span class="ge-toggle-track"></span></label>
@@ -838,7 +849,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
const presetLabels = { none: 'None', teal_orange: 'Teal & Orange', cold: 'Cold', warm: 'Warm', high_contrast: 'High Contrast', sepia: 'Sepia' };
const strength = p.strength == null ? 100 : p.strength;
const hasCube = !!(p.cube && p.cube.size && p.cube.data);
const lutName = hasCube ? `Loaded LUT · ${p.cube.size}³` : 'No .cube loaded';
const lutName = hasCube ? `Loaded LUT · ${finite(p.cube.size)}³` : 'No .cube loaded';
body.innerHTML = `
<label class="ge-adj-channel-field">Lookup preset
<select class="ge-adj-lut-select">
@@ -972,6 +983,8 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
p.strength = raw; display = raw + '%';
} else if (type === 'white-balance') {
p[key] = raw;
} else if (type === 'vibrance') {
p[key] = raw;
} else if (type === 'black-white') {
p[key] = raw;
} else if (type === 'shadows-highlights') {
+2 -1
View File
@@ -1948,7 +1948,8 @@ export function createLayerPanelRenderer(deps) {
});
const sName = document.createElement('span');
sName.className = 'ge-layer-name ge-adj-sub-name';
sName.innerHTML = `<span class="ge-adj-sub-icon">◌</span><span>${effectLabel(effect.type)}</span>`;
sName.innerHTML = '<span class="ge-adj-sub-icon">◌</span><span></span>';
sName.lastElementChild.textContent = effectLabel(effect.type);
sName.title = 'Edit effect (drag onto another layer to copy)';
sName.addEventListener('click', event => {
event.stopPropagation();
+2 -2
View File
@@ -28,10 +28,10 @@ const GRADIENTS = [
['Spectrum', ['#ff0000', '#ffff00', '#00ff00', '#00ffff', '#0000ff', '#ff00ff']],
];
function el(tag, className, html) {
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (html != null) node.innerHTML = html;
if (text != null) node.textContent = text;
return node;
}
+5 -1
View File
@@ -274,7 +274,11 @@ export function createStudioPanels({
return;
}
shapeLayers.slice().reverse().forEach(layer => {
const button = el('button', 'ge-channel-row', `<span class="ge-channel-name">${layer.shape.type === 'path' ? 'Path' : layer.shape.type} · ${layer.name || layer.id.slice(0, 6)}</span>`);
const button = el('button', 'ge-channel-row');
const name = document.createElement('span');
name.className = 'ge-channel-name';
name.textContent = `${layer.shape.type === 'path' ? 'Path' : layer.shape.type} · ${layer.name || layer.id.slice(0, 6)}`;
button.appendChild(name);
button.type = 'button';
button.addEventListener('click', () => { onSelectLayer(layer.id); renderPaths(); });
pathBody.appendChild(button);
+6 -1
View File
@@ -400,10 +400,15 @@ export const state = {
// Loading-overlay element (whirlpool + label).
editorLoadingEl: null,
// ── Draft persistence ──
// ── Draft persistence & revision tracking ──
draftId: null,
draftName: '',
documentId: null,
documentRevision: 0,
persistingRevision: 0,
savedRevision: 0,
persistRetryTimer: null,
persistRetryCount: 0,
persistTimer: null,
// Current PUT/POST promise so concurrent saves can chain.
persistInFlight: null,
+8 -2
View File
@@ -12,15 +12,21 @@ function toolSettings(tool) {
return { opacity: state.brushOpacity, flow: state.brushFlow, softness: state.brushSoftness };
}
let _lastColor = null;
let _lastTransparentColor = null;
function transparentColor(color) {
if (color === _lastColor && _lastTransparentColor) return _lastTransparentColor;
_lastColor = color;
if (color.startsWith('#')) {
const raw = color.slice(1);
const expanded = raw.length === 3 ? raw.split('').map(char => char + char).join('') : raw;
const rgb = [0, 2, 4].map(index => parseInt(expanded.slice(index, index + 2), 16));
return `rgba(${rgb.join(',')},0)`;
_lastTransparentColor = `rgba(${rgb.join(',')},0)`;
return _lastTransparentColor;
}
const match = color.match(/rgba?\(([^)]+)\)/);
return match ? `rgba(${match[1].split(',').slice(0, 3).join(',')},0)` : 'rgba(0,0,0,0)';
_lastTransparentColor = match ? `rgba(${match[1].split(',').slice(0, 3).join(',')},0)` : 'rgba(0,0,0,0)';
return _lastTransparentColor;
}
function circleStamp(ctx, x, y, radius, softness, color, alpha, operation) {
+10
View File
@@ -10,6 +10,16 @@
* the Window > Workspace submenu (wire-extra-menus.js).
*/
export function wireSubmenuToggles(menu) {
menu.addEventListener('mouseover', (e) => {
if (e.target.closest('.ge-menu-parent, .ge-submenu')) {
menu.classList.add('ge-flyout-open');
}
});
menu.addEventListener('mouseleave', () => {
if (!menu.querySelector('.ge-menu-parent.open')) {
menu.classList.remove('ge-flyout-open');
}
});
menu.addEventListener('click', (e) => {
const parent = e.target.closest('.ge-menu-parent');
if (!parent) return;
@@ -241,11 +241,13 @@ export function createTransformSession({
}
if (state.transformPopup) {
try {
state.transformPopup.closest('.ge-controls')?.classList.remove('ge-transform-active');
state.transformPopup.innerHTML = '';
state.transformPopup.style.display = 'none';
} catch {}
state.transformPopup = null;
}
document.querySelectorAll('.ge-controls.ge-transform-active').forEach(c => c.classList.remove('ge-transform-active'));
}
let mobileBoundsHandler = null;
@@ -285,6 +287,11 @@ export function createTransformSession({
// bar) instead of a floating popup over the canvas.
const pop = document.getElementById('ge-transform-section');
if (!pop) return;
const parentControls = pop.closest('.ge-controls');
if (parentControls) {
parentControls.classList.add('ge-transform-active');
parentControls.classList.remove('dismissed');
}
pop.innerHTML = `<div class="ge-transform-popup">${transformPopupHTML()}</div>`;
const title = pop.querySelector('.ge-adj-title');
if (title && state.transformTarget === 'selection') title.textContent = 'Transform Selection';
+2 -2
View File
@@ -127,8 +127,8 @@ export function wireTopbar(deps) {
(Math.abs(state.zoom - 1) < 0.001 && state.zoomViewMode !== 'fit')) fitZoom();
else { state.zoom = 1; state.zoomViewMode = 'scale'; applyZoom(); }
});
document.getElementById('ge-zoom-in')?.addEventListener('click', () => { state.zoom = Math.min(5, state.zoom * 1.25); applyZoom(); });
document.getElementById('ge-zoom-out')?.addEventListener('click', () => { state.zoom = Math.max(0.1, state.zoom / 1.25); applyZoom(); });
document.getElementById('ge-zoom-in')?.addEventListener('click', () => { state.zoom = Math.min(5, state.zoom * 1.25); state.zoomViewMode = 'scale'; applyZoom(); });
document.getElementById('ge-zoom-out')?.addEventListener('click', () => { state.zoom = Math.max(0.1, state.zoom / 1.25); state.zoomViewMode = 'scale'; applyZoom(); });
// Export / Download / Project Save / Project Load.
document.getElementById('ge-export-gallery')?.addEventListener('click', exportToGallery);
+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 {}
+1 -1
View File
@@ -1,4 +1,4 @@
export const TOOL_WINDOW_SELECTOR = 'body > .modal, body > .research-overlay, body > .notes-pane-backdrop';
export const TOOL_WINDOW_SELECTOR = 'body > .modal:not(#styled-confirm-overlay), body > .research-overlay, body > .notes-pane-backdrop';
export function topToolWindowZ(options = {}) {
const {
+3 -1
View File
@@ -8,7 +8,7 @@ import themeModule from './theme.js?v=20260911organsrain1';
import * as Modals from './modalManager.js';
import spinnerModule from './spinner.js';
import { registerMenuDismiss, dismissTopEscapeLayer, dismissOrRemove } from './escMenuStack.js';
import { nextToolWindowZ, topToolWindowZ } from './toolWindowZOrder.js';
import { nextToolWindowZ, topToolWindowZ, topPortalZ } from './toolWindowZOrder.js';
let toastEl = null;
let autoScrollEnabled = true;
@@ -793,10 +793,12 @@ export function styledConfirm(message, { confirmText = 'Confirm', cancelText = '
const _prevFocus = document.activeElement;
overlay.classList.remove('hidden');
overlay.style.display = '';
overlay.style.zIndex = String(topPortalZ() + 1000);
function cleanup(result) {
overlay.classList.add('hidden');
overlay.style.display = 'none';
overlay.style.zIndex = '';
okBtn.removeEventListener('click', onOk);
cancelBtn.removeEventListener('click', onCancel);
altBtn.removeEventListener('click', onAlt);
+23 -3
View File
@@ -7,7 +7,7 @@
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
// - API / non-GET: never cached.
// Bump CACHE_NAME whenever the precache list or SW logic changes.
const CACHE_NAME = 'odysseus-v1113-pr40-editor-precache';
const CACHE_NAME = 'odysseus-v1114-pr40-editor-precache';
// KaTeX resolves these from its own stylesheet, so caching the CSS without them
// gives offline math fallback glyphs instead of proper typesetting.
@@ -74,6 +74,8 @@ const PRECACHE = [
'/static/js/fileHandler.js?v=20260909mobileattachmentedit1',
'/static/js/voiceRecorder.js',
'/static/js/actionMenuOrder.js',
'/static/js/platform.js',
'/static/js/modelSort.js',
'/static/js/models.js',
'/static/js/rag.js',
'/static/js/presets.js?v=20260908personaname1',
@@ -364,11 +366,29 @@ self.addEventListener('activate', (e) => {
);
});
// Offline fallback for /static/ JS/CSS. /static is a public, unauthenticated
// mount whose bytes do not depend on the request, but CORS adds `Vary: Origin`
// and module imports send an Origin header that install-time fetches omit, so a
// strict match hides the precached copy. Ignore Vary only for entries in this
// worker's own cache whose sole Vary header is Origin.
async function cachedStaticFile(request) {
const cache = await caches.open(CACHE_NAME);
const exact = await cache.match(request);
if (exact) return exact;
const variesOnlyOnOrigin = response => (response.headers.get('Vary') || '')
.split(',').map(name => name.trim().toLowerCase()).filter(Boolean)
.every(name => name === 'origin');
const candidates = await cache.matchAll(request, { ignoreVary: true });
return candidates.find(variesOnlyOnOrigin) || Response.error();
}
self.addEventListener('fetch', (e) => {
const url = new URL(e.request.url);
// Never touch API calls or non-GET.
if (url.pathname.startsWith('/api/') || e.request.method !== 'GET') return;
// Only this origin's own files are cached; anything else goes to the network.
if (url.origin !== self.location.origin) return;
// HTML navigation: network-first app shell — but ONLY for the SPA root.
// Other navigations (e.g. a deep-linked /static/*.html page) must go to the
@@ -394,10 +414,10 @@ self.addEventListener('fetch', (e) => {
fetch(e.request).then(res => {
if (res && res.ok) {
const copy = res.clone();
caches.open(CACHE_NAME).then(cache => cache.put(e.request, copy));
caches.open(CACHE_NAME).then(cache => cache.put(e.request, copy)).catch(() => {});
}
return res;
}).catch(() => caches.match(e.request))
}).catch(() => cachedStaticFile(e.request))
);
return;
}