Files
odysseus/static/js/editor/keyboard-shortcuts.js
T
39303f19cd fix(editor): stabilize image generation, layer editing and controls (#6992)
* fix(editor): stabilize image workflows and editing controls

* docs(editor): add desktop and mobile visual validation evidence

* fix(editor): synchronize quick edit model events and register imagePreferences in test coordinator

* fix(settings): enforce authoritative global image-gen policy and non-admin preference persistence

- Enforce authoritative administrator global image_gen_enabled disable in get_user_setting, do_generate_image, and image_editing_provider_available, preventing non-admin user preference overrides while preserving user-disabled opt-outs and can_generate_images privilege checks across direct, gallery, and editor paths.
- Filter non-admin preference persistence in saveImagePreferences to user preference keys only (image_model, image_edit_model, image_quality), preventing global policy toggles from leaking into user preferences.
- Return accurate 200 OK Response from saveImagePreferences after fallback persistence succeeds so settings UI displays 'Saved' instead of false 403 error, while retaining visibility of real errors.
- Load per-user preferences via readImagePreferences in initImageSettings so non-admin selections persist and display correctly after page reload.
- Clear per-user model preferences on successful admin save (POST /api/auth/settings) via PUT /api/prefs/{key} with value: null to prevent stale per-user preferences from shadowing updated global settings.
- Support clearing keys in PUT /api/prefs/{key} when value is null.
- Add comprehensive Python regression tests for global-disabled/user-enabled, global-enabled/user-disabled, and authorized generation, plus Node and browser regression tests for non-admin persistence, toggle isolation, reload, and admin clearing.

* fix(settings): invalidate shared appConfig cache near auth settings write

* fix(test): isolate test_tool_policy unit suite from external model endpoint state

* fix(test): isolate web_fetch failure regression from contextual follow-up heuristics

* fix(test): isolate test_tool_policy from cross-test approval gate state

Ensure _patch_loop_basics disables TOOL_APPROVAL_GATE_ENABLED so routing tests in test_tool_policy run in the intended opt-out posture even when prior tests populate DATA_DIR with skills or untrusted context. Remove unnecessary forced_tools and contextual web mock workarounds.

* fix(settings): isolate non-admin image toggle and surface preference cleanup errors

- Restrict image generation availability toggle to admin users in imageModels.js, disabling the switch for non-admins and reverting unauthorized changes with an explicit message.
- Separate non-admin image model preference saves from global toggle updates.
- Track preference cleanup results on admin save in imagePreferences.js, propagating cleanup failures instead of ignoring them.
- Require valid preference keys on 403 fallback so toggle-only attempts reject cleanly.
- Add /api/prefs route mock to test_image_model_settings_sync_both_directions_and_survive_reopening.
- Add regression coverage for admin cleanup failures and non-admin toggle rejection.

* fix(settings): reconcile admin toggle on partial cleanup failure and report errors

- In imagePreferences.js, distinguish pre-persistence failures from post-save cleanup failures using err.globalSaved and err.phase.

- In imagePreferences.js, notify listeners of global policy updates on partial success before propagating cleanup failure.

- In imageModels.js, retain the persisted target state on admin toggle when global persistence succeeds but cleanup fails.

- In imageModels.js, reconcile admin toggle with authoritative server state via readImagePreferences() after ambiguous or partial failures.

- In imageModels.js, report partial success accurately without suppressing preference cleanup error messages.

- Add regression coverage in test_settings_save_response_ok_js.py for cleanup HTTP 500, cleanup network rejection, successful cleanup, and failed global POST.

---------

Co-authored-by: pewdiepie-archdaemon <pewdiepie-archdaemon@users.noreply.github.com>
Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-01 02:56:46 +00:00

394 lines
16 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, openLayerAdjustment,
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. Enter still confirms an active
// transform even from a transform field, so typing a value then hitting
// Enter accepts the transform instead of doing nothing.
if (isTypingTarget(e.target) &&
!(isNonTextInput(e.target) && (e.key === 'Delete' || e.key === 'Backspace')) &&
!(state.transformActive && e.key === 'Enter')) 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.shiftKey && !e.altKey && e.code === 'KeyU') {
e.preventDefault();
e.stopPropagation();
openLayerAdjustment?.('hue-saturation');
return;
}
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+N — new document. The File menu owns the size dialog, so
// click its row rather than duplicating the prompt here.
if (!e.shiftKey && !e.altKey && e.code === 'KeyN') {
const row = document.querySelector('[data-file-action="new-custom"]');
if (row) { e.preventDefault(); e.stopPropagation(); row.click(); 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+Shift+E — stamp visible: merge every visible layer into a new
// layer on top of the stack while keeping the originals (Photoshop).
// Flatten Copy in the layer footer already does exactly that, so click it.
if (e.altKey && e.shiftKey && e.code === 'KeyE') {
const stamp = document.getElementById('ge-flatten');
if (stamp) { e.preventDefault(); e.stopPropagation(); stamp.click(); return; }
}
// Ctrl+Alt+J — new empty layer.
if (e.altKey && e.code === 'KeyJ') {
e.preventDefault();
e.stopPropagation();
addEmptyLayer();
return;
}
// Ctrl+Alt+L edits the current layer, leaving Levels controls open.
if (e.altKey && e.code === 'KeyL') {
e.preventDefault();
e.stopPropagation();
openLayerAdjustment?.('levels');
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();
// A slot can host several tools (gradient + paint bucket, brush + eraser
// + pixel). The main key re-activates whatever member the slot is
// currently showing, so picking Paint Bucket and then tapping G keeps
// Paint Bucket instead of snapping back to the default. A key for a
// secondary member (e.g. E for eraser) still picks that exact tool.
const flyoutItem = toolbar.querySelector(`[data-tool-option="${toolId}"]`);
const slotEl = flyoutItem ? flyoutItem.closest('.ge-tool-slot') : null;
let toolBtn = null;
if (slotEl) {
const firstMember = slotEl.querySelector('.ge-tool-flyout-item[data-tool-option]');
const defaultTool = firstMember ? firstMember.dataset.toolOption : null;
if (toolId === defaultTool) toolBtn = slotEl.querySelector('.ge-tool-slot-face');
}
toolBtn = toolBtn || toolbar.querySelector(`[data-tool="${toolId}"]`) || flyoutItem;
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 });
}