diff --git a/CHANGELOG.md b/CHANGELOG.md index 38858464..1a01909f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p * Add option `inlineEntityActions` (defaults to `true`) for `VisualAuthoring` to display entity actions inline at the top of each entity; * Improve the style for "cancel" (discard) action on entities and relations to make it consistent with other inline actions. - Add `ElementDecoration` component to display additional decorations over canvas elements either from the template itself or from outside the element: - * Element decorations are not included in the computed element bounds but are exported with the canvas unless explicitly marked with `data-reactodia-no-export` attribute (as with other canvas elements); + * Element decorations are not included in the computed element bounds but are exported with the canvas unless explicitly marked with `data-reactodia-no-export` attribute (as with other canvas elements). +- Support keyboard hotkeys for the focused canvas: + * Allow to specify arbitrary hotkeys to `ToolbarAction` and `SelectionAction` components, export `useCanvasHotkey()` hook to bind hotkey from any canvas widget; + * Add default hotkeys for components: `Selection` (`Ctrl+A`: select all), `ToolbarActionUndo` (`Ctrl+Z`), `ToolbarActionRedo` (`Ctrl+Shift+Z`), `SelectionActionRemove` (`Delete`, same as before), `SelectionActionGroup` (`G`). - **[💥Breaking]** Use separate HTML paper layer to display `LinkLabel` components instead of an SVG canvas, which allows to use CSS for layout, backgrounds and improves rendering performance: * `textClass`, `textStyle`, `rectClass` and `rectStyle` are replaced by `className` and `style` props; * CSS should use HTML styling properties instead of SVG variants, e.g. `color` and `background-color` instead of `stroke` and `fill`; @@ -44,7 +47,6 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p * `PLACEHOLDER_LINK_TYPE` -> `PlaceholderRelationType`; - Support the ability to expand up the `Dropdown`, `DropdownMenu` and `Toolbar` by setting `direction` to `"up"` e.g. for docking the toolbar to the bottom of the viewport. - Allow to return `iconMonochrome: true` for a type style to automatically apply dark theme filter for the icon. -- Add keyboard shortcuts for `Selection` (`Ctrl+A`: select all), `ToolbarActionUndo` (`Ctrl+Z`), `ToolbarActionRedo` (`Ctrl+Shift+Z`), `SelectionActionRemove` (`Delete`, same as before), `SelectionActionGroup` (`G`). - Support optional dependency list in `useEventStore()` to re-subscribe to store if needed. #### 🔧 Maintenance diff --git a/examples/resources/common.tsx b/examples/resources/common.tsx index fb2d3dd9..994d0e92 100644 --- a/examples/resources/common.tsx +++ b/examples/resources/common.tsx @@ -30,6 +30,7 @@ export function ExampleToolbarMenu() { return ( <> { const preloadedElements = new Map(); @@ -61,6 +62,7 @@ export function ExampleToolbarMenu() { Open diagram from file { const diagramLayout = model.exportLayout(); const layoutString = JSON.stringify(diagramLayout); @@ -93,7 +95,7 @@ export function ExampleToolbarMenu() { - + ); } diff --git a/src/coreUtils/hotkey.ts b/src/coreUtils/hotkey.ts new file mode 100644 index 00000000..b15f35ff --- /dev/null +++ b/src/coreUtils/hotkey.ts @@ -0,0 +1,135 @@ +import { hashNumber, hashString, chainHash, dropHighestNonSignBit } from '@reactodia/hashmap'; +import * as React from 'react'; + +/** + * Represents a keyboard press sequence expression for a hotkey. + * + * The valid hotkey expression is at least one or more modifiers and the key separated by `+`, + * e.g. `Ctrl+Alt+K`, `Alt+Meta+Q`, `Ctrl+/`: + * - modifier is one of `Mod`, `Ctrl`, `Meta`, `Alt`, `Shift` + * (`Mod` is `Meta` on Mac and `Ctrl` everywhere else). + * - key is a [KeyboardEvent.key](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key) + * with a special case for `A-Z` keys to handle them independently of an active keyboard layout. + * - single-letter keys are matched case-insensitively, so `Ctrl+Shift+a` is the same as `Ctrl+Shift+A`. + * - `Shift`-specific special keys needs to be specified as-is i.e. `Shift+5` will not be triggered and + * should be specified as `Shift+%` (and only for keyboard layouts with that mapping). + * + * @category Core + * @see {@link useCanvasHotkey} + */ +export type HotkeyString = `${'Mod' | 'Ctrl' | 'Meta' | 'Alt' | 'Shift' | 'None'}+${Capitalize}`; + +export interface HotkeyAst { + readonly modifiers: HotkeyModifier; + readonly key: string; +} + +export const enum HotkeyModifier { + None = 0, + Ctrl = 1, + Meta = 2, + Alt = 4, + Shift = 8, +} + +const IsMac = /Mac|iPhone|iPad/.test(window?.navigator?.platform || ''); + +/** + * Parses a keyboard hotkey sequence to an AST. + */ +export function parseHotkey(hotkey: HotkeyString): HotkeyAst { + const parts = hotkey.split('+'); + + let modifiers = HotkeyModifier.None; + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + switch (part) { + case 'Ctrl': { + modifiers |= HotkeyModifier.Ctrl; + break; + } + case 'Meta': { + modifiers |= HotkeyModifier.Meta; + break; + } + case 'Alt': { + modifiers |= HotkeyModifier.Alt; + break; + } + case 'Shift': { + modifiers |= HotkeyModifier.Shift; + break; + } + case 'Mod': { + modifiers |= IsMac ? HotkeyModifier.Meta : HotkeyModifier.Ctrl; + break; + } + case 'None': { + /* ignore */ + break; + } + default: { + throw new Error(`Unknown hotkey modifier "${part}"`); + } + } + } + + const key = parts[parts.length - 1]; + if (!key) { + throw new Error('Missing main key for a hotkey'); + } + + return { + modifiers, + key: key.length === 1 ? key.toLowerCase() : key, + }; +} + +export function formatHotkey(ast: HotkeyAst): string { + const {modifiers, key} = ast; + let result = ''; + if (modifiers & HotkeyModifier.Ctrl) { + result += 'Ctrl+'; + } + if (modifiers & HotkeyModifier.Meta) { + result += '⌘+'; + } + if (modifiers & HotkeyModifier.Alt) { + result += 'Alt+'; + } + if (modifiers & HotkeyModifier.Shift) { + result += 'Shift+'; + } + result += key.length === 1 ? key.toUpperCase() : key; + return result; +} + +export function sameHotkeyAst(a: HotkeyAst, b: HotkeyAst): boolean { + return ( + a.modifiers === b.modifiers && + a.key === b.key + ); +} + +export function hashHotkeyAst(ast: HotkeyAst): number { + return dropHighestNonSignBit(chainHash( + hashNumber(ast.modifiers), + hashString(ast.key) + )); +} + +export function eventToHotkeyAst(e: React.KeyboardEvent | KeyboardEvent): HotkeyAst { + const key = e.key.length === 1 ? e.key.toLowerCase() : e.key; + const keyIsLetter = /^[a-z]$/.test(key); + const codeMatch = /^Key([A-Z])$/.exec(e.code); + const codeKey = codeMatch ? codeMatch[1].toLowerCase() : undefined; + return { + modifiers: ( + (e.ctrlKey ? HotkeyModifier.Ctrl : HotkeyModifier.None) | + (e.metaKey ? HotkeyModifier.Meta : HotkeyModifier.None) | + (e.altKey ? HotkeyModifier.Alt : HotkeyModifier.None) | + (e.shiftKey ? HotkeyModifier.Shift : HotkeyModifier.None) + ), + key: codeKey && !keyIsLetter ? codeKey : key, + }; +} diff --git a/src/diagram/canvasWidget.tsx b/src/diagram/canvasWidget.tsx index e1776c08..999c4c5c 100644 --- a/src/diagram/canvasWidget.tsx +++ b/src/diagram/canvasWidget.tsx @@ -1,6 +1,9 @@ import * as React from 'react'; -import type { CanvasWidgetDescription } from './canvasApi'; +import { HotkeyAst, type HotkeyString, parseHotkey, formatHotkey } from '../coreUtils/hotkey'; + +import { type CanvasWidgetDescription, useCanvas } from './canvasApi'; +import { type MutableRenderingState } from './renderingState'; const GET_WIDGET_METADATA: unique symbol = Symbol('getWidgetMetadata'); @@ -46,3 +49,54 @@ export function extractCanvasWidget( } return undefined; } + +/** + * Represents a registered canvas hotkey. + * + * @see {@link useCanvasHotkey} + */ +export interface CanvasHotkey { + /** + * Hotkey displayed as human-readable sequence + */ + readonly text: string; +} + +/** + * Registers an active hotkey while the caller component is mounted on the canvas. + * + * If either `hotkey` or `action` is `undefined` or `null`, the hotkey will be inactive. + * + * @category Hooks + */ +export function useCanvasHotkey( + hotkey: HotkeyString | undefined | null, + action: (() => void) | undefined +): CanvasHotkey | undefined { + const {canvas} = useCanvas(); + + interface CanvasHotkeyWithAst extends CanvasHotkey { + _ast: HotkeyAst; + } + + const actionKey = React.useMemo((): CanvasHotkeyWithAst | undefined => { + if (hotkey) { + const ast = parseHotkey(hotkey); + return {_ast: ast, text: formatHotkey(ast)}; + } + return undefined; + }, [hotkey]); + const lastAction = React.useRef(); + lastAction.current = action; + + React.useEffect(() => { + if (actionKey) { + const renderingState = canvas.renderingState as MutableRenderingState; + return renderingState.listenHotkey(actionKey._ast, () => { + lastAction.current?.(); + }); + } + }, [actionKey]); + + return actionKey; +} diff --git a/src/diagram/paperArea.tsx b/src/diagram/paperArea.tsx index 3fd4d358..2a11373e 100644 --- a/src/diagram/paperArea.tsx +++ b/src/diagram/paperArea.tsx @@ -892,6 +892,7 @@ export class PaperArea extends React.Component implements const target = e.target; return target instanceof Node && Boolean( this.rootRef.current === target || + this.area === target || this.linkLayerRef.current?.contains(target) || this.labelLayerRef.current?.contains(target) || this.elementLayerRef.current?.contains(target) diff --git a/src/diagram/renderingState.ts b/src/diagram/renderingState.ts index 0b2ff025..a7fe8a51 100644 --- a/src/diagram/renderingState.ts +++ b/src/diagram/renderingState.ts @@ -1,6 +1,11 @@ +import { HashMap } from '@reactodia/hashmap'; import * as React from 'react'; +import { multimapAdd, multimapDelete } from '../coreUtils/collections'; import { Events, EventObserver, EventSource, PropertyChange } from '../coreUtils/events'; +import { + type HotkeyAst, sameHotkeyAst, hashHotkeyAst, eventToHotkeyAst, +} from '../coreUtils/hotkey'; import { Debouncer } from '../coreUtils/scheduler'; import { @@ -195,6 +200,10 @@ export class MutableRenderingState implements RenderingState { private readonly delayedUpdateRoutings = new Debouncer(); private routings: RoutedLinks = new Map(); + private readonly hotkeyHandlers = new HashMap void>>( + hashHotkeyAst, sameHotkeyAst + ); + readonly shared: SharedCanvasState; /** @hidden */ @@ -396,9 +405,30 @@ export class MutableRenderingState implements RenderingState { this.routings = computedRoutes; this.source.trigger('changeRoutings', {source: this, previous: previousRoutes}); }; + + listenHotkey(ast: HotkeyAst, handler: () => void): () => void { + multimapAdd(this.hotkeyHandlers, ast, handler); + return () => { + multimapDelete(this.hotkeyHandlers, ast, handler); + }; + } + + triggerHotkey(e: React.KeyboardEvent | KeyboardEvent): void { + if (e.repeat) { + return; + } + const pressAst = eventToHotkeyAst(e); + const handlers = this.hotkeyHandlers.get(pressAst); + if (handlers) { + for (const handler of handlers) { + e.preventDefault(); + handler(); + } + } + } } -function sameRoutedLink(a: RoutedLink, b: RoutedLink) { +function sameRoutedLink(a: RoutedLink, b: RoutedLink): boolean { return ( a.linkId === b.linkId && a.labelTextAnchor === b.labelTextAnchor && diff --git a/src/editor/overlayController.tsx b/src/editor/overlayController.tsx index 7c506551..7ca67bf6 100644 --- a/src/editor/overlayController.tsx +++ b/src/editor/overlayController.tsx @@ -7,10 +7,11 @@ import { } from '../coreUtils/hooks'; import type { Translation } from '../coreUtils/i18n'; -import { CanvasPointerUpEvent, useCanvas } from '../diagram/canvasApi'; +import { CanvasPointerUpEvent, CanvasKeyboardEvent, useCanvas } from '../diagram/canvasApi'; import { Element, Link, LinkVertex } from '../diagram/elements'; import { Size, Vector } from '../diagram/geometry'; import { DiagramModel } from '../diagram/model'; +import type { MutableRenderingState } from '../diagram/renderingState'; import { SharedCanvasState } from '../diagram/sharedCanvasState'; import { Spinner, SpinnerProps } from '../diagram/spinner'; @@ -104,7 +105,12 @@ export class OverlayController { }); view.setCanvasWidget('selectionHandler', { - element: , + element: ( + + ), attachment: 'viewport', }); } @@ -123,7 +129,7 @@ export class OverlayController { this.listener.stopListening(); } - private onAnyCanvasPointerUp = (event: CanvasPointerUpEvent) => { + private onAnyCanvasPointerUp = (event: CanvasPointerUpEvent): void => { const {source: canvas, sourceEvent, target, triggerAsClick} = event; if (sourceEvent.ctrlKey || sourceEvent.shiftKey || sourceEvent.metaKey) { @@ -147,6 +153,12 @@ export class OverlayController { } }; + private onAnyCanvasKeydown = (event: CanvasKeyboardEvent): void => { + const {source, sourceEvent} = event; + const renderingState = source.renderingState as MutableRenderingState; + renderingState.triggerHotkey(sourceEvent); + }; + /** * Starts a new foreground task which blocks canvas interaction and * displays a loading indicator until the task has ended. @@ -459,14 +471,16 @@ function useViewportSize() { function CanvasOverlayHandler(props: { onCanvasPointerUp: (event: CanvasPointerUpEvent) => void; + onCanvasKeydown: (event: CanvasKeyboardEvent) => void; }) { - const {onCanvasPointerUp} = props; + const {onCanvasPointerUp, onCanvasKeydown} = props; const {canvas} = useCanvas(); React.useEffect(() => { const listener = new EventObserver(); listener.listen(canvas.events, 'pointerUp', onCanvasPointerUp); + listener.listen(canvas.events, 'keydown', onCanvasKeydown); return () => listener.stopListening(); - }, [onCanvasPointerUp]); + }, [onCanvasPointerUp, onCanvasKeydown]); return null; } diff --git a/src/widgets/selection.tsx b/src/widgets/selection.tsx index 39ae49ad..4938ea17 100644 --- a/src/widgets/selection.tsx +++ b/src/widgets/selection.tsx @@ -5,9 +5,10 @@ import { EventObserver } from '../coreUtils/events'; import { SyncStore, useEventStore, useFrameDebouncedStore, useSyncStoreWithComparator, } from '../coreUtils/hooks'; +import type { HotkeyString } from '../coreUtils/hotkey'; import { CanvasApi, CanvasMetrics, useCanvas } from '../diagram/canvasApi'; -import { defineCanvasWidget } from '../diagram/canvasWidget'; +import { defineCanvasWidget, useCanvasHotkey } from '../diagram/canvasWidget'; import { Element, Link } from '../diagram/elements'; import { Rect, SizeProvider, Vector, boundsOf, findElementAtPoint, getContentFittingBox, @@ -37,6 +38,14 @@ export interface SelectionProps { * @default 2 */ itemMargin?: number; + /** + * Keyboard hotkey to select all canvas elements. + * + * Passing `null` disables the default hotkey. + * + * @default "Mod+A" + */ + hotkeySelectAll?: HotkeyString | null; /** * {@link SelectionAction} items representing available actions on the selected elements. * @@ -60,12 +69,10 @@ const CLASS_NAME = 'reactodia-selection'; /** * Canvas widget component for rectangular element selection on the diagram. * - * When mounted, handles the following keyboard shortcuts: - * - `Ctrl+A` / `⌘+A`: select all canvas elements. - * * @category Components */ export function Selection(props: SelectionProps) { + const {hotkeySelectAll} = props; const {model, canvas} = useCanvas(); const subscribeSelection = useEventStore(model.events, 'changeSelection'); @@ -123,22 +130,16 @@ export function Selection(props: SelectionProps) { } origin = undefined; }); - listener.listen(canvas.events, 'keydown', e => { - if ( - e.sourceEvent.key === 'a' && - (e.sourceEvent.ctrlKey || e.sourceEvent.metaKey) && - !e.sourceEvent.altKey - ) { - e.sourceEvent.preventDefault(); - model.setSelection([...model.elements]); - } - }); return () => { listener.stopListening(); moveListener?.stopListening(); }; }, []); + useCanvasHotkey(hotkeySelectAll === undefined ? 'Mod+A' : hotkeySelectAll, () => { + model.setSelection([...model.elements]); + }); + if (highlightedBox || selectedElements.length > 1) { return ( @@ -167,20 +179,22 @@ export function SelectionActionSpinner(props: SelectionActionSpinnerProps) { * * @see {@link SelectionActionRemove} */ -export interface SelectionActionRemoveProps extends SelectionActionStyleProps {} +export interface SelectionActionRemoveProps extends SelectionActionStyleProps { + /** + * @default "None+Delete" + */ + hotkey?: HotkeyString | null; +} /** * Selection action component to remove an element from the diagram. * * Removing the elements adds a command to the command history. * - * When mounted, handles the following keyboard shortcuts: - * - `Delete`: remove all currently selected elements from the canvas. - * * @category Components */ export function SelectionActionRemove(props: SelectionActionRemoveProps) { - const {className, title, ...otherProps} = props; + const {className, title, hotkey, ...otherProps} = props; const {canvas} = useCanvas(); const {model, editor, translation: t} = useWorkspace(); const elements = model.selection.filter((cell): cell is Element => cell instanceof Element); @@ -196,24 +210,7 @@ export function SelectionActionRemove(props: SelectionActionRemoveProps) { } } - React.useEffect(() => { - const listener = new EventObserver(); - listener.listen(canvas.events, 'keyup', e => { - if ( - e.sourceEvent.key === 'Delete' && - !e.sourceEvent.ctrlKey && - !e.sourceEvent.altKey && - !e.sourceEvent.shiftKey - ) { - editor.removeSelectedElements(); - canvas.focus(); - } - }); - return () => listener.stopListening(); - }, []); - const singleNewEntity = newEntities === 1 && totalEntities === 1; - const shortcut = ' (Delete)'; return ( editor.removeSelectedElements()} + hotkey={hotkey === undefined ? 'None+Delete' : hotkey} + onSelect={() => { + editor.removeSelectedElements(); + canvas.focus(); + }} /> ); } @@ -559,7 +560,12 @@ export function SelectionActionAddToFilter(props: SelectionActionAddToFilterProp * * @see {@link SelectionActionGroup} */ -export interface SelectionActionGroupProps extends SelectionActionStyleProps {} +export interface SelectionActionGroupProps extends SelectionActionStyleProps { + /** + * @default "None+G" + */ + hotkey?: HotkeyString | null; +} /** * Selection action component to group or ungroup selected elements. @@ -569,13 +575,10 @@ export interface SelectionActionGroupProps extends SelectionActionStyleProps {} * * Grouping or ungrouping the elements adds a command to the command history. * - * When mounted, handles the following keyboard shortcuts: - * - `G`: group or ungroup selected elements. - * * @category Components */ export function SelectionActionGroup(props: SelectionActionGroupProps) { - const {className, title, ...otherProps} = props; + const {className, title, hotkey, ...otherProps} = props; const {canvas} = useCanvas(); const workspace = useWorkspace(); const {model, translation: t} = workspace; @@ -597,31 +600,10 @@ export function SelectionActionGroup(props: SelectionActionGroupProps) { } }; - const latestOnSelect = React.useRef(); - React.useEffect(() => { - latestOnSelect.current = onSelect; - }); - React.useEffect(() => { - const listener = new EventObserver(); - listener.listen(canvas.events, 'keydown', e => { - if ( - e.sourceEvent.key === 'g' && - !e.sourceEvent.ctrlKey && - !e.sourceEvent.metaKey && - !e.sourceEvent.altKey - ) { - e.sourceEvent.preventDefault(); - latestOnSelect.current?.(); - } - }); - return () => listener.stopListening(); - }, []); - if (elements.length === 0 || elements.length === 1 && canGroup) { return null; } - const shortcut = ' (G)'; return ( ); diff --git a/src/widgets/toolbarAction.tsx b/src/widgets/toolbarAction.tsx index d85dbf32..fc90c328 100644 --- a/src/widgets/toolbarAction.tsx +++ b/src/widgets/toolbarAction.tsx @@ -3,9 +3,11 @@ import { saveAs } from 'file-saver'; import * as React from 'react'; import { useObservedProperty } from '../coreUtils/hooks'; +import type { HotkeyString } from '../coreUtils/hotkey'; import { Translation, TranslatedText, useTranslation } from '../coreUtils/i18n'; import { ExportRasterOptions, useCanvas } from '../diagram/canvasApi'; +import { useCanvasHotkey } from '../diagram/canvasWidget'; import type { Command } from '../diagram/history'; import { dataURLToBlob } from '../diagram/toSvg'; @@ -36,6 +38,12 @@ export interface ToolbarActionStyleProps { * Whether the action is disabled. */ disabled?: boolean; + /** + * Keyboard hotkey for the action when it's mounted. + * + * Passing `null` disables a default hotkey if there is one. + */ + hotkey?: HotkeyString | null; } /** @@ -62,14 +70,24 @@ export interface ToolbarActionProps extends ToolbarActionStyleProps { * @category Components */ export function ToolbarAction(props: ToolbarActionProps) { - const {className, title, disabled, onSelect, children} = props; + const {className, title, disabled, hotkey, onSelect, children} = props; + const insideDropdown = useInsideDropdown(); + const actionKey = useCanvasHotkey(hotkey, onSelect); + const titleWithHotkey = title && actionKey ? `${title} (${actionKey.text})` : title; + return insideDropdown ? ( {children} + {actionKey ? ( + <> + + {actionKey.text} + + ) : null} ) : (