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}
) : (