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
+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);