Files
odysseus/static/js/editor/build/options-bar.js
T
pewdiepie-archdaemonandClaude Opus 5.5 5fac693c1c feat(photo-editor): Ajax Image Editor client (Photoshop-parity tools)
Photoshop-parity editor client: layers, selection/masking, brushes,
smart filters, LUT import, Quick Edit, mobile gestures, workspaces,
batch operations, and the service-worker versioning (v978->v1006).

Source: vendor/photocraft (ad1cff44) photo-editor client files, merged
onto upstream/dev (a8c147b2). Conflict resolution documented in
pr_work/A_conflicts.md; editor files taken from vendor/photocraft
(upstream's editor is an earlier snapshot of the same feature).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:57:35 +00:00

253 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Contextual tool options bar — a Photoshop-style strip that sits
* directly under the topbar and shows quick controls for the active
* tool.
*
* This is a *mirror* of the authoritative controls in the right-hand
* panel: it never owns state. Every value is delegated to the existing
* input element (by selector) so the two views can never disagree —
* editing here re-dispatches the same `input`/`change` event the real
* control listens for, and the real control pushes updates back into
* the bar.
*
* On phones (<= 820px) the bar is hidden by CSS and the right-panel
* bottom sheet remains the single surface, exactly as before.
*/
import { TOOLS } from './toolbar.js?v=20261008flyouts';
import { state } from '../state.js';
// Per-tool option sources. Each value is a CSS selector for an existing
// control inside the right panel; `null` means "no such control".
const BRUSH_TOOLS = {
brush: { size: '.ge-size-slider', opacity: '#ge-brush-opacity', flow: '#ge-brush-flow', softness: '#ge-brush-softness' },
smudge: { size: '.ge-size-slider', opacity: '#ge-brush-opacity', flow: '#ge-brush-flow', softness: '#ge-brush-softness' },
dodge: { size: '.ge-size-slider', opacity: '#ge-brush-opacity', flow: '#ge-brush-flow', softness: '#ge-brush-softness' },
burn: { size: '.ge-size-slider', opacity: '#ge-brush-opacity', flow: '#ge-brush-flow', softness: '#ge-brush-softness' },
eraser: { size: '.ge-size-slider', opacity: '#ge-eraser-opacity', flow: '#ge-eraser-flow', softness: '#ge-eraser-softness' },
clone: { size: '.ge-size-slider', opacity: '#ge-clone-opacity', flow: '#ge-clone-flow', softness: '#ge-clone-softness' },
heal: { size: '.ge-size-slider', opacity: '#ge-clone-opacity', flow: '#ge-clone-flow', softness: '#ge-clone-softness' },
};
const SELECTION_TOOLS = {
marquee: { modes: '#ge-marquee-section', feather: null },
lasso: { modes: '#ge-lasso-section', feather: '#ge-lasso-feather' },
wand: { modes: '#ge-wand-section', feather: '#ge-wand-feather' },
quickselect: { modes: '#ge-quickselect-section', feather: null },
objectselect: { modes: '#ge-objectselect-section', feather: null },
sam: { modes: '#ge-sam-section', feather: null },
};
const MODE_LABELS = [['replace', 'New'], ['add', '+'], ['subtract', '−'], ['intersect', '∩']];
function resolve(selector) {
if (!selector) return null;
return selector.startsWith('#')
? document.getElementById(selector.slice(1))
: document.querySelector(selector);
}
function makeRange(label, sourceSelector, { suffix = '' } = {}) {
const source = resolve(sourceSelector);
const value = source ? source.value : '0';
const min = source?.getAttribute('min') ?? '0';
const max = source?.getAttribute('max') ?? '100';
const step = source?.getAttribute('step') ?? '1';
return `
<div class="ge-opt" data-opt="range">
<label class="ge-opt-label">${label}<span class="ge-opt-value" data-opt-value>--</span></label>
<input type="range" data-opt-range data-src="${sourceSelector}" min="${min}" max="${max}" step="${step}" value="${value}" />
</div>`;
}
function makeSelect(label, sourceSelector) {
const source = resolve(sourceSelector);
const options = source
? [...source.options].map(o => `<option value="${o.value}">${o.textContent}</option>`).join('')
: '';
const value = source ? source.value : '';
return `
<div class="ge-opt" data-opt="select">
<label class="ge-opt-label">${label}</label>
<select data-opt-select data-src="${sourceSelector}">${options}</select>
</div>`;
}
function makeAutoSelect() {
return `
<label class="ge-opt ge-auto-select-option" data-opt="check">
<input type="checkbox" id="ge-auto-select-layer" />
<span class="ge-opt-label">Auto-Select</span>
</label>
<div class="ge-opt" data-opt="select">
<label class="ge-opt-label" for="ge-auto-select-target">Target</label>
<select id="ge-auto-select-target">
<option value="layer">Layer</option>
<option value="group">Group</option>
</select>
</div>`;
}
function makeModes(sectionSelector) {
const buttons = MODE_LABELS.map(([mode, text]) =>
`<button type="button" class="ge-opt-mode" data-opt-mode="${mode}" data-section="${sectionSelector}">${text}</button>`
).join('');
return `<div class="ge-opt" data-opt="modes"><span class="ge-opt-label">Mode</span><span class="ge-opt-modes">${buttons}</span></div>`;
}
function buildToolControls(toolId) {
const parts = [];
if (BRUSH_TOOLS[toolId]) {
const src = BRUSH_TOOLS[toolId];
parts.push(makeRange('Size', src.size, { suffix: 'px' }));
parts.push(makeRange('Opacity', src.opacity));
parts.push(makeRange('Flow', src.flow));
parts.push(makeRange('Softness', src.softness));
} else if (SELECTION_TOOLS[toolId]) {
const src = SELECTION_TOOLS[toolId];
parts.push(makeModes(src.modes));
if (src.feather) parts.push(makeRange('Feather', src.feather, { suffix: 'px' }));
} else if (toolId === 'gradient') {
parts.push(makeSelect('Type', '#ge-gradient-type'));
parts.push(makeRange('Opacity', '#ge-gradient-opacity'));
} else if (toolId === 'move') {
parts.push(makeAutoSelect());
}
if (!parts.length) {
return '';
}
return parts.join('');
}
function valueLabelOf(source, value) {
// Prefer the sibling label the right panel already maintains so the
// two surfaces always show the same formatted number.
const row = source?.closest?.('.ge-eraser-row, .ge-control-row');
const label = row?.querySelector('span[id$="-label"], span[id$="-opacity-label"], span[id$="-flow-label"], span[id$="-softness-label"]');
const text = label?.textContent?.trim();
return text || (typeof value === 'string' ? value : String(value));
}
/** Refresh every mirrored control inside `bar` from its source. */
export function syncOptionsBar(bar) {
if (!bar) return;
bar.querySelectorAll('[data-src]').forEach(el => {
const source = resolve(el.dataset.src);
if (!source) return;
if (el.tagName === 'SELECT') { el.value = source.value; return; }
el.value = source.value;
const field = el.closest('.ge-opt');
const out = field?.querySelector('[data-opt-value]');
if (out) out.textContent = valueLabelOf(source, source.value);
});
// Selection mode buttons.
bar.querySelectorAll('[data-opt-mode]').forEach(btn => {
const source = resolve(`${btn.dataset.section} [data-wand-mode="${btn.dataset.optMode}"]`);
btn.classList.toggle('active', !!source && source.classList.contains('active'));
});
// Move-tool auto-select (owned here rather than in the right panel).
const autoSelect = bar.querySelector('#ge-auto-select-layer');
if (autoSelect) autoSelect.checked = !!state.autoSelectLayer;
const target = bar.querySelector('#ge-auto-select-target');
if (target) target.value = state.autoSelectTarget === 'group' ? 'group' : 'layer';
}
/** Render the controls for `toolId` and wire delegation + mirroring. */
export function renderOptionsBar(bar, toolId) {
if (!bar) return;
bar.dataset.optionsTool = toolId;
const hasControls = !!(BRUSH_TOOLS[toolId] || SELECTION_TOOLS[toolId] || toolId === 'gradient' || toolId === 'move');
// Keep the strip mounted at a stable height for every tool. Hiding it
// for tools without options reflows the centered canvas-area and makes
// the canvas jump vertically when switching tools; an empty strip is
// visually harmless.
bar.classList.toggle('ge-options-empty', !hasControls);
const icon = bar.querySelector('.ge-options-tool-icon');
const name = bar.querySelector('.ge-options-tool-name');
const meta = TOOLS[toolId];
if (name) name.textContent = meta?.label || toolId || '';
if (icon) icon.innerHTML = meta?.icon || '';
const body = bar.querySelector('.ge-options-body');
if (body) body.innerHTML = buildToolControls(toolId);
// Mirror right-panel changes back into the bar.
bar.querySelectorAll('[data-src]').forEach(el => {
const source = resolve(el.dataset.src);
if (!source || source.dataset.geOptMirror) return;
source.dataset.geOptMirror = '1';
source.addEventListener('input', () => syncOptionsBar(bar));
source.addEventListener('change', () => syncOptionsBar(bar));
});
syncOptionsBar(bar);
}
/**
* Build the (empty) options-bar shell. Call `renderOptionsBar` after
* the right-panel controls exist so delegation can resolve them.
*/
export function buildOptionsBar() {
const bar = document.createElement('div');
bar.className = 'ge-options-bar';
bar.id = 'ge-options-bar';
bar.innerHTML = `
<div class="ge-options-tool">
<span class="ge-options-tool-icon" aria-hidden="true"></span>
<span class="ge-options-tool-name"></span>
</div>
<div class="ge-options-sep" aria-hidden="true"></div>
<div class="ge-options-body"></div>
<button type="button" class="ge-options-more" id="ge-options-more" title="More options" aria-label="More options" aria-haspopup="true" aria-expanded="false" hidden>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg>
</button>`;
const more = bar.querySelector('#ge-options-more');
more.addEventListener('click', (event) => {
event.stopPropagation();
const open = bar.classList.toggle('ge-more-open');
more.setAttribute('aria-expanded', open ? 'true' : 'false');
});
// Delegation: ranges + selects write through to their real control.
bar.addEventListener('input', event => {
const el = event.target.closest('[data-opt-range]');
if (!el) return;
const source = resolve(el.dataset.src);
if (!source) return;
source.value = el.value;
source.dispatchEvent(new Event('input', { bubbles: true }));
const out = el.closest('.ge-opt')?.querySelector('[data-opt-value]');
if (out) out.textContent = valueLabelOf(source, source.value);
});
bar.addEventListener('change', event => {
if (event.target.id === 'ge-auto-select-layer') {
state.autoSelectLayer = event.target.checked;
bar.dispatchEvent(new CustomEvent('ge-editor-prefs-change', { bubbles: true }));
return;
}
if (event.target.id === 'ge-auto-select-target') {
state.autoSelectTarget = event.target.value === 'group' ? 'group' : 'layer';
bar.dispatchEvent(new CustomEvent('ge-editor-prefs-change', { bubbles: true }));
return;
}
const el = event.target.closest('[data-opt-select]');
if (!el) return;
const source = resolve(el.dataset.src);
if (!source) return;
source.value = el.value;
source.dispatchEvent(new Event('change', { bubbles: true }));
});
bar.addEventListener('click', event => {
const btn = event.target.closest('[data-opt-mode]');
if (!btn) return;
const source = resolve(`${btn.dataset.section} [data-wand-mode="${btn.dataset.optMode}"]`);
source?.click();
syncOptionsBar(bar);
});
return bar;
}