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
+313
View File
@@ -0,0 +1,313 @@
/**
* Tool-aware canvas context menu (Photoshop-style right-click).
*
* A right-click on the image opens a menu whose items depend on the
* active tool: paint tools get brush size/presets, selection tools get
* select/feather/expand actions, the crop tool gets Apply/Cancel, the
* transform tool gets commit/cancel, and so on. Every action re-uses the
* existing control ops (window.geEditorControl.invoke) or clicks the
* existing topbar menu items, so there is a single source of truth.
*
* Wire it with wireCanvasContextMenu(canvasArea) from canvas-events.js.
*/
import { state } from './state.js';
const PAINT = ['brush', 'eraser', 'clone', 'heal', 'dodge', 'burn', 'smudge', 'inpaint', 'rembg', 'sharpen'];
const SELECT = ['marquee', 'lasso', 'wand', 'quickselect', 'objectselect', 'pen', 'path', 'sam'];
const TRANSFORM = ['move', 'transform'];
let rootMenu = null;
let subMenu = null;
let cleanups = [];
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const hasSelection = () => !!(state.wandMask || (state.lassoPoints && state.lassoPoints.length >= 3));
function invoke(op, params) {
try { return window.geEditorControl?.invoke(op, params || {}); } catch (err) { console.warn('[ge-canvas-menu]', op, err); }
}
function clickEl(selector) { document.querySelector(selector)?.click(); }
function clickMenu(selector) { document.querySelector(selector)?.click(); }
function dispatchEscape() {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
}
function stepBrushSize(factor) {
const slider = document.querySelector('.ge-size-slider');
if (!slider) return;
const next = Math.max(1, Math.min(800, Math.round((state.brushSize || 1) * factor)));
slider.value = String(Math.round(Math.log(next) / Math.log(800) * 1000));
slider.dispatchEvent(new Event('input', { bubbles: true }));
}
function applyPresetId(id) {
const sel = document.getElementById('ge-brush-preset');
if (!sel) return;
sel.value = id;
sel.dispatchEvent(new Event('change', { bubbles: true }));
}
function presetItems() {
const sel = document.getElementById('ge-brush-preset');
if (!sel) return [];
return [...sel.options].map((opt) => ({
label: opt.textContent,
checked: opt.value === sel.value,
onSelect: () => applyPresetId(opt.value),
}));
}
function promptNumber(message, fallback, run) {
const raw = window.prompt(message, String(fallback));
if (raw == null) return;
const value = Number(raw);
if (Number.isFinite(value)) run(value);
}
/** Build the menu item descriptors for a tool. Exported for tests. */
export function buildCanvasMenuItems(tool) {
const items = [];
const push = (...x) => items.push(...x);
const sep = () => items.push({ type: 'sep' });
const sel = hasSelection();
if (PAINT.includes(tool)) {
push(
{ label: 'Increase Brush Size', hint: ']', onSelect: () => stepBrushSize(1.25) },
{ label: 'Decrease Brush Size', hint: '[', onSelect: () => stepBrushSize(0.8) },
{ label: 'Brush Preset', submenu: () => presetItems() },
{
label: 'Pixel Brush',
checked: !!state.brushPixel,
onSelect: () => applyPresetId(state.brushPixel ? 'hard-round' : 'pixel-1px'),
},
);
sep();
} else if (SELECT.includes(tool)) {
push(
{ label: 'Select All', hint: 'Ctrl+A', onSelect: () => invoke('selection.selectAll') },
{ label: 'Deselect', hint: 'Ctrl+D', disabled: !sel, onSelect: () => invoke('selection.deselect') },
{ label: 'Inverse Selection', disabled: !sel, onSelect: () => invoke('selection.invert') },
{ label: 'Transform Selection', disabled: !sel, onSelect: () => clickMenu('[data-selection-action="transform"]') },
{ label: 'Refine Edge…', disabled: !sel, onSelect: () => clickMenu('[data-selection-action="refine"]') },
);
sep();
push(
{ label: 'Feather…', disabled: !sel, onSelect: () => promptNumber('Feather radius (px)', 8, (v) => invoke('selection.feather', { radius: v })) },
{ label: 'Expand', disabled: !sel, onSelect: () => invoke('selection.expand', { amount: 4 }) },
{ label: 'Contract', disabled: !sel, onSelect: () => invoke('selection.contract', { amount: 4 }) },
);
sep();
} else if (tool === 'crop' || state.cropRect) {
const cropping = !!(state.cropRect || state.cropping || state.cropMoving);
push(
{ label: 'Apply Crop', disabled: !cropping, onSelect: () => clickEl('.ge-crop-apply-btn') },
{ label: 'Cancel Crop', hint: 'Esc', disabled: !cropping, onSelect: () => dispatchEscape() },
);
sep();
} else if (TRANSFORM.includes(tool) || state.transformActive) {
push(
{ label: 'Commit Transform', hint: 'Enter', disabled: !state.transformActive, onSelect: () => invoke('transform.apply') },
{ label: 'Cancel Transform', hint: 'Esc', disabled: !state.transformActive, onSelect: () => invoke('transform.cancel') },
{ label: 'Free Transform', hint: 'Ctrl+T', disabled: state.transformActive, onSelect: () => invoke('transform.start') },
);
sep();
} else if (tool === 'text') {
push(
{ label: 'Add Text Layer', onSelect: () => invoke('text.add', { text: 'Text' }) },
{ label: 'Rasterize Text', onSelect: () => invoke('layers.rasterize') },
);
sep();
} else if (tool === 'eyedropper') {
push(
{ label: 'Copy Color Hex', onSelect: () => { try { navigator.clipboard?.writeText(state.color || ''); } catch (_) {} } },
{ label: 'Swap Foreground / Background', onSelect: () => clickEl('#ge-swap-colors') },
);
sep();
} else if (tool === 'hand') {
push(
{ label: 'Zoom In', hint: 'Ctrl++', onSelect: () => clickEl('#ge-zoom-in') },
{ label: 'Zoom Out', hint: 'Ctrl+-', onSelect: () => clickEl('#ge-zoom-out') },
{ label: 'Fit on Screen', hint: 'Ctrl+0', onSelect: () => clickEl('#ge-zoom-100') },
);
sep();
} else if (tool === 'gradient' || tool === 'shape') {
push(
{ label: 'Fill Selection with Foreground', disabled: !sel, onSelect: () => invoke('selection.fill', { color: state.color }) },
);
sep();
}
if (!SELECT.includes(tool) && sel) {
push(
{ label: 'Deselect', hint: 'Ctrl+D', onSelect: () => invoke('selection.deselect') },
{ label: 'Inverse Selection', onSelect: () => invoke('selection.invert') },
{ label: 'Fill Selection', onSelect: () => invoke('selection.fill', { color: state.color }) },
{ label: 'Delete Selection', hint: 'Del', onSelect: () => invoke('selection.delete') },
);
sep();
}
push(
{ label: 'Cut', hint: 'Ctrl+X', disabled: !sel, onSelect: () => invoke('selection.copy', { cut: true }) },
{ label: 'Copy', hint: 'Ctrl+C', disabled: !sel, onSelect: () => invoke('selection.copy') },
{ label: 'Paste', hint: 'Ctrl+V', onSelect: () => invoke('selection.paste') },
sep(),
{ label: 'Select All', hint: 'Ctrl+A', onSelect: () => invoke('selection.selectAll') },
{ label: 'Deselect', hint: 'Ctrl+D', disabled: !sel, onSelect: () => invoke('selection.deselect') },
sep(),
{ label: 'Rotate 90° CW', onSelect: () => invoke('layer.rotate', { degrees: 90 }) },
{ label: 'Rotate 180°', onSelect: () => invoke('layer.rotate', { degrees: 180 }) },
{ label: 'Flip Horizontal', onSelect: () => invoke('layer.flip', { axis: 'h' }) },
{ label: 'Flip Vertical', onSelect: () => invoke('layer.flip', { axis: 'v' }) },
sep(),
{ label: 'Undo', hint: 'Ctrl+Z', onSelect: () => invoke('history.undo') },
{ label: 'Redo', hint: 'Ctrl+Shift+Z', onSelect: () => invoke('history.redo') },
);
return items;
}
function buildItemEl(item, close) {
if (item.type === 'sep') {
const hr = document.createElement('div');
hr.className = 'ge-canvas-menu-sep';
return hr;
}
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'ge-canvas-menu-item';
btn.setAttribute('role', 'menuitem');
btn.disabled = !!item.disabled;
if (item.checked) btn.classList.add('is-checked');
const lead = document.createElement('span');
lead.className = 'ge-canvas-menu-lead';
lead.textContent = item.checked ? '✓' : '';
const label = document.createElement('span');
label.className = 'ge-canvas-menu-label';
label.textContent = item.label;
btn.append(lead, label);
if (item.submenu) {
btn.classList.add('has-submenu');
const caret = document.createElement('span');
caret.className = 'ge-canvas-menu-caret';
caret.textContent = '›';
btn.appendChild(caret);
btn.addEventListener('pointerenter', () => openSubmenu(btn, item.submenu));
} else if (item.hint) {
const hint = document.createElement('span');
hint.className = 'ge-canvas-menu-hint';
hint.textContent = item.hint;
btn.appendChild(hint);
}
btn.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
if (item.disabled) return;
if (item.submenu) return;
try { item.onSelect?.(); } finally { close(); }
});
btn.addEventListener('pointerenter', () => { closeSubmenu(); });
return btn;
}
function buildMenuEl(items, close) {
const menu = document.createElement('div');
menu.className = 'ge-canvas-menu';
menu.setAttribute('role', 'menu');
for (const item of items) menu.appendChild(buildItemEl(item, close));
return menu;
}
function placeMenu(menu, x, y) {
menu.style.left = '0px';
menu.style.top = '0px';
document.body.appendChild(menu);
const rect = menu.getBoundingClientRect();
const left = clamp(x, 4, Math.max(4, window.innerWidth - rect.width - 4));
const top = clamp(y, 4, Math.max(4, window.innerHeight - rect.height - 4));
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
}
function closeSubmenu() {
subMenu?.remove();
subMenu = null;
}
function openSubmenu(anchor, spec) {
closeSubmenu();
const items = typeof spec === 'function' ? spec() : spec;
if (!items || !items.length) return;
subMenu = buildMenuEl(items, closeAll);
subMenu.classList.add('ge-canvas-menu-sub');
document.body.appendChild(subMenu);
const a = anchor.getBoundingClientRect();
const r = subMenu.getBoundingClientRect();
let left = a.right - 4;
if (left + r.width > window.innerWidth - 4) left = a.left - r.width + 4;
const top = clamp(a.top - 4, 4, Math.max(4, window.innerHeight - r.height - 4));
subMenu.style.left = `${left}px`;
subMenu.style.top = `${top}px`;
}
function moveFocus(menu, delta) {
const focusables = [...menu.querySelectorAll('.ge-canvas-menu-item:not([disabled])')];
if (!focusables.length) return;
const current = focusables.indexOf(document.activeElement);
const next = clamp((current < 0 ? (delta > 0 ? -1 : 0) : current) + delta, 0, focusables.length - 1);
focusables[next]?.focus();
}
export function closeCanvasContextMenu() {
for (const fn of cleanups) { try { fn(); } catch (_) {} }
cleanups = [];
closeSubmenu();
rootMenu?.remove();
rootMenu = null;
}
/** Open the tool-aware menu at a pointer event (or {clientX, clientY}). */
export function openCanvasContextMenu(event) {
if (!state.editorOpen || !event) return;
closeCanvasContextMenu();
const menu = buildMenuEl(buildCanvasMenuItems(state.tool), closeCanvasContextMenu);
placeMenu(menu, event.clientX, event.clientY);
rootMenu = menu;
menu.querySelector('.ge-canvas-menu-item:not([disabled])')?.focus();
const onDown = (ev) => {
if (menu.contains(ev.target)) return;
if (subMenu && subMenu.contains(ev.target)) return;
closeCanvasContextMenu();
};
const onKey = (ev) => {
if (ev.key === 'Escape') { ev.preventDefault(); closeCanvasContextMenu(); }
else if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') { ev.preventDefault(); moveFocus(menu, ev.key === 'ArrowDown' ? 1 : -1); }
else if (ev.key === 'Enter') {
const active = document.activeElement;
if (active?.classList?.contains('ge-canvas-menu-item')) { ev.preventDefault(); active.click(); }
}
};
const onAway = () => closeCanvasContextMenu();
document.addEventListener('pointerdown', onDown, true);
document.addEventListener('keydown', onKey, true);
window.addEventListener('resize', onAway);
window.addEventListener('blur', onAway);
cleanups.push(() => document.removeEventListener('pointerdown', onDown, true));
cleanups.push(() => document.removeEventListener('keydown', onKey, true));
cleanups.push(() => window.removeEventListener('resize', onAway));
cleanups.push(() => window.removeEventListener('blur', onAway));
}
/** Wire right-click on the image (and the surrounding canvas area). */
export function wireCanvasContextMenu(canvasArea, { signal } = {}) {
const canvas = state.mainCanvas;
if (!canvas) return;
const opts = signal ? { signal } : undefined;
canvas.addEventListener('contextmenu', (event) => {
event.preventDefault();
openCanvasContextMenu(event);
}, opts);
canvasArea?.addEventListener('contextmenu', (event) => {
if (event.target === canvas) return;
if (event.target.closest?.('input, textarea, .ge-canvas-menu')) return;
event.preventDefault();
openCanvasContextMenu(event);
}, opts);
}