mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
2f4644d434
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.
179 lines
7.0 KiB
TypeScript
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]);
|
|
}
|