Files
odysseus/static/js/editor/keyboard-shortcuts.js
CocoLng 8e918dfdbb Ignore AltGr keystrokes in Ctrl+Alt keyboard shortcuts (#825)
* Ignore AltGr keystrokes in Ctrl+Alt keyboard shortcuts

Browsers report AltGr (right Alt on AZERTY/QWERTZ and most non-US
layouts, used to type @ # { } [ ] | \ and the euro sign) as
ctrlKey+altKey. The default keybinds map destructive actions to
Ctrl+Alt+<letter> (delete_session, new_session, incognito,
open_calendar), so a non-US user typing a special character could
silently fire them.

Guard the shortcut matcher, the editor keydown handler, and the rebind
capture with getModifierState('AltGraph'), which is true for AltGr but
false for a genuine left Ctrl+Alt. macOS is excluded: there the Option
key legitimately sets AltGraph and there is no AltGr/Ctrl+Alt collision
to guard against, so the guard would otherwise break Ctrl+Option /
Cmd+Option shortcuts (notably in Firefox).

The detection lives in one place — isAltGrEvent / IS_MAC in
static/js/platform.js — and all three call sites route through it, so the
guards can't drift apart.

The editor handler only skips the Ctrl+Alt chord block, so layout
shortcuts reachable via AltGr (e.g. [ ] brush size = AltGr+5/+8 on
AZERTY) keep working.

* Require Ctrl+Alt for the AltGr guard and consolidate keybind test marks

isAltGrEvent now also checks ctrlKey+altKey so it only suppresses the
"AltGr reported as Ctrl+Alt" collision; an event asserting AltGraph on
its own (a Linux ISO_Level3_Shift layout, a stray modifier) is left
alone. Pin it with test_isaltgr_false_when_altgraph_set_but_not_ctrl_alt.

Collapse the 12 per-test node skipif marks into one module-level
pytestmark, and note in platform.js why IS_MAC intentionally covers
iPad/iPhone and mirrors the isMac checks in calendar.js / sessions.js.
2026-06-02 11:12:54 +09:00

272 lines
12 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+Shift+D deselect (clears wand + lasso)
* Ctrl+S save (Shift = save as / export to gallery)
* Ctrl+Shift+T open resize popup
* Ctrl+Alt+T start free transform
* Ctrl+Alt+I invert wand / lasso selection
* Ctrl+Alt+J new empty layer
* Ctrl+Alt+A select all canvas (lasso polygon = full bounds)
* Ctrl+C/X copy / cut wand or lasso selection (image clipboard
* + internal clipboard)
* Ctrl+V (handled by the paste event listener)
* Tool keys (V, B, E, L, …) → toolbar click
* [ / ] shrink / grow brush size proportionally
* D, C, M (when lasso has 3+ points) → delete / copy / convert mask
* 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,
* startTransform: () => void,
* resizeCustomPrompt: () => void,
* addEmptyLayer: () => void,
* brushSizeSync: (source: HTMLInputElement | null) => void,
* invertSelection: () => boolean,
* wandDeleteSelection: () => void,
* wandCopyToNewLayer: () => void,
* lassoDeleteSelection: () => void,
* lassoCopyToLayer: () => void,
* lassoToMask: () => void,
* buildLassoMask: (w: number, h: number, offX: number, offY: number, feather: number, grow: number) => HTMLCanvasElement,
* drawLassoOverlay: () => void,
* activeLayer: () => object | null,
* uiModule: object,
* }} deps
*/
import { state } from './state.js';
import { isAltGrEvent } from '../platform.js';
export function wireKeyboardShortcuts(deps) {
const {
toolbar, toolKeyMap,
composite, saveState, undo, redo,
toggleShortcuts, confirmTransform, cancelTransform, startTransform,
resizeCustomPrompt, addEmptyLayer, brushSizeSync,
invertSelection,
wandDeleteSelection, wandCopyToNewLayer,
lassoDeleteSelection, lassoCopyToLayer, lassoToMask,
buildLassoMask, drawLassoOverlay,
activeLayer, uiModule,
} = deps;
document.addEventListener('keydown', (e) => {
if (!state.editorOpen) 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 === 'z') { e.preventDefault(); if (e.shiftKey) redo(); else undo(); }
// Ctrl+Shift+D = Deselect: clears the wand selection (and
// lasso if active) without affecting layers.
if (e.shiftKey && (e.key === 'D' || e.key === 'd')) {
if (state.wandMask || state.lassoPoints.length) {
e.preventDefault();
if (state.wandMask) {
saveState();
state.wandMask = null;
state.wandLayerId = null;
state.wandLastSeed = null;
}
if (state.lassoPoints.length) {
state.lassoPoints = [];
state.lassoActive = false;
}
composite();
}
}
// 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();
}
if (e.shiftKey && e.key === 'T') { e.preventDefault(); resizeCustomPrompt(); }
if (e.altKey && e.key === 't') { e.preventDefault(); startTransform(); }
// Ctrl+Alt+I — invert current 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();
}
}
// Ctrl+Alt+J — new empty layer.
if (e.altKey && e.code === 'KeyJ') {
e.preventDefault();
e.stopPropagation();
addEmptyLayer();
}
// Wand selection: Delete = erase pixels. Ctrl+X = cut to
// clipboard + new layer + erase. Ctrl+C = copy.
// (Legacy `&& !_wandActive` clause referenced an undeclared
// variable — removed; the wand is selection-only and has no
// "active drag" state.)
if (state.wandMask) {
if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault();
wandDeleteSelection();
return;
}
if ((e.ctrlKey || e.metaKey) && (e.key === 'x' || e.key === 'c')) {
e.preventDefault();
const isCut = e.key === 'x';
const src = state.layers.find(l => l.id === state.wandLayerId);
if (!src) return;
// Clip source by wand mask into a temp canvas.
const w = src.canvas.width, h = src.canvas.height;
const tmp = document.createElement('canvas');
tmp.width = w; tmp.height = h;
const tCtx = tmp.getContext('2d');
tCtx.drawImage(src.canvas, 0, 0);
tCtx.globalCompositeOperation = 'destination-in';
tCtx.drawImage(state.wandMask, 0, 0);
state.internalClipboard = tmp;
tmp.toBlob(blob => {
if (blob && navigator.clipboard?.write) {
navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]).then(() => {
uiModule.showToast(isCut ? 'Cut to clipboard' : 'Copied to clipboard');
}).catch(() => uiModule.showToast(isCut ? 'Cut (editor only)' : 'Copied (editor only)'));
}
}, 'image/png');
if (isCut) {
// Cut also moves the selection to a new layer + erases source.
wandCopyToNewLayer();
wandDeleteSelection();
}
return;
}
}
if ((e.key === 'x' || e.key === 'c') && state.lassoPoints.length >= 3) {
e.preventDefault();
const layer = activeLayer();
if (!layer) return;
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const feather = parseInt(document.getElementById('ge-lasso-feather')?.value || '0');
const grow = parseInt(document.getElementById('ge-lasso-grow')?.value || '0');
const w = layer.canvas.width, h = layer.canvas.height;
const mask = buildLassoMask(w, h, off.x, off.y, feather, grow);
const srcData = layer.ctx.getImageData(0, 0, w, h);
const maskData = mask.getContext('2d').getImageData(0, 0, w, h);
// Build clipped image.
const tmp = document.createElement('canvas');
tmp.width = w; tmp.height = h;
const tCtx = tmp.getContext('2d');
const outData = tCtx.createImageData(w, h);
for (let i = 0; i < w * h; i++) {
const mv = maskData.data[i * 4] / 255;
if (mv > 0) {
outData.data[i*4] = srcData.data[i*4];
outData.data[i*4+1] = srcData.data[i*4+1];
outData.data[i*4+2] = srcData.data[i*4+2];
outData.data[i*4+3] = Math.round(srcData.data[i*4+3] * mv);
}
}
tCtx.putImageData(outData, 0, 0);
state.internalClipboard = tmp;
const isCut = e.key === 'x';
tmp.toBlob(blob => {
if (blob && navigator.clipboard?.write) {
navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]).then(() => {
uiModule.showToast(isCut ? 'Cut to clipboard' : 'Copied to clipboard');
}).catch(() => uiModule.showToast(isCut ? 'Cut (editor only)' : 'Copied (editor only)'));
}
}, 'image/png');
if (e.key === 'x') {
const savedPts = [...state.lassoPoints];
state.lassoPoints = savedPts;
lassoDeleteSelection();
} else {
state.lassoPoints = [];
composite();
}
}
// Ctrl+C with no active selection → copy the entire active layer
// to the system clipboard as a PNG. Gives a "just copy this image"
// shortcut without having to lasso-select-all first. The
// selection-aware Ctrl+C paths above run first (wand + lasso),
// so this only fires when neither is active.
if (e.key === 'c' && !e.shiftKey && !state.wandMask && state.lassoPoints.length < 3) {
const layer = activeLayer();
if (layer && layer.canvas && layer.canvas.width > 0) {
e.preventDefault();
layer.canvas.toBlob(blob => {
if (blob && navigator.clipboard?.write) {
navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
.then(() => uiModule.showToast('Layer copied to clipboard'))
.catch(() => uiModule.showToast('Copy failed (clipboard permission denied?)'));
}
}, 'image/png');
return;
}
}
// Ctrl+Alt+A = select all canvas.
if (e.altKey && e.key === 'a' && state.imgWidth > 0 && state.imgHeight > 0) {
e.preventDefault();
state.lassoPoints = [
{ x: 0, y: 0 }, { x: state.imgWidth, y: 0 },
{ x: state.imgWidth, y: state.imgHeight }, { x: 0, y: state.imgHeight },
];
state.lassoActive = false;
composite();
drawLassoOverlay();
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 */ }
return;
}
// Tool shortcuts (only when not typing in an input).
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
const toolId = toolKeyMap[e.key.toLowerCase()];
if (toolId) {
const toolBtn = toolbar.querySelector(`[data-tool="${toolId}"]`);
if (toolBtn) toolBtn.click();
}
// 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 {}
}
// Lasso shortcuts (when selection exists).
if (state.lassoPoints.length >= 3) {
if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); lassoDeleteSelection(); }
if (e.key === 'd') { e.preventDefault(); lassoDeleteSelection(); }
if (e.key === 'c') { e.preventDefault(); lassoCopyToLayer(); }
if (e.key === 'm') { e.preventDefault(); lassoToMask(); }
}
});
}