mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-29 17:22:25 +01:00
feat(editor): harden photo editor security and interactions
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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/'));
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user