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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user