Files
odysseus/static/js/editor/keyboard-shortcuts.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

352 lines
14 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.
/**
* Editor keyboard shortcuts — bound to `document` so shortcuts work
* without first clicking into the canvas. Gated by `state.editorOpen`
* so they don't leak into chat input when the editor is closed.
*
* Covers:
* ? toggle the shortcuts cheatsheet
* Enter confirm in-progress transform
* Esc cancel transform / lasso / crop (in priority order)
* Ctrl+Z undo (Shift adds redo)
* Ctrl+D deselect (clears wand + lasso)
* Ctrl+S save (Shift = save as / export to gallery)
* Ctrl+Shift+T open resize popup
* Ctrl+T start free transform (Ctrl+Alt+T also works)
* Ctrl+Alt+Shift+I invert selection (Ctrl+Alt+I also works)
* Ctrl+Alt+J new empty layer
* Ctrl/Cmd+J copy selected pixels to a layer, or duplicate the layer
* Ctrl+Alt+G create/release clipping mask
* Ctrl+A select all canvas
* Ctrl+C/X copy / cut the selected surface (image clipboard
* + internal clipboard, preserving its document offset)
* Ctrl+V (handled by the paste event listener)
* Ctrl+ / - / 0 zoom in / out / fit (overrides browser zoom)
* Tool keys (V, B, E, L, …) → toolbar click
* Hold Space temporarily pan without changing the active tool
* [ / ] shrink / grow brush size proportionally
* Delete / Backspace (wand or lasso) → delete pixels
*
* @param {{
* toolbar: HTMLDivElement,
* toolKeyMap: Record<string, string>,
* composite: () => void,
* saveState: (label?: string) => void,
* undo: () => void,
* redo: () => void,
* toggleShortcuts: (show?: boolean) => void,
* confirmTransform: () => void,
* cancelTransform: () => void,
* nudgeTransform: (dx: number, dy: number) => boolean,
* startTransform: () => void,
* resizeCustomPrompt: () => void,
* addEmptyLayer: () => void,
* brushSizeSync: (source: HTMLInputElement | null) => void,
* invertSelection: () => boolean,
* wandDeleteSelection: () => void,
* wandCopyToNewLayer: () => void,
* lassoDeleteSelection: () => void,
* lassoCopyToLayer: () => void,
* copyPixelsToClipboard: (options: {cut: boolean}) => Promise<void>,
* activeLayer: () => object | null,
* deleteSelectedLayers: () => boolean | Promise<boolean>,
* duplicateActiveLayer: () => boolean,
* uiModule: object,
* }} deps
*/
import { state } from './state.js';
import { isAltGrEvent } from '../platform.js';
import { createMarqueeMask } from './selection-mask.js';
let keyboardBindings;
export function wireKeyboardShortcuts(deps) {
keyboardBindings?.abort();
keyboardBindings = new AbortController();
const { signal } = keyboardBindings;
const {
toolbar, toolKeyMap,
composite, saveState, undo, redo,
toggleShortcuts, confirmTransform, cancelTransform, startTransform, nudgeTransform,
resizeCustomPrompt, addEmptyLayer, brushSizeSync,
invertSelection,
wandDeleteSelection, wandCopyToNewLayer, copyPixelsToClipboard,
lassoDeleteSelection, lassoCopyToLayer,
activeLayer, deleteSelectedLayers, duplicateActiveLayer, uiModule,
setTemporaryPan,
nudgeActiveLayer, endLayerNudge,
toggleQuickMask, nudgeSelection,
deselectSelection, fillSelection,
} = deps;
const isTypingTarget = (target) => target && (
target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' || target.isContentEditable
);
// A focused range/checkbox/button widget is still an <input>, but it is not
// a text field: Delete/Backspace there should fall through to the editor
// (e.g. delete the selected layer) instead of being swallowed.
const isNonTextInput = (target) => target && target.tagName === 'INPUT' && (
!['text', 'search', 'email', 'url', 'password', 'number', 'tel'].includes(target.type)
);
const releaseTemporaryPan = () => setTemporaryPan?.(false);
document.addEventListener('keyup', (e) => {
if (e.code === 'Space') releaseTemporaryPan();
if (e.key.startsWith('Arrow')) endLayerNudge?.();
}, { signal });
window.addEventListener('blur', releaseTemporaryPan, { signal });
document.addEventListener('keydown', (e) => {
if (!state.editorOpen || e.defaultPrevented || e.isComposing) return;
if (e.target?.closest?.('#styled-confirm-overlay')) return;
// Fields and text layers own native editing, including undo and clipboard.
// Let Delete/Backspace out of non-text widgets (range/checkbox/button) so
// they still delete the selected layer.
if (isTypingTarget(e.target) &&
!(isNonTextInput(e.target) && (e.key === 'Delete' || e.key === 'Backspace'))) return;
if (e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey && e.key === 'Backspace') {
e.preventDefault();
e.stopPropagation();
if (!e.repeat) fillSelection?.();
return;
}
if (e.code === 'Space' && !isTypingTarget(e.target)) {
e.preventDefault();
setTemporaryPan?.(true);
return;
}
if (!isTypingTarget(e.target) && state.tool === 'marquee' && e.key.startsWith('Arrow')) {
const amount = e.shiftKey ? 10 : 1;
const delta = {
ArrowLeft: [-amount, 0], ArrowRight: [amount, 0],
ArrowUp: [0, -amount], ArrowDown: [0, amount],
}[e.key];
if (delta && nudgeSelection?.(...delta)) {
e.preventDefault();
return;
}
}
if (!isTypingTarget(e.target) && state.transformActive && e.key.startsWith('Arrow')) {
const amount = e.shiftKey ? 10 : 1;
const delta = {
ArrowLeft: [-amount, 0], ArrowRight: [amount, 0],
ArrowUp: [0, -amount], ArrowDown: [0, amount],
}[e.key];
if (delta && nudgeTransform?.(...delta)) {
e.preventDefault();
return;
}
}
if (!isTypingTarget(e.target) && ['move', 'transform'].includes(state.tool) && e.key.startsWith('Arrow')) {
const amount = e.shiftKey ? 10 : 1;
const delta = {
ArrowLeft: [-amount, 0], ArrowRight: [amount, 0],
ArrowUp: [0, -amount], ArrowDown: [0, amount],
}[e.key];
if (delta && nudgeActiveLayer?.(...delta)) {
e.preventDefault();
return;
}
}
// `?` toggles the cheatsheet. Don't fire while typing in a text
// field — the user might be typing a prompt with a `?`.
if (e.key === '?' && e.target.tagName !== 'INPUT' && e.target.tagName !== 'TEXTAREA') {
e.preventDefault();
toggleShortcuts();
return;
}
if (e.key === 'Enter' && state.transformActive) {
e.preventDefault();
confirmTransform();
return;
}
if (e.key === 'Escape') return;
// Skip the Ctrl+Alt editor chords for an AltGr keystroke (see platform.js);
// only the chord block is skipped, so the layout-character handlers below
// still act — AltGr+5 / AltGr+8 stay as the [ ] brush-size shortcut on
// AZERTY / QWERTZ.
if ((e.ctrlKey || e.metaKey) && !isAltGrEvent(e)) {
if (e.key.toLowerCase() === 'z') {
e.preventDefault(); e.stopPropagation();
if (e.shiftKey) redo(); else undo();
return;
}
// Preserve the old chord as an alias while supporting the familiar one.
if (!e.altKey && e.key.toLowerCase() === 'd') {
e.preventDefault(); e.stopPropagation();
deselectSelection?.();
return;
}
// Save shortcuts — match the hints shown in the Save dropdown.
if ((e.key === 's' || e.key === 'S') && !e.altKey) {
e.preventDefault();
document.getElementById(e.shiftKey ? 'ge-export-gallery' : 'ge-save')?.click();
e.stopPropagation();
return;
}
if (e.shiftKey && e.key === 'T') { e.preventDefault(); e.stopPropagation(); resizeCustomPrompt(); return; }
// Ctrl/Cmd+T — free transform (Photoshop). Ctrl+Shift+T stays the canvas
// resize prompt and Ctrl+Alt+T remains a legacy alias.
if (!e.shiftKey && e.code === 'KeyT') { e.preventDefault(); e.stopPropagation(); startTransform(); return; }
// Ctrl+Alt+Shift+I (or the previous Ctrl+Alt+I) inverts the selection. Uses e.code so
// Alt-modified key values (e.g. `ˆ` on Mac with Option+I)
// don't break the match.
if (e.altKey && e.code === 'KeyI') {
if (invertSelection()) {
e.preventDefault();
e.stopPropagation();
}
return;
}
// Ctrl+Alt+J — new empty layer.
if (e.altKey && e.code === 'KeyJ') {
e.preventDefault();
e.stopPropagation();
addEmptyLayer();
return;
}
// Ctrl/Cmd+J duplicates the active layer through the layer panel's
// existing implementation, which preserves masks and effects.
if (!e.altKey && e.code === 'KeyJ') {
e.preventDefault();
e.stopPropagation();
if (state.wandMask) wandCopyToNewLayer();
else if (state.lassoPoints.length >= 3) lassoCopyToLayer();
else duplicateActiveLayer?.();
return;
}
// Ctrl+Alt+G — Photoshop-compatible clipping mask shortcut.
if (e.altKey && e.code === 'KeyG') {
const row = [...document.querySelectorAll('.ge-layer-item[data-layer-id]')]
.find(item => item.dataset.layerId === state.activeLayerId);
const button = row?.querySelector('.ge-layer-clip-btn');
if (button && !button.disabled) {
e.preventDefault();
e.stopPropagation();
button.click();
}
return;
}
if (!e.altKey && (e.key.toLowerCase() === 'c' || e.key.toLowerCase() === 'x')) {
e.preventDefault();
e.stopPropagation();
void copyPixelsToClipboard({ cut: e.key.toLowerCase() === 'x' });
return;
}
// Keep Ctrl+Alt+A as an alias for existing users.
if (e.key.toLowerCase() === 'a' && state.imgWidth > 0 && state.imgHeight > 0) {
e.preventDefault();
e.stopPropagation();
saveState('Select all');
state.wandMask = createMarqueeMask(
state.imgWidth,
state.imgHeight,
{ x: 0, y: 0, w: state.imgWidth, h: state.imgHeight },
'rectangle',
);
state.wandLayerId = state.activeLayerId;
state.wandMaskSpace = 'document';
state.selectionSource = 'marquee';
state.wandLastSeed = null;
state.lassoPoints = [];
composite();
uiModule.showToast('All selected — Ctrl+C to copy, Del to delete');
}
// Ctrl+V handled by the paste event listener.
if (e.key === 'v') { /* no-op here */ }
// Ctrl/Cmd +/- zoom the canvas in/out and Ctrl+0 fits — overriding the
// browser's page zoom (we own the shortcut inside the editor).
if (e.key === '+' || e.key === '=' || e.code === 'Equal' || e.code === 'NumpadAdd') {
e.preventDefault();
e.stopPropagation();
document.getElementById('ge-zoom-in')?.click();
return;
}
if (e.key === '-' || e.key === '_' || e.code === 'Minus' || e.code === 'NumpadSubtract') {
e.preventDefault();
e.stopPropagation();
document.getElementById('ge-zoom-out')?.click();
return;
}
if (e.key === '0' || e.code === 'Digit0' || e.code === 'Numpad0') {
e.preventDefault();
e.stopPropagation();
document.getElementById('ge-zoom-100')?.click();
return;
}
return;
}
// Tool shortcuts (only when not typing in an input).
if (isTypingTarget(e.target) && !isNonTextInput(e.target)) return;
// Delete pixels for a selection, otherwise delete the selected layer(s).
// Clipboard shortcuts above retain ownership of Ctrl/Cmd+X and C.
if (e.key === 'Delete' || e.key === 'Backspace') {
if (state.wandMask) {
e.preventDefault();
wandDeleteSelection();
return;
}
if (state.lassoPoints.length >= 3) {
e.preventDefault();
lassoDeleteSelection();
return;
}
const layer = activeLayer?.();
const activeMask = layer?.activeMaskId &&
layer.masks?.some(mask => mask.id === layer.activeMaskId);
const group = state.activeGroupId &&
state.layerGroups?.find(item => item.id === state.activeGroupId);
const activeGroupMask = group?.activeMaskId &&
group.masks?.some(mask => mask.id === group.activeMaskId);
if (state.transformActive || state.cropping || state.cropMoving ||
state.marqueeActive || state.selectionMoving || state.lassoActive ||
activeMask || activeGroupMask || state.maskInspectMode) return;
if (deleteSelectedLayers) {
e.preventDefault();
deleteSelectedLayers();
return;
}
}
if (!e.ctrlKey && !e.metaKey && !e.altKey && e.key.toLowerCase() === 'q') {
e.preventDefault();
toggleQuickMask?.();
return;
}
// Photoshop-compatible colour keys (tool shortcuts use neither): X swaps
// the foreground/background colours, D resets them to black/white.
if (!e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) {
const colorKey = e.key.toLowerCase();
if (colorKey === 'x') {
e.preventDefault();
e.stopPropagation();
window.geSwapColors?.();
return;
}
if (colorKey === 'd') {
e.preventDefault();
e.stopPropagation();
window.geResetColors?.();
return;
}
}
const toolId = toolKeyMap[e.key.toLowerCase()];
if (toolId) {
e.preventDefault();
e.stopPropagation();
const toolBtn = toolbar.querySelector(`[data-tool="${toolId}"]`) || toolbar.querySelector(`[data-tool-option="${toolId}"]`);
if (toolBtn) toolBtn.click();
return;
}
// Bracket keys for brush size — ±10% multiplier mirrors the
// exponential slider curve so each press feels the same at any
// size.
if (e.key === '[' || e.key === ']') {
const factor = e.key === '[' ? 0.9 : 1.1;
state.brushSize = Math.max(1, Math.min(800, Math.round(state.brushSize * factor)));
try { brushSizeSync(null); } catch {}
}
}, { signal });
}