Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion examples/resources/common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export function ExampleToolbarMenu() {
return (
<>
<Reactodia.ToolbarActionOpen
hotkey='Mod+O'
fileAccept='.json'
onSelect={async file => {
const preloadedElements = new Map<Reactodia.ElementIri, Reactodia.ElementModel>();
Expand Down Expand Up @@ -61,6 +62,7 @@ export function ExampleToolbarMenu() {
Open diagram from file
</Reactodia.ToolbarActionOpen>
<Reactodia.ToolbarActionSave mode='layout'
hotkey='Mod+S'
onSelect={() => {
const diagramLayout = model.exportLayout();
const layoutString = JSON.stringify(diagramLayout);
Expand Down Expand Up @@ -93,7 +95,7 @@ export function ExampleToolbarMenu() {
<Reactodia.ToolbarActionClearAll />
<Reactodia.ToolbarActionExport kind='exportRaster' />
<Reactodia.ToolbarActionExport kind='exportSvg' />
<Reactodia.ToolbarActionExport kind='print' />
<Reactodia.ToolbarActionExport kind='print' hotkey='Mod+P' />
</>
);
}
Expand Down
135 changes: 135 additions & 0 deletions src/coreUtils/hotkey.ts
Original file line number Diff line number Diff line change
@@ -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<string>}`;

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,
};
}
56 changes: 55 additions & 1 deletion src/diagram/canvasWidget.tsx
Original file line number Diff line number Diff line change
@@ -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');

Expand Down Expand Up @@ -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<typeof action>();
lastAction.current = action;

React.useEffect(() => {
if (actionKey) {
const renderingState = canvas.renderingState as MutableRenderingState;
return renderingState.listenHotkey(actionKey._ast, () => {
lastAction.current?.();
});
}
}, [actionKey]);

return actionKey;
}
1 change: 1 addition & 0 deletions src/diagram/paperArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -892,6 +892,7 @@ export class PaperArea extends React.Component<PaperAreaProps, State> 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)
Expand Down
32 changes: 31 additions & 1 deletion src/diagram/renderingState.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -195,6 +200,10 @@ export class MutableRenderingState implements RenderingState {
private readonly delayedUpdateRoutings = new Debouncer();
private routings: RoutedLinks = new Map<string, RoutedLink>();

private readonly hotkeyHandlers = new HashMap<HotkeyAst, Set<() => void>>(
hashHotkeyAst, sameHotkeyAst
);

readonly shared: SharedCanvasState;

/** @hidden */
Expand Down Expand Up @@ -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 &&
Expand Down
24 changes: 19 additions & 5 deletions src/editor/overlayController.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -104,7 +105,12 @@ export class OverlayController {
});

view.setCanvasWidget('selectionHandler', {
element: <CanvasOverlayHandler onCanvasPointerUp={this.onAnyCanvasPointerUp} />,
element: (
<CanvasOverlayHandler
onCanvasPointerUp={this.onAnyCanvasPointerUp}
onCanvasKeydown={this.onAnyCanvasKeydown}
/>
),
attachment: 'viewport',
});
}
Expand All @@ -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) {
Expand All @@ -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.
Expand Down Expand Up @@ -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;
}

Expand Down
Loading