From d126d04d179ec10aca05bf7b3b2a0fb3eb04f739 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Tue, 13 Apr 2021 01:29:25 +0530 Subject: [PATCH] feat: Bind keyboard events to the current excalidraw container and add handleKeyboardGlobally prop to allow host to bind to document (#3430) * fix: Bind keyboard events to excalidraw container * fix cases around blurring * fix modal rendering so keyboard shortcuts work on modal as well * Revert "fix modal rendering so keyboard shortcuts work on modal as well" This reverts commit 2c8ec6be8eff7d308591467fe2c33cfbca16138f. * Attach keyboard event in react way so we need not handle portals separately (modals) * dnt propagate esc event when modal shown * focus the container when help dialog closed with shift+? * focus the help icon when help dialog on close triggered * move focusNearestTabbableParent to util * rename util to focusNearestParent and remove outline from excal and modal * Add prop bindKeyGlobally to decide if keyboard events should be binded to document and allow it in excal app, revert tests * fix * focus container after installing library, reset library and closing error dialog * fix tests and create util to focus container * Add excalidraw-container class to focus on the container * pass focus container to library to focus current instance of excal * update docs * remove util as it wont be used anywhere * fix propagation not being stopped for React keyboard handling * tweak reamde Co-authored-by: David Luzar * tweak changelog * rename prop to handleKeyboardGlobally Co-authored-by: dwelle --- src/actions/actionFinalize.tsx | 4 +- src/actions/actionMenu.tsx | 5 +- src/actions/manager.tsx | 8 +- src/actions/types.ts | 8 +- src/components/App.tsx | 308 ++++++------ src/components/ColorPicker.tsx | 1 + src/components/Dialog.tsx | 1 - src/components/ErrorDialog.tsx | 1 + src/components/IconPicker.tsx | 1 + src/components/LayerUI.tsx | 19 +- src/components/Modal.scss | 4 + src/components/Modal.tsx | 2 + src/components/ProjectName.tsx | 2 + src/css/styles.scss | 4 + src/element/textWysiwyg.tsx | 5 +- src/excalidraw-app/index.tsx | 1 + src/packages/excalidraw/CHANGELOG.md | 8 + src/packages/excalidraw/README_NEXT.md | 7 + src/packages/excalidraw/index.tsx | 2 + .../__snapshots__/dragCreate.test.tsx.snap | 18 +- src/tests/dragCreate.test.tsx | 476 +++++++++--------- src/tests/multiPointCreate.test.tsx | 8 +- src/tests/regressionTests.test.tsx | 20 +- src/tests/selection.test.tsx | 20 +- src/types.ts | 2 + src/utils.ts | 11 + 26 files changed, 537 insertions(+), 409 deletions(-) diff --git a/src/actions/actionFinalize.tsx b/src/actions/actionFinalize.tsx index 30db774f97..d314f1a363 100644 --- a/src/actions/actionFinalize.tsx +++ b/src/actions/actionFinalize.tsx @@ -18,7 +18,7 @@ import { isBindingElement } from "../element/typeChecks"; export const actionFinalize = register({ name: "finalize", - perform: (elements, appState, _, { canvas }) => { + perform: (elements, appState, _, { canvas, focusContainer }) => { if (appState.editingLinearElement) { const { elementId, @@ -51,7 +51,7 @@ export const actionFinalize = register({ let newElements = elements; if (window.document.activeElement instanceof HTMLElement) { - window.document.activeElement.blur(); + focusContainer(); } const multiPointElement = appState.multiElement diff --git a/src/actions/actionMenu.tsx b/src/actions/actionMenu.tsx index 8cfae39814..18d56dc4a1 100644 --- a/src/actions/actionMenu.tsx +++ b/src/actions/actionMenu.tsx @@ -70,7 +70,10 @@ export const actionFullScreen = register({ export const actionShortcuts = register({ name: "toggleShortcuts", - perform: (_elements, appState) => { + perform: (_elements, appState, _, { focusContainer }) => { + if (appState.showHelpDialog) { + focusContainer(); + } return { appState: { ...appState, diff --git a/src/actions/manager.tsx b/src/actions/manager.tsx index 3cdc81f0a5..a0339ffa3b 100644 --- a/src/actions/manager.tsx +++ b/src/actions/manager.tsx @@ -12,7 +12,11 @@ import { MODES } from "../constants"; // This is the component, but for now we don't care about anything but its // `canvas` state. -type App = { canvas: HTMLCanvasElement | null; props: AppProps }; +type App = { + canvas: HTMLCanvasElement | null; + focusContainer: () => void; + props: AppProps; +}; export class ActionManager implements ActionsManagerInterface { actions = {} as ActionsManagerInterface["actions"]; @@ -51,7 +55,7 @@ export class ActionManager implements ActionsManagerInterface { actions.forEach((action) => this.registerAction(action)); } - handleKeyDown(event: KeyboardEvent) { + handleKeyDown(event: React.KeyboardEvent | KeyboardEvent) { const canvasActions = this.app.props.UIOptions.canvasActions; const data = Object.values(this.actions) .sort((a, b) => (b.keyPriority || 0) - (a.keyPriority || 0)) diff --git a/src/actions/types.ts b/src/actions/types.ts index 1597714d9e..5af6faa304 100644 --- a/src/actions/types.ts +++ b/src/actions/types.ts @@ -15,11 +15,13 @@ export type ActionResult = } | false; +type AppAPI = { canvas: HTMLCanvasElement | null; focusContainer(): void }; + type ActionFn = ( elements: readonly ExcalidrawElement[], appState: Readonly, formData: any, - app: { canvas: HTMLCanvasElement | null }, + app: AppAPI, ) => ActionResult | Promise; export type UpdaterFn = (res: ActionResult) => void; @@ -105,7 +107,7 @@ export interface Action { perform: ActionFn; keyPriority?: number; keyTest?: ( - event: KeyboardEvent, + event: React.KeyboardEvent | KeyboardEvent, appState: AppState, elements: readonly ExcalidrawElement[], ) => boolean; @@ -120,6 +122,6 @@ export interface Action { export interface ActionsManagerInterface { actions: Record; registerAction: (action: Action) => void; - handleKeyDown: (event: KeyboardEvent) => boolean; + handleKeyDown: (event: React.KeyboardEvent | KeyboardEvent) => boolean; renderAction: (name: ActionName) => React.ReactElement | null; } diff --git a/src/components/App.tsx b/src/components/App.tsx index 4b4b6c92b6..4961fa194e 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -445,12 +445,16 @@ class App extends React.Component { return (
{ } libraryReturnUrl={this.props.libraryReturnUrl} UIOptions={this.props.UIOptions} + focusContainer={this.focusContainer} />
@@ -509,6 +514,10 @@ class App extends React.Component { ); } + public focusContainer = () => { + this.excalidrawContainerRef.current?.focus(); + }; + public getSceneElementsIncludingDeleted = () => { return this.scene.getElementsIncludingDeleted(); }; @@ -655,6 +664,8 @@ class App extends React.Component { } catch (error) { window.alert(t("alerts.errorLoadingLibrary")); console.error(error); + } finally { + this.focusContainer(); } }; @@ -795,6 +806,10 @@ class App extends React.Component { this.scene.addCallback(this.onSceneUpdated); this.addEventListeners(); + if (this.excalidrawContainerRef.current) { + this.focusContainer(); + } + if ("ResizeObserver" in window && this.excalidrawContainerRef?.current) { this.resizeObserver = new ResizeObserver(() => { // compute isMobile state @@ -854,7 +869,6 @@ class App extends React.Component { EVENT.SCROLL, this.onScroll, ); - document.removeEventListener(EVENT.KEYDOWN, this.onKeyDown, false); document.removeEventListener( EVENT.MOUSE_MOVE, @@ -890,7 +904,9 @@ class App extends React.Component { private addEventListeners() { this.removeEventListeners(); document.addEventListener(EVENT.COPY, this.onCopy); - document.addEventListener(EVENT.KEYDOWN, this.onKeyDown, false); + if (this.props.handleKeyboardGlobally) { + document.addEventListener(EVENT.KEYDOWN, this.onKeyDown, false); + } document.addEventListener(EVENT.KEYUP, this.onKeyUp, { passive: true }); document.addEventListener( EVENT.MOUSE_MOVE, @@ -1434,152 +1450,156 @@ class App extends React.Component { // Input handling - private onKeyDown = withBatchedUpdates((event: KeyboardEvent) => { - // normalize `event.key` when CapsLock is pressed #2372 - if ( - "Proxy" in window && - ((!event.shiftKey && /^[A-Z]$/.test(event.key)) || - (event.shiftKey && /^[a-z]$/.test(event.key))) - ) { - event = new Proxy(event, { - get(ev: any, prop) { - const value = ev[prop]; - if (typeof value === "function") { - // fix for Proxies hijacking `this` - return value.bind(ev); - } - return prop === "key" - ? // CapsLock inverts capitalization based on ShiftKey, so invert - // it back - event.shiftKey - ? ev.key.toUpperCase() - : ev.key.toLowerCase() - : value; - }, - }); - } - - if ( - (isWritableElement(event.target) && event.key !== KEYS.ESCAPE) || - // case: using arrows to move between buttons - (isArrowKey(event.key) && isInputLike(event.target)) - ) { - return; - } - - if (event.key === KEYS.QUESTION_MARK) { - this.setState({ - showHelpDialog: true, - }); - } - - if (this.actionManager.handleKeyDown(event)) { - return; - } - - if (this.state.viewModeEnabled) { - return; - } - - if (event[KEYS.CTRL_OR_CMD] && this.state.isBindingEnabled) { - this.setState({ isBindingEnabled: false }); - } - - if (event.code === CODES.NINE) { - this.setState({ isLibraryOpen: !this.state.isLibraryOpen }); - } - - if (isArrowKey(event.key)) { - const step = - (this.state.gridSize && - (event.shiftKey ? ELEMENT_TRANSLATE_AMOUNT : this.state.gridSize)) || - (event.shiftKey - ? ELEMENT_SHIFT_TRANSLATE_AMOUNT - : ELEMENT_TRANSLATE_AMOUNT); - - const selectedElements = this.scene - .getElements() - .filter((element) => this.state.selectedElementIds[element.id]); - - let offsetX = 0; - let offsetY = 0; - - if (event.key === KEYS.ARROW_LEFT) { - offsetX = -step; - } else if (event.key === KEYS.ARROW_RIGHT) { - offsetX = step; - } else if (event.key === KEYS.ARROW_UP) { - offsetY = -step; - } else if (event.key === KEYS.ARROW_DOWN) { - offsetY = step; + private onKeyDown = withBatchedUpdates( + (event: React.KeyboardEvent | KeyboardEvent) => { + // normalize `event.key` when CapsLock is pressed #2372 + if ( + "Proxy" in window && + ((!event.shiftKey && /^[A-Z]$/.test(event.key)) || + (event.shiftKey && /^[a-z]$/.test(event.key))) + ) { + event = new Proxy(event, { + get(ev: any, prop) { + const value = ev[prop]; + if (typeof value === "function") { + // fix for Proxies hijacking `this` + return value.bind(ev); + } + return prop === "key" + ? // CapsLock inverts capitalization based on ShiftKey, so invert + // it back + event.shiftKey + ? ev.key.toUpperCase() + : ev.key.toLowerCase() + : value; + }, + }); } - selectedElements.forEach((element) => { - mutateElement(element, { - x: element.x + offsetX, - y: element.y + offsetY, - }); - - updateBoundElements(element, { - simultaneouslyUpdated: selectedElements, - }); - }); - - this.maybeSuggestBindingForAll(selectedElements); - - event.preventDefault(); - } else if (event.key === KEYS.ENTER) { - const selectedElements = getSelectedElements( - this.scene.getElements(), - this.state, - ); - if ( - selectedElements.length === 1 && - isLinearElement(selectedElements[0]) + (isWritableElement(event.target) && event.key !== KEYS.ESCAPE) || + // case: using arrows to move between buttons + (isArrowKey(event.key) && isInputLike(event.target)) ) { - if ( - !this.state.editingLinearElement || - this.state.editingLinearElement.elementId !== selectedElements[0].id - ) { - history.resumeRecording(); - this.setState({ - editingLinearElement: new LinearElementEditor( - selectedElements[0], - this.scene, - ), - }); - } - } else if ( - selectedElements.length === 1 && - !isLinearElement(selectedElements[0]) - ) { - const selectedElement = selectedElements[0]; - this.startTextEditing({ - sceneX: selectedElement.x + selectedElement.width / 2, - sceneY: selectedElement.y + selectedElement.height / 2, - }); - event.preventDefault(); return; } - } else if ( - !event.ctrlKey && - !event.altKey && - !event.metaKey && - this.state.draggingElement === null - ) { - const shape = findShapeByKey(event.key); - if (shape) { - this.selectShapeTool(shape); - } else if (event.key === KEYS.Q) { - this.toggleLock(); + + if (event.key === KEYS.QUESTION_MARK) { + this.setState({ + showHelpDialog: true, + }); } - } - if (event.key === KEYS.SPACE && gesture.pointers.size === 0) { - isHoldingSpace = true; - setCursor(this.canvas, CURSOR_TYPE.GRABBING); - } - }); + + if (this.actionManager.handleKeyDown(event)) { + return; + } + + if (this.state.viewModeEnabled) { + return; + } + + if (event[KEYS.CTRL_OR_CMD] && this.state.isBindingEnabled) { + this.setState({ isBindingEnabled: false }); + } + + if (event.code === CODES.NINE) { + this.setState({ isLibraryOpen: !this.state.isLibraryOpen }); + } + + if (isArrowKey(event.key)) { + const step = + (this.state.gridSize && + (event.shiftKey + ? ELEMENT_TRANSLATE_AMOUNT + : this.state.gridSize)) || + (event.shiftKey + ? ELEMENT_SHIFT_TRANSLATE_AMOUNT + : ELEMENT_TRANSLATE_AMOUNT); + + const selectedElements = this.scene + .getElements() + .filter((element) => this.state.selectedElementIds[element.id]); + + let offsetX = 0; + let offsetY = 0; + + if (event.key === KEYS.ARROW_LEFT) { + offsetX = -step; + } else if (event.key === KEYS.ARROW_RIGHT) { + offsetX = step; + } else if (event.key === KEYS.ARROW_UP) { + offsetY = -step; + } else if (event.key === KEYS.ARROW_DOWN) { + offsetY = step; + } + + selectedElements.forEach((element) => { + mutateElement(element, { + x: element.x + offsetX, + y: element.y + offsetY, + }); + + updateBoundElements(element, { + simultaneouslyUpdated: selectedElements, + }); + }); + + this.maybeSuggestBindingForAll(selectedElements); + + event.preventDefault(); + } else if (event.key === KEYS.ENTER) { + const selectedElements = getSelectedElements( + this.scene.getElements(), + this.state, + ); + + if ( + selectedElements.length === 1 && + isLinearElement(selectedElements[0]) + ) { + if ( + !this.state.editingLinearElement || + this.state.editingLinearElement.elementId !== selectedElements[0].id + ) { + history.resumeRecording(); + this.setState({ + editingLinearElement: new LinearElementEditor( + selectedElements[0], + this.scene, + ), + }); + } + } else if ( + selectedElements.length === 1 && + !isLinearElement(selectedElements[0]) + ) { + const selectedElement = selectedElements[0]; + this.startTextEditing({ + sceneX: selectedElement.x + selectedElement.width / 2, + sceneY: selectedElement.y + selectedElement.height / 2, + }); + event.preventDefault(); + return; + } + } else if ( + !event.ctrlKey && + !event.altKey && + !event.metaKey && + this.state.draggingElement === null + ) { + const shape = findShapeByKey(event.key); + if (shape) { + this.selectShapeTool(shape); + } else if (event.key === KEYS.Q) { + this.toggleLock(); + } + } + if (event.key === KEYS.SPACE && gesture.pointers.size === 0) { + isHoldingSpace = true; + setCursor(this.canvas, CURSOR_TYPE.GRABBING); + } + }, + ); private onKeyUp = withBatchedUpdates((event: KeyboardEvent) => { if (event.key === KEYS.SPACE) { @@ -1615,7 +1635,7 @@ class App extends React.Component { setCursorForShape(this.canvas, elementType); } if (isToolIcon(document.activeElement)) { - document.activeElement.blur(); + this.focusContainer(); } if (!isLinearElementType(elementType)) { this.setState({ suggestedBindings: [] }); @@ -1745,6 +1765,8 @@ class App extends React.Component { if (this.state.elementLocked) { setCursorForShape(this.canvas, this.state.elementType); } + + this.focusContainer(); }), element, }); diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx index 82d6d9e172..bc353e58cc 100644 --- a/src/components/ColorPicker.tsx +++ b/src/components/ColorPicker.tsx @@ -115,6 +115,7 @@ const Picker = ({ onClose(); } event.nativeEvent.stopImmediatePropagation(); + event.stopPropagation(); }; return ( diff --git a/src/components/Dialog.tsx b/src/components/Dialog.tsx index 2646751706..c1d71fefc0 100644 --- a/src/components/Dialog.tsx +++ b/src/components/Dialog.tsx @@ -18,7 +18,6 @@ export const Dialog = (props: { autofocus?: boolean; }) => { const [islandNode, setIslandNode] = useCallbackRefState(); - useEffect(() => { if (!islandNode) { return; diff --git a/src/components/ErrorDialog.tsx b/src/components/ErrorDialog.tsx index 2886222c70..63493f80f1 100644 --- a/src/components/ErrorDialog.tsx +++ b/src/components/ErrorDialog.tsx @@ -18,6 +18,7 @@ export const ErrorDialog = ({ if (onClose) { onClose(); } + document.querySelector(".excalidraw-container")?.focus(); }, [onClose]); return ( diff --git a/src/components/IconPicker.tsx b/src/components/IconPicker.tsx index 77e9d5f8bf..9b6d7ea5c5 100644 --- a/src/components/IconPicker.tsx +++ b/src/components/IconPicker.tsx @@ -88,6 +88,7 @@ function Picker({ onClose(); } event.nativeEvent.stopImmediatePropagation(); + event.stopPropagation(); }; return ( diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index 304bb048f0..67b579d4ab 100644 --- a/src/components/LayerUI.tsx +++ b/src/components/LayerUI.tsx @@ -72,6 +72,7 @@ interface LayerUIProps { viewModeEnabled: boolean; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; UIOptions: AppProps["UIOptions"]; + focusContainer: () => void; } const useOnClickOutside = ( @@ -111,6 +112,7 @@ const LibraryMenuItems = ({ setAppState, setLibraryItems, libraryReturnUrl, + focusContainer, }: { library: LibraryItems; pendingElements: LibraryItem; @@ -120,6 +122,7 @@ const LibraryMenuItems = ({ setAppState: React.Component["setState"]; setLibraryItems: (library: LibraryItems) => void; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; + focusContainer: () => void; }) => { const isMobile = useIsMobile(); const numCells = library.length + (pendingElements.length > 0 ? 1 : 0); @@ -178,6 +181,7 @@ const LibraryMenuItems = ({ if (window.confirm(t("alerts.resetLibrary"))) { Library.resetLibrary(); setLibraryItems([]); + focusContainer(); } }} /> @@ -242,6 +246,7 @@ const LibraryMenu = ({ onAddToLibrary, setAppState, libraryReturnUrl, + focusContainer, }: { pendingElements: LibraryItem; onClickOutside: (event: MouseEvent) => void; @@ -249,6 +254,7 @@ const LibraryMenu = ({ onAddToLibrary: () => void; setAppState: React.Component["setState"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; + focusContainer: () => void; }) => { const ref = useRef(null); useOnClickOutside(ref, (event) => { @@ -322,6 +328,7 @@ const LibraryMenu = ({ setAppState={setAppState} setLibraryItems={setLibraryItems} libraryReturnUrl={libraryReturnUrl} + focusContainer={focusContainer} /> )} @@ -347,6 +354,7 @@ const LayerUI = ({ viewModeEnabled, libraryReturnUrl, UIOptions, + focusContainer, }: LayerUIProps) => { const isMobile = useIsMobile(); @@ -517,6 +525,7 @@ const LayerUI = ({ onAddToLibrary={deselectItems} setAppState={setAppState} libraryReturnUrl={libraryReturnUrl} + focusContainer={focusContainer} /> ) : null; @@ -660,7 +669,15 @@ const LayerUI = ({ /> )} {appState.showHelpDialog && ( - setAppState({ showHelpDialog: false })} /> + { + const helpIcon = document.querySelector( + ".help-icon", + )! as HTMLElement; + helpIcon.focus(); + setAppState({ showHelpDialog: false }); + }} + /> )} {appState.pasteDialog.shown && ( { if (event.key === KEYS.ESCAPE) { event.nativeEvent.stopImmediatePropagation(); + event.stopPropagation(); props.onCloseRequest(); } }; @@ -38,6 +39,7 @@ export const Modal = (props: {
{props.children}
diff --git a/src/components/ProjectName.tsx b/src/components/ProjectName.tsx index a7d723ef36..9e7af01e71 100644 --- a/src/components/ProjectName.tsx +++ b/src/components/ProjectName.tsx @@ -1,6 +1,7 @@ import "./TextInput.scss"; import React, { Component } from "react"; +import { focusNearestParent } from "../utils"; type Props = { value: string; @@ -17,6 +18,7 @@ export class ProjectName extends Component { fileName: this.props.value, }; private handleBlur = (event: any) => { + focusNearestParent(event.target); const value = event.target.value; if (value !== this.props.value) { this.props.onChange(value); diff --git a/src/css/styles.scss b/src/css/styles.scss index 164c942315..aac0110096 100644 --- a/src/css/styles.scss +++ b/src/css/styles.scss @@ -19,6 +19,10 @@ height: 100%; width: 100%; + &:focus { + outline: none; + } + // serves 2 purposes: // 1. prevent selecting text outside the component when double-clicking or // dragging inside it (e.g. on canvas) diff --git a/src/element/textWysiwyg.tsx b/src/element/textWysiwyg.tsx index 862a957780..1329e13b0c 100644 --- a/src/element/textWysiwyg.tsx +++ b/src/element/textWysiwyg.tsx @@ -159,11 +159,14 @@ export const textWysiwyg = ({ // so that we don't need to create separate a callback for event handlers let submittedViaKeyboard = false; const handleSubmit = () => { + // cleanup must be run before onSubmit otherwise when app blurs the wysiwyg + // it'd get stuck in an infinite loop of blur→onSubmit after we re-focus the + // wysiwyg on update + cleanup(); onSubmit({ text: normalizeText(editable.value), viaKeyboard: submittedViaKeyboard, }); - cleanup(); }; const cleanup = () => { diff --git a/src/excalidraw-app/index.tsx b/src/excalidraw-app/index.tsx index 3a9763af2a..36915d6c02 100644 --- a/src/excalidraw-app/index.tsx +++ b/src/excalidraw-app/index.tsx @@ -324,6 +324,7 @@ const ExcalidrawWrapper = () => { langCode={langCode} renderCustomStats={renderCustomStats} detectScroll={false} + handleKeyboardGlobally={true} /> {excalidrawAPI && } {errorMessage && ( diff --git a/src/packages/excalidraw/CHANGELOG.md b/src/packages/excalidraw/CHANGELOG.md index 71a2b3546c..5fbc2b3e5d 100644 --- a/src/packages/excalidraw/CHANGELOG.md +++ b/src/packages/excalidraw/CHANGELOG.md @@ -15,6 +15,14 @@ Please add the latest change on the top under the correct section. ## Excalidraw API +### Features + +- Bind the keyboard events to component and added a prop [`handleKeyboardGlobally`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#handleKeyboardGlobally) which if set to true will bind the keyboard events to document [#3430](https://github.com/excalidraw/excalidraw/pull/3430). + + #### BREAKING CHNAGE + + - Earlier keyboard events were bind to document but now its bind to Excalidraw component by default. So you will need to set [`handleKeyboardGlobally`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#handleKeyboardGlobally) to true if you want the previous behaviour (bind the keyboard events to document). + - Recompute offsets on `scroll` of the nearest scrollable container [#3408](https://github.com/excalidraw/excalidraw/pull/3408). This can be disabled by setting [`detectScroll`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#detectScroll) to `false`. - Add `onPaste` prop to handle custom clipboard behaviours [#3420](https://github.com/excalidraw/excalidraw/pull/3420). diff --git a/src/packages/excalidraw/README_NEXT.md b/src/packages/excalidraw/README_NEXT.md index 49c86126da..488985ea2d 100644 --- a/src/packages/excalidraw/README_NEXT.md +++ b/src/packages/excalidraw/README_NEXT.md @@ -366,6 +366,7 @@ To view the full example visit :point_down: | [`UIOptions`](#UIOptions) |
{ canvasActions:  CanvasActions }
| [DEFAULT UI OPTIONS](https://github.com/excalidraw/excalidraw/blob/master/src/constants.ts#L129) | To customise UI options. Currently we support customising [`canvas actions`](#canvasActions) | | [`onPaste`](#onPaste) |
(data: ClipboardData, event: ClipboardEvent | null) => boolean
| | Callback to be triggered if passed when the something is pasted in to the scene | | [`detectScroll`](#detectScroll) | boolean | true | Indicates whether to update the offsets when nearest ancestor is scrolled. | +| [`handleKeyboardGlobally`](#handleKeyboardGlobally) | boolean | false | Indicates whether to bind the keyboard events to document. | ### Dimensions of Excalidraw @@ -592,6 +593,12 @@ Try out the [Demo](#Demo) to see it in action. Indicates whether Excalidraw should listen for `scroll` event on the nearest scrollable container in the DOM tree and recompute the coordinates (e.g. to correctly handle the cursor) when the component's position changes. You can disable this when you either know this doesn't affect your app or you want to take care of it yourself (calling the [`refresh()`](#ref) method). +### handleKeyboardGlobally + +Indicates whether to bind keyboard events to `document`. Disabled by default, meaning the keyboard events are bound to the Excalidraw component. This allows for multiple Excalidraw components to live on the same page, and ensures that Excalidraw keyboard handling doesn't collide with your app's (or the browser) when the component isn't focused. + +Enable this if you want Excalidraw to handle keyboard even if the component isn't focused (e.g. a user is interacting with the navbar, sidebar, or similar). + ### Extra API's #### `getSceneVersion` diff --git a/src/packages/excalidraw/index.tsx b/src/packages/excalidraw/index.tsx index 3278169e0c..ffe106353c 100644 --- a/src/packages/excalidraw/index.tsx +++ b/src/packages/excalidraw/index.tsx @@ -31,6 +31,7 @@ const Excalidraw = (props: ExcalidrawProps) => { renderCustomStats, onPaste, detectScroll = true, + handleKeyboardGlobally = false, } = props; const canvasActions = props.UIOptions?.canvasActions; @@ -82,6 +83,7 @@ const Excalidraw = (props: ExcalidrawProps) => { UIOptions={UIOptions} onPaste={onPaste} detectScroll={detectScroll} + handleKeyboardGlobally={handleKeyboardGlobally} /> ); diff --git a/src/tests/__snapshots__/dragCreate.test.tsx.snap b/src/tests/__snapshots__/dragCreate.test.tsx.snap index d2721d30ab..2f7cb64301 100644 --- a/src/tests/__snapshots__/dragCreate.test.tsx.snap +++ b/src/tests/__snapshots__/dragCreate.test.tsx.snap @@ -1,8 +1,8 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`add element to the scene when pointer dragging long enough arrow 1`] = `1`; +exports[`Test dragCreate add element to the scene when pointer dragging long enough arrow 1`] = `1`; -exports[`add element to the scene when pointer dragging long enough arrow 2`] = ` +exports[`Test dragCreate add element to the scene when pointer dragging long enough arrow 2`] = ` Object { "angle": 0, "backgroundColor": "transparent", @@ -43,9 +43,9 @@ Object { } `; -exports[`add element to the scene when pointer dragging long enough diamond 1`] = `1`; +exports[`Test dragCreate add element to the scene when pointer dragging long enough diamond 1`] = `1`; -exports[`add element to the scene when pointer dragging long enough diamond 2`] = ` +exports[`Test dragCreate add element to the scene when pointer dragging long enough diamond 2`] = ` Object { "angle": 0, "backgroundColor": "transparent", @@ -71,9 +71,9 @@ Object { } `; -exports[`add element to the scene when pointer dragging long enough ellipse 1`] = `1`; +exports[`Test dragCreate add element to the scene when pointer dragging long enough ellipse 1`] = `1`; -exports[`add element to the scene when pointer dragging long enough ellipse 2`] = ` +exports[`Test dragCreate add element to the scene when pointer dragging long enough ellipse 2`] = ` Object { "angle": 0, "backgroundColor": "transparent", @@ -99,7 +99,7 @@ Object { } `; -exports[`add element to the scene when pointer dragging long enough line 1`] = ` +exports[`Test dragCreate add element to the scene when pointer dragging long enough line 1`] = ` Object { "angle": 0, "backgroundColor": "transparent", @@ -140,9 +140,9 @@ Object { } `; -exports[`add element to the scene when pointer dragging long enough rectangle 1`] = `1`; +exports[`Test dragCreate add element to the scene when pointer dragging long enough rectangle 1`] = `1`; -exports[`add element to the scene when pointer dragging long enough rectangle 2`] = ` +exports[`Test dragCreate add element to the scene when pointer dragging long enough rectangle 2`] = ` Object { "angle": 0, "backgroundColor": "transparent", diff --git a/src/tests/dragCreate.test.tsx b/src/tests/dragCreate.test.tsx index 895f882d3b..3af6683382 100644 --- a/src/tests/dragCreate.test.tsx +++ b/src/tests/dragCreate.test.tsx @@ -24,276 +24,282 @@ beforeEach(() => { const { h } = window; -describe("add element to the scene when pointer dragging long enough", () => { - it("rectangle", async () => { - const { getByToolName, container } = await render(); - // select tool - const tool = getByToolName("rectangle"); - fireEvent.click(tool); +describe("Test dragCreate", () => { + describe("add element to the scene when pointer dragging long enough", () => { + it("rectangle", async () => { + const { getByToolName, container } = await render(); + // select tool + const tool = getByToolName("rectangle"); + fireEvent.click(tool); - const canvas = container.querySelector("canvas")!; + const canvas = container.querySelector("canvas")!; - // start from (30, 20) - fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); + // start from (30, 20) + fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); - // move to (60,70) - fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); + // move to (60,70) + fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); - // finish (position does not matter) - fireEvent.pointerUp(canvas); + // finish (position does not matter) + fireEvent.pointerUp(canvas); - expect(renderScene).toHaveBeenCalledTimes(8); - expect(h.state.selectionElement).toBeNull(); + expect(renderScene).toHaveBeenCalledTimes(8); + expect(h.state.selectionElement).toBeNull(); - expect(h.elements.length).toEqual(1); - expect(h.elements[0].type).toEqual("rectangle"); - expect(h.elements[0].x).toEqual(30); - expect(h.elements[0].y).toEqual(20); - expect(h.elements[0].width).toEqual(30); // 60 - 30 - expect(h.elements[0].height).toEqual(50); // 70 - 20 + expect(h.elements.length).toEqual(1); + expect(h.elements[0].type).toEqual("rectangle"); + expect(h.elements[0].x).toEqual(30); + expect(h.elements[0].y).toEqual(20); + expect(h.elements[0].width).toEqual(30); // 60 - 30 + expect(h.elements[0].height).toEqual(50); // 70 - 20 - expect(h.elements.length).toMatchSnapshot(); - h.elements.forEach((element) => expect(element).toMatchSnapshot()); + expect(h.elements.length).toMatchSnapshot(); + h.elements.forEach((element) => expect(element).toMatchSnapshot()); + }); + + it("ellipse", async () => { + const { getByToolName, container } = await render(); + // select tool + const tool = getByToolName("ellipse"); + fireEvent.click(tool); + + const canvas = container.querySelector("canvas")!; + + // start from (30, 20) + fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); + + // move to (60,70) + fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); + + // finish (position does not matter) + fireEvent.pointerUp(canvas); + + expect(renderScene).toHaveBeenCalledTimes(8); + expect(h.state.selectionElement).toBeNull(); + + expect(h.elements.length).toEqual(1); + expect(h.elements[0].type).toEqual("ellipse"); + expect(h.elements[0].x).toEqual(30); + expect(h.elements[0].y).toEqual(20); + expect(h.elements[0].width).toEqual(30); // 60 - 30 + expect(h.elements[0].height).toEqual(50); // 70 - 20 + + expect(h.elements.length).toMatchSnapshot(); + h.elements.forEach((element) => expect(element).toMatchSnapshot()); + }); + + it("diamond", async () => { + const { getByToolName, container } = await render(); + // select tool + const tool = getByToolName("diamond"); + fireEvent.click(tool); + + const canvas = container.querySelector("canvas")!; + + // start from (30, 20) + fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); + + // move to (60,70) + fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); + + // finish (position does not matter) + fireEvent.pointerUp(canvas); + + expect(renderScene).toHaveBeenCalledTimes(8); + expect(h.state.selectionElement).toBeNull(); + + expect(h.elements.length).toEqual(1); + expect(h.elements[0].type).toEqual("diamond"); + expect(h.elements[0].x).toEqual(30); + expect(h.elements[0].y).toEqual(20); + expect(h.elements[0].width).toEqual(30); // 60 - 30 + expect(h.elements[0].height).toEqual(50); // 70 - 20 + + expect(h.elements.length).toMatchSnapshot(); + h.elements.forEach((element) => expect(element).toMatchSnapshot()); + }); + + it("arrow", async () => { + const { getByToolName, container } = await render(); + // select tool + const tool = getByToolName("arrow"); + fireEvent.click(tool); + + const canvas = container.querySelector("canvas")!; + + // start from (30, 20) + fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); + + // move to (60,70) + fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); + + // finish (position does not matter) + fireEvent.pointerUp(canvas); + + expect(renderScene).toHaveBeenCalledTimes(8); + expect(h.state.selectionElement).toBeNull(); + + expect(h.elements.length).toEqual(1); + + const element = h.elements[0] as ExcalidrawLinearElement; + + expect(element.type).toEqual("arrow"); + expect(element.x).toEqual(30); + expect(element.y).toEqual(20); + expect(element.points.length).toEqual(2); + expect(element.points[0]).toEqual([0, 0]); + expect(element.points[1]).toEqual([30, 50]); // (60 - 30, 70 - 20) + + expect(h.elements.length).toMatchSnapshot(); + h.elements.forEach((element) => expect(element).toMatchSnapshot()); + }); + + it("line", async () => { + const { getByToolName, container } = await render(); + // select tool + const tool = getByToolName("line"); + fireEvent.click(tool); + + const canvas = container.querySelector("canvas")!; + + // start from (30, 20) + fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); + + // move to (60,70) + fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); + + // finish (position does not matter) + fireEvent.pointerUp(canvas); + + expect(renderScene).toHaveBeenCalledTimes(8); + expect(h.state.selectionElement).toBeNull(); + + expect(h.elements.length).toEqual(1); + + const element = h.elements[0] as ExcalidrawLinearElement; + + expect(element.type).toEqual("line"); + expect(element.x).toEqual(30); + expect(element.y).toEqual(20); + expect(element.points.length).toEqual(2); + expect(element.points[0]).toEqual([0, 0]); + expect(element.points[1]).toEqual([30, 50]); // (60 - 30, 70 - 20) + + h.elements.forEach((element) => expect(element).toMatchSnapshot()); + }); }); - it("ellipse", async () => { - const { getByToolName, container } = await render(); - // select tool - const tool = getByToolName("ellipse"); - fireEvent.click(tool); + describe("do not add element to the scene if size is too small", () => { + beforeAll(() => { + mockBoundingClientRect(); + }); + afterAll(() => { + restoreOriginalGetBoundingClientRect(); + }); - const canvas = container.querySelector("canvas")!; + it("rectangle", async () => { + const { getByToolName, container } = await render(); + // select tool + const tool = getByToolName("rectangle"); + fireEvent.click(tool); - // start from (30, 20) - fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); + const canvas = container.querySelector("canvas")!; - // move to (60,70) - fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); + // start from (30, 20) + fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); - // finish (position does not matter) - fireEvent.pointerUp(canvas); + // finish (position does not matter) + fireEvent.pointerUp(canvas); - expect(renderScene).toHaveBeenCalledTimes(8); - expect(h.state.selectionElement).toBeNull(); + expect(renderScene).toHaveBeenCalledTimes(6); + expect(h.state.selectionElement).toBeNull(); + expect(h.elements.length).toEqual(0); + }); - expect(h.elements.length).toEqual(1); - expect(h.elements[0].type).toEqual("ellipse"); - expect(h.elements[0].x).toEqual(30); - expect(h.elements[0].y).toEqual(20); - expect(h.elements[0].width).toEqual(30); // 60 - 30 - expect(h.elements[0].height).toEqual(50); // 70 - 20 + it("ellipse", async () => { + const { getByToolName, container } = await render(); + // select tool + const tool = getByToolName("ellipse"); + fireEvent.click(tool); - expect(h.elements.length).toMatchSnapshot(); - h.elements.forEach((element) => expect(element).toMatchSnapshot()); - }); + const canvas = container.querySelector("canvas")!; - it("diamond", async () => { - const { getByToolName, container } = await render(); - // select tool - const tool = getByToolName("diamond"); - fireEvent.click(tool); + // start from (30, 20) + fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); - const canvas = container.querySelector("canvas")!; + // finish (position does not matter) + fireEvent.pointerUp(canvas); - // start from (30, 20) - fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); + expect(renderScene).toHaveBeenCalledTimes(6); + expect(h.state.selectionElement).toBeNull(); + expect(h.elements.length).toEqual(0); + }); - // move to (60,70) - fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); + it("diamond", async () => { + const { getByToolName, container } = await render(); + // select tool + const tool = getByToolName("diamond"); + fireEvent.click(tool); - // finish (position does not matter) - fireEvent.pointerUp(canvas); + const canvas = container.querySelector("canvas")!; - expect(renderScene).toHaveBeenCalledTimes(8); - expect(h.state.selectionElement).toBeNull(); + // start from (30, 20) + fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); - expect(h.elements.length).toEqual(1); - expect(h.elements[0].type).toEqual("diamond"); - expect(h.elements[0].x).toEqual(30); - expect(h.elements[0].y).toEqual(20); - expect(h.elements[0].width).toEqual(30); // 60 - 30 - expect(h.elements[0].height).toEqual(50); // 70 - 20 + // finish (position does not matter) + fireEvent.pointerUp(canvas); - expect(h.elements.length).toMatchSnapshot(); - h.elements.forEach((element) => expect(element).toMatchSnapshot()); - }); + expect(renderScene).toHaveBeenCalledTimes(6); + expect(h.state.selectionElement).toBeNull(); + expect(h.elements.length).toEqual(0); + }); - it("arrow", async () => { - const { getByToolName, container } = await render(); - // select tool - const tool = getByToolName("arrow"); - fireEvent.click(tool); + it("arrow", async () => { + const { getByToolName, container } = await render(); + // select tool + const tool = getByToolName("arrow"); + fireEvent.click(tool); - const canvas = container.querySelector("canvas")!; + const canvas = container.querySelector("canvas")!; - // start from (30, 20) - fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); + // start from (30, 20) + fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); - // move to (60,70) - fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); + // finish (position does not matter) + fireEvent.pointerUp(canvas); - // finish (position does not matter) - fireEvent.pointerUp(canvas); + // we need to finalize it because arrows and lines enter multi-mode + fireEvent.keyDown(document, { + key: KEYS.ENTER, + }); - expect(renderScene).toHaveBeenCalledTimes(8); - expect(h.state.selectionElement).toBeNull(); + expect(renderScene).toHaveBeenCalledTimes(7); + expect(h.state.selectionElement).toBeNull(); + expect(h.elements.length).toEqual(0); + }); - expect(h.elements.length).toEqual(1); + it("line", async () => { + const { getByToolName, container } = await render(); + // select tool + const tool = getByToolName("line"); + fireEvent.click(tool); - const element = h.elements[0] as ExcalidrawLinearElement; + const canvas = container.querySelector("canvas")!; - expect(element.type).toEqual("arrow"); - expect(element.x).toEqual(30); - expect(element.y).toEqual(20); - expect(element.points.length).toEqual(2); - expect(element.points[0]).toEqual([0, 0]); - expect(element.points[1]).toEqual([30, 50]); // (60 - 30, 70 - 20) + // start from (30, 20) + fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); - expect(h.elements.length).toMatchSnapshot(); - h.elements.forEach((element) => expect(element).toMatchSnapshot()); - }); + // finish (position does not matter) + fireEvent.pointerUp(canvas); - it("line", async () => { - const { getByToolName, container } = await render(); - // select tool - const tool = getByToolName("line"); - fireEvent.click(tool); + // we need to finalize it because arrows and lines enter multi-mode + fireEvent.keyDown(document, { + key: KEYS.ENTER, + }); - const canvas = container.querySelector("canvas")!; - - // start from (30, 20) - fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); - - // move to (60,70) - fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); - - // finish (position does not matter) - fireEvent.pointerUp(canvas); - - expect(renderScene).toHaveBeenCalledTimes(8); - expect(h.state.selectionElement).toBeNull(); - - expect(h.elements.length).toEqual(1); - - const element = h.elements[0] as ExcalidrawLinearElement; - - expect(element.type).toEqual("line"); - expect(element.x).toEqual(30); - expect(element.y).toEqual(20); - expect(element.points.length).toEqual(2); - expect(element.points[0]).toEqual([0, 0]); - expect(element.points[1]).toEqual([30, 50]); // (60 - 30, 70 - 20) - - h.elements.forEach((element) => expect(element).toMatchSnapshot()); - }); -}); - -describe("do not add element to the scene if size is too small", () => { - beforeAll(() => { - mockBoundingClientRect(); - }); - afterAll(() => { - restoreOriginalGetBoundingClientRect(); - }); - - it("rectangle", async () => { - const { getByToolName, container } = await render(); - // select tool - const tool = getByToolName("rectangle"); - fireEvent.click(tool); - - const canvas = container.querySelector("canvas")!; - - // start from (30, 20) - fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); - - // finish (position does not matter) - fireEvent.pointerUp(canvas); - - expect(renderScene).toHaveBeenCalledTimes(6); - expect(h.state.selectionElement).toBeNull(); - expect(h.elements.length).toEqual(0); - }); - - it("ellipse", async () => { - const { getByToolName, container } = await render(); - // select tool - const tool = getByToolName("ellipse"); - fireEvent.click(tool); - - const canvas = container.querySelector("canvas")!; - - // start from (30, 20) - fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); - - // finish (position does not matter) - fireEvent.pointerUp(canvas); - - expect(renderScene).toHaveBeenCalledTimes(6); - expect(h.state.selectionElement).toBeNull(); - expect(h.elements.length).toEqual(0); - }); - - it("diamond", async () => { - const { getByToolName, container } = await render(); - // select tool - const tool = getByToolName("diamond"); - fireEvent.click(tool); - - const canvas = container.querySelector("canvas")!; - - // start from (30, 20) - fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); - - // finish (position does not matter) - fireEvent.pointerUp(canvas); - - expect(renderScene).toHaveBeenCalledTimes(6); - expect(h.state.selectionElement).toBeNull(); - expect(h.elements.length).toEqual(0); - }); - - it("arrow", async () => { - const { getByToolName, container } = await render(); - // select tool - const tool = getByToolName("arrow"); - fireEvent.click(tool); - - const canvas = container.querySelector("canvas")!; - - // start from (30, 20) - fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); - - // finish (position does not matter) - fireEvent.pointerUp(canvas); - - // we need to finalize it because arrows and lines enter multi-mode - fireEvent.keyDown(document, { key: KEYS.ENTER }); - - expect(renderScene).toHaveBeenCalledTimes(7); - expect(h.state.selectionElement).toBeNull(); - expect(h.elements.length).toEqual(0); - }); - - it("line", async () => { - const { getByToolName, container } = await render(); - // select tool - const tool = getByToolName("line"); - fireEvent.click(tool); - - const canvas = container.querySelector("canvas")!; - - // start from (30, 20) - fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); - - // finish (position does not matter) - fireEvent.pointerUp(canvas); - - // we need to finalize it because arrows and lines enter multi-mode - fireEvent.keyDown(document, { key: KEYS.ENTER }); - - expect(renderScene).toHaveBeenCalledTimes(7); - expect(h.state.selectionElement).toBeNull(); - expect(h.elements.length).toEqual(0); + expect(renderScene).toHaveBeenCalledTimes(7); + expect(h.state.selectionElement).toBeNull(); + expect(h.elements.length).toEqual(0); + }); }); }); diff --git a/src/tests/multiPointCreate.test.tsx b/src/tests/multiPointCreate.test.tsx index 748f8807bb..d3e40767c8 100644 --- a/src/tests/multiPointCreate.test.tsx +++ b/src/tests/multiPointCreate.test.tsx @@ -99,7 +99,9 @@ describe("multi point mode in linear elements", () => { // done fireEvent.pointerDown(canvas); fireEvent.pointerUp(canvas); - fireEvent.keyDown(document, { key: KEYS.ENTER }); + fireEvent.keyDown(document, { + key: KEYS.ENTER, + }); expect(renderScene).toHaveBeenCalledTimes(14); expect(h.elements.length).toEqual(1); @@ -140,7 +142,9 @@ describe("multi point mode in linear elements", () => { // done fireEvent.pointerDown(canvas); fireEvent.pointerUp(canvas); - fireEvent.keyDown(document, { key: KEYS.ENTER }); + fireEvent.keyDown(document, { + key: KEYS.ENTER, + }); expect(renderScene).toHaveBeenCalledTimes(14); expect(h.elements.length).toEqual(1); diff --git a/src/tests/regressionTests.test.tsx b/src/tests/regressionTests.test.tsx index 9c44a497a7..3aca8c38f3 100644 --- a/src/tests/regressionTests.test.tsx +++ b/src/tests/regressionTests.test.tsx @@ -413,11 +413,23 @@ describe("regression tests", () => { it("zoom hotkeys", () => { expect(h.state.zoom.value).toBe(1); - fireEvent.keyDown(document, { code: CODES.EQUAL, ctrlKey: true }); - fireEvent.keyUp(document, { code: CODES.EQUAL, ctrlKey: true }); + fireEvent.keyDown(document, { + code: CODES.EQUAL, + ctrlKey: true, + }); + fireEvent.keyUp(document, { + code: CODES.EQUAL, + ctrlKey: true, + }); expect(h.state.zoom.value).toBeGreaterThan(1); - fireEvent.keyDown(document, { code: CODES.MINUS, ctrlKey: true }); - fireEvent.keyUp(document, { code: CODES.MINUS, ctrlKey: true }); + fireEvent.keyDown(document, { + code: CODES.MINUS, + ctrlKey: true, + }); + fireEvent.keyUp(document, { + code: CODES.MINUS, + ctrlKey: true, + }); expect(h.state.zoom.value).toBe(1); }); diff --git a/src/tests/selection.test.tsx b/src/tests/selection.test.tsx index b882713adb..6fa1bddaec 100644 --- a/src/tests/selection.test.tsx +++ b/src/tests/selection.test.tsx @@ -100,7 +100,9 @@ describe("select single element on the scene", () => { fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); fireEvent.pointerUp(canvas); - fireEvent.keyDown(document, { key: KEYS.ESCAPE }); + fireEvent.keyDown(document, { + key: KEYS.ESCAPE, + }); } const tool = getByToolName("selection"); @@ -127,7 +129,9 @@ describe("select single element on the scene", () => { fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); fireEvent.pointerUp(canvas); - fireEvent.keyDown(document, { key: KEYS.ESCAPE }); + fireEvent.keyDown(document, { + key: KEYS.ESCAPE, + }); } const tool = getByToolName("selection"); @@ -154,7 +158,9 @@ describe("select single element on the scene", () => { fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); fireEvent.pointerUp(canvas); - fireEvent.keyDown(document, { key: KEYS.ESCAPE }); + fireEvent.keyDown(document, { + key: KEYS.ESCAPE, + }); } const tool = getByToolName("selection"); @@ -181,7 +187,9 @@ describe("select single element on the scene", () => { fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); fireEvent.pointerUp(canvas); - fireEvent.keyDown(document, { key: KEYS.ESCAPE }); + fireEvent.keyDown(document, { + key: KEYS.ESCAPE, + }); } /* @@ -220,7 +228,9 @@ describe("select single element on the scene", () => { fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); fireEvent.pointerUp(canvas); - fireEvent.keyDown(document, { key: KEYS.ESCAPE }); + fireEvent.keyDown(document, { + key: KEYS.ESCAPE, + }); } /* diff --git a/src/types.ts b/src/types.ts index e2d52f8245..7b95f38366 100644 --- a/src/types.ts +++ b/src/types.ts @@ -196,6 +196,7 @@ export interface ExcalidrawProps { ) => JSX.Element; UIOptions?: UIOptions; detectScroll?: boolean; + handleKeyboardGlobally?: boolean; } export type SceneData = { @@ -230,4 +231,5 @@ export type AppProps = ExcalidrawProps & { canvasActions: Required; }; detectScroll: boolean; + handleKeyboardGlobally: boolean; }; diff --git a/src/utils.ts b/src/utils.ts index 68c6e19b8f..58ce9b28a2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -427,3 +427,14 @@ export const getNearestScrollableContainer = ( } return document; }; + +export const focusNearestParent = (element: HTMLInputElement) => { + let parent = element.parentElement; + while (parent) { + if (parent.tabIndex > -1) { + parent.focus(); + return; + } + parent = parent.parentElement; + } +};