diff --git a/CHANGELOG.md b/CHANGELOG.md index e9ae9dd4..b01c8898 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Always display validation state for an entities and relations in case when the target does not have any authoring changes. - Display elliptical authoring state overlays for elliptically-shaped entity elements. - Use provided `duration` in `CanvasApi.animateGraph()` for element transitions without the need to override the styles. +- Trigger `keydown`, `keyup`, `scroll` and `contextMenu` canvas events only from a non-widget layer. #### ⏱ Performance - Optimize diagram loading time by avoiding unnecessary updates and separating a measuring element sizes step from applying the sizes to the rendering state. @@ -42,6 +43,7 @@ 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/src/diagram/paperArea.tsx b/src/diagram/paperArea.tsx index b251b859..3fd4d358 100644 --- a/src/diagram/paperArea.tsx +++ b/src/diagram/paperArea.tsx @@ -857,10 +857,16 @@ export class PaperArea extends React.Component implements }; private onScroll = (e: Event) => { + if (!this.isEventFromCellLayer(e)) { + return; + } this.source.trigger('scroll', {source: this, sourceEvent: e}); }; private onContextMenu = (e: React.MouseEvent, cell: Cell | undefined) => { + if (!this.isEventFromCellLayer(e)) { + return; + } this.source.trigger('contextMenu', { source: this, sourceEvent: e, @@ -869,13 +875,29 @@ export class PaperArea extends React.Component implements }; private onKeyDown = (e: React.KeyboardEvent) => { + if (!this.isEventFromCellLayer(e)) { + return; + } this.source.trigger('keydown', {source: this, sourceEvent: e}); }; private onKeyUp = (e: React.KeyboardEvent) => { + if (!this.isEventFromCellLayer(e)) { + return; + } this.source.trigger('keyup', {source: this, sourceEvent: e}); }; + private isEventFromCellLayer(e: Event | React.SyntheticEvent): boolean { + const target = e.target; + return target instanceof Node && Boolean( + this.rootRef.current === target || + this.linkLayerRef.current?.contains(target) || + this.labelLayerRef.current?.contains(target) || + this.elementLayerRef.current?.contains(target) + ); + } + private makeToSVGOptions(baseOptions: ExportSvgOptions): ToSVGOptions { const {colorSchemeApi} = this.props; const { diff --git a/src/widgets/selection.tsx b/src/widgets/selection.tsx index b279546e..39ae49ad 100644 --- a/src/widgets/selection.tsx +++ b/src/widgets/selection.tsx @@ -60,6 +60,9 @@ 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) { @@ -120,6 +123,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(); diff --git a/src/widgets/selectionAction.tsx b/src/widgets/selectionAction.tsx index 603d5644..19e63d75 100644 --- a/src/widgets/selectionAction.tsx +++ b/src/widgets/selectionAction.tsx @@ -174,6 +174,9 @@ export interface SelectionActionRemoveProps extends SelectionActionStyleProps {} * * 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) { @@ -198,8 +201,9 @@ export function SelectionActionRemove(props: SelectionActionRemoveProps) { listener.listen(canvas.events, 'keyup', e => { if ( e.sourceEvent.key === 'Delete' && - document.activeElement && - document.activeElement.localName !== 'input' + !e.sourceEvent.ctrlKey && + !e.sourceEvent.altKey && + !e.sourceEvent.shiftKey ) { editor.removeSelectedElements(); canvas.focus(); @@ -209,6 +213,7 @@ export function SelectionActionRemove(props: SelectionActionRemoveProps) { }, []); const singleNewEntity = newEntities === 1 && totalEntities === 1; + const shortcut = ' (Delete)'; return ( editor.removeSelectedElements()} /> @@ -564,6 +569,9 @@ 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) { @@ -577,10 +585,43 @@ export function SelectionActionGroup(props: SelectionActionGroupProps) { const canGroup = elements.length > 0 && elements.every(element => element instanceof EntityElement); const canUngroup = elements.length > 0 && elements.every(element => element instanceof EntityGroup); + const onSelect = async () => { + if (canGroup) { + const group = await groupEntitiesAnimated(elements, canvas, workspace); + model.setSelection([group]); + group.focus(); + } else if (canUngroup) { + const ungrouped = await ungroupAllEntitiesAnimated(elements, canvas, workspace); + model.setSelection(ungrouped); + canvas.focus(); + } + }; + + 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 ( { - if (canGroup) { - const group = await groupEntitiesAnimated(elements, canvas, workspace); - model.setSelection([group]); - group.focus(); - } else if (canUngroup) { - const ungrouped = await ungroupAllEntitiesAnimated(elements, canvas, workspace); - model.setSelection(ungrouped); - canvas.focus(); - } - }} + onSelect={onSelect} /> ); } diff --git a/src/widgets/toolbarAction.tsx b/src/widgets/toolbarAction.tsx index 8e257742..d85dbf32 100644 --- a/src/widgets/toolbarAction.tsx +++ b/src/widgets/toolbarAction.tsx @@ -14,6 +14,7 @@ import { AuthoringState } from '../editor/authoringState'; import { DropdownMenuItem, useInsideDropdown } from './utility/dropdown'; import { useWorkspace } from '../workspace/workspaceContext'; +import { EventObserver } from '../workspace'; const CLASS_NAME = 'reactodia-toolbar-action'; @@ -342,11 +343,14 @@ export interface ToolbarActionUndoProps extends Omit { + const listener = new EventObserver(); + listener.listen(canvas.events, 'keydown', e => { + if ( + e.sourceEvent.key === 'z' && + (e.sourceEvent.ctrlKey || e.sourceEvent.metaKey) && + !e.sourceEvent.altKey + ) { + e.sourceEvent.preventDefault(); + history.undo(); + } + }); + return () => listener.stopListening(); + }, [history]); const commandTitle = !title && undoCommand ? resolveCommandTitle(undoCommand, t) : undefined; + const shortcut = ' (Ctrl+Z / ⌘+Z)'; return ( history.undo()}> {insideDropdown ? t.text('toolbar_action.undo.label') : null} @@ -384,11 +406,14 @@ export interface ToolbarActionRedoProps extends Omit { + const listener = new EventObserver(); + listener.listen(canvas.events, 'keydown', e => { + if ( + e.sourceEvent.key === 'Z' && + (e.sourceEvent.ctrlKey || e.sourceEvent.metaKey) && + !e.sourceEvent.altKey + ) { + e.sourceEvent.preventDefault(); + history.redo(); + } + }); + return () => listener.stopListening(); + }, [history]); const commandTitle = !title && redoCommand ? resolveCommandTitle(redoCommand, t) : undefined; + const shortcut = ' (Ctrl+Shift+Z / ⌘+Shift+Z)'; return ( history.redo()}> {insideDropdown ? t.text('toolbar_action.redo.label') : null}