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>
This commit is contained in:
pewdiepie-archdaemon
2026-10-15 11:57:35 +00:00
co-authored by Claude Opus 5.5
parent c74f475679
commit 5fac693c1c
290 changed files with 34358 additions and 766 deletions
+252
View File
@@ -0,0 +1,252 @@
/**
* 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;
}