mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-16 03:42:20 +02:00
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>
253 lines
11 KiB
JavaScript
253 lines
11 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|