Files
TREK/client/src/components/Studio/useStudioKeys.ts
T
Maurice 2f4644d434 feat(studio): groups, a layers panel, marquee, nudge, multi-resize, align and paste style
Elements can be grouped on a page and picked, moved, resized, aligned and
restacked as one. A layers panel lists the page front to back with lock,
visibility, rename and drag to restack. The sheet gains marquee selection,
arrow-key nudge, one box that resizes a multi-selection with Shift holding
the proportions, and the design-tool shortcuts. The inspector lines a
selection up and spaces it out, and copies and pastes a style.

The assistant gets the same through the group, ungroup, name and hide
operations, and describe_book_page lists the groups. The format gains the
optional group, name and hidden fields and a spread's group names, so
BOOK_SCHEMA_REV goes to 2.
2026-10-23 19:31:14 +02:00

179 lines
7.0 KiB
TypeScript

import type { BookSpread } from '@trek/shared';
import { useEffect } from 'react';
import { useStudioStore } from '../../store/studioStore';
/**
* The sheet's keyboard.
*
* Every shortcut here is one a design tool has taught people already, and none
* of them takes a key away from the browser while somebody is typing: in a
* field, a text area or a text being edited on the page, the keys are the
* field's. Undo and redo are the Studio page's own (useJourneyStudio.ts), and
* every action below is one undo step there.
*
* - Delete or Backspace: remove what is selected and not locked.
* - Ctrl+C, Ctrl+V: copy and paste elements, onto any page (#2316).
* - Ctrl+D: duplicate. Ctrl+A: select everything on the page.
* - Ctrl+G, Ctrl+Shift+G: group and ungroup.
* - Ctrl+Alt+C, Ctrl+Alt+V: copy and paste style.
* - Ctrl+] and Ctrl+[: forward and backward, with Shift to the front and back.
* - Arrows: nudge 1 mm, 10 mm with Shift. Holding one down is one undo step.
* - Escape inside a group: back to the whole group selected.
*
* Ctrl is Cmd on a Mac throughout.
*/
const NUDGE_MM = 1;
const NUDGE_BIG_MM = 10;
const ARROWS: Record<string, [number, number]> = {
ArrowLeft: [-1, 0],
ArrowRight: [1, 0],
ArrowUp: [0, -1],
ArrowDown: [0, 1],
};
/** Whether a key press belongs to whatever has the focus rather than to the sheet. */
function isTypingTarget(target: EventTarget | null): boolean {
const el = target as HTMLElement | null;
if (!el) return false;
return el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT' || el.isContentEditable;
}
/** Which of the deletable: on the page, selected, unlocked. */
const deletable = (spread: BookSpread | null, selection: string[]): string[] =>
(spread?.elements ?? []).filter((e) => selection.includes(e.id) && !e.locked).map((e) => e.id);
type Store = ReturnType<typeof useStudioStore.getState>;
/**
* One shortcut with Ctrl (Cmd on a Mac): whether it is this press, and what it
* does. The first that matches runs; a press none of them takes stays the
* browser's.
*/
interface Shortcut {
matches: (e: KeyboardEvent, key: string, store: Store, spread: BookSpread | null) => boolean;
run: (e: KeyboardEvent, store: Store, spreadIndex: number) => void;
}
const has = (store: Store) => store.selection.length > 0;
const BRACKETS = new Set([']', '[', '}', '{']);
const SHORTCUTS: Shortcut[] = [
// Ctrl+Alt is AltGr on many keyboards: only the two letters it does not type are taken.
{ matches: (e, key, s) => e.altKey && key === 'c' && has(s), run: (_e, s, i) => s.copyStyle(i) },
{
matches: (e, key, s) => e.altKey && key === 'v' && has(s) && s.styleClip !== null,
run: (_e, s, i) => s.pasteStyle(i),
},
{
matches: (e, key, s) => !e.altKey && key === 'g' && has(s),
run: (e, s, i) => (e.shiftKey ? s.ungroup(i) : s.group(i)),
},
{
matches: (e, key, s) => !e.altKey && !e.shiftKey && key === 'd' && has(s),
run: (_e, s, i) => s.duplicate(i, s.selection),
},
{
matches: (e, key, _s, spread) => !e.altKey && !e.shiftKey && key === 'a' && !!spread?.elements.length,
run: (_e, s, i) => s.selectAll(i),
},
{
matches: (e, _key, s) => !e.altKey && BRACKETS.has(e.key) && has(s),
run: (e, s, i) => {
const up = e.key === ']' || e.key === '}';
const far = up ? 'front' : 'back';
s.raise(i, s.selection, e.shiftKey ? far : up ? 'forward' : 'backward');
},
},
// Copy and paste across pages (#2316). Text selected on the page is the browser's to copy, not the book's.
{
matches: (e, key, s) => !e.altKey && key === 'c' && has(s) && !window.getSelection()?.toString(),
run: (_e, s, i) => s.copy(i, s.selection),
},
{ matches: (e, key, s) => !e.altKey && key === 'v' && s.clipboard !== null, run: (_e, s, i) => s.paste(i) },
];
/** A shortcut with Ctrl (Cmd on a Mac): answers whether it was one of ours. */
function command(e: KeyboardEvent, spreadIndex: number, spread: BookSpread | null): boolean {
const store = useStudioStore.getState();
const key = e.key.toLowerCase();
const hit = SHORTCUTS.find((sc) => sc.matches(e, key, store, spread));
hit?.run(e, store, spreadIndex);
return !!hit;
}
/** The arrow a press is, when it moves the selection: no modifier but Shift, something selected. */
function nudgeOf(e: KeyboardEvent, store: Store): [number, number] | null {
const arrow = ARROWS[e.key];
if (!arrow || !store.selection.length || e.ctrlKey || e.metaKey || e.altKey) return null;
const step = e.shiftKey ? NUDGE_BIG_MM : NUDGE_MM;
return [arrow[0] * step, arrow[1] * step];
}
/** Delete or Backspace: what is selected and not locked goes. Answers whether the key was taken. */
function deleteSelection(e: KeyboardEvent, store: Store, spreadIndex: number, spread: BookSpread | null): boolean {
if ((e.key !== 'Delete' && e.key !== 'Backspace') || !store.selection.length) return false;
e.preventDefault();
const ids = deletable(spread, store.selection);
if (ids.length) store.removeElements(spreadIndex, ids);
return true;
}
export function useStudioKeys(opts: { spreadIndex: number; editing: boolean }) {
const { spreadIndex, editing } = opts;
useEffect(() => {
/** The arrow keys hold one gesture open while any of them is down, so holding one is one undo step. */
let nudging = false;
const stopNudge = () => {
if (!nudging) return;
nudging = false;
useStudioStore.getState().endGesture();
};
const onKey = (e: KeyboardEvent) => {
if (editing || isTypingTarget(e.target)) return;
const store = useStudioStore.getState();
// Read when the key comes, not when the effect ran: a nudge changes the
// page, and an effect that re-ran on it would close the gesture each step.
const spread = store.doc?.spreads[spreadIndex] ?? null;
if (e.key === 'Escape') {
// Out of the group first; the Studio page's own Escape sees it was taken.
if (store.leaveGroup(spreadIndex)) e.preventDefault();
return;
}
if (deleteSelection(e, store, spreadIndex, spread)) return;
const nudge = nudgeOf(e, store);
if (nudge) {
e.preventDefault();
if (store.readOnly) return;
if (!nudging) {
nudging = true;
store.beginGesture();
}
store.nudge(spreadIndex, nudge[0], nudge[1]);
return;
}
if (!(e.metaKey || e.ctrlKey)) return;
if (command(e, spreadIndex, spread)) e.preventDefault();
};
const onKeyUp = (e: KeyboardEvent) => {
if (ARROWS[e.key]) stopNudge();
};
// Capture, so a step out of a group is decided before the page's Escape clears the selection.
window.addEventListener('keydown', onKey, true);
window.addEventListener('keyup', onKeyUp, true);
window.addEventListener('blur', stopNudge);
return () => {
stopNudge();
window.removeEventListener('keydown', onKey, true);
window.removeEventListener('keyup', onKeyUp, true);
window.removeEventListener('blur', stopNudge);
};
}, [spreadIndex, editing]);
}