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 <luzar.david@gmail.com>

* tweak changelog

* rename prop to handleKeyboardGlobally

Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
Aakansha Doshi 2021-04-13 01:29:25 +05:30 committed by GitHub
parent 153ca6a7c6
commit d126d04d17
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 537 additions and 409 deletions

View File

@ -18,7 +18,7 @@ import { isBindingElement } from "../element/typeChecks";
export const actionFinalize = register({ export const actionFinalize = register({
name: "finalize", name: "finalize",
perform: (elements, appState, _, { canvas }) => { perform: (elements, appState, _, { canvas, focusContainer }) => {
if (appState.editingLinearElement) { if (appState.editingLinearElement) {
const { const {
elementId, elementId,
@ -51,7 +51,7 @@ export const actionFinalize = register({
let newElements = elements; let newElements = elements;
if (window.document.activeElement instanceof HTMLElement) { if (window.document.activeElement instanceof HTMLElement) {
window.document.activeElement.blur(); focusContainer();
} }
const multiPointElement = appState.multiElement const multiPointElement = appState.multiElement

View File

@ -70,7 +70,10 @@ export const actionFullScreen = register({
export const actionShortcuts = register({ export const actionShortcuts = register({
name: "toggleShortcuts", name: "toggleShortcuts",
perform: (_elements, appState) => { perform: (_elements, appState, _, { focusContainer }) => {
if (appState.showHelpDialog) {
focusContainer();
}
return { return {
appState: { appState: {
...appState, ...appState,

View File

@ -12,7 +12,11 @@ import { MODES } from "../constants";
// This is the <App> component, but for now we don't care about anything but its // This is the <App> component, but for now we don't care about anything but its
// `canvas` state. // `canvas` state.
type App = { canvas: HTMLCanvasElement | null; props: AppProps }; type App = {
canvas: HTMLCanvasElement | null;
focusContainer: () => void;
props: AppProps;
};
export class ActionManager implements ActionsManagerInterface { export class ActionManager implements ActionsManagerInterface {
actions = {} as ActionsManagerInterface["actions"]; actions = {} as ActionsManagerInterface["actions"];
@ -51,7 +55,7 @@ export class ActionManager implements ActionsManagerInterface {
actions.forEach((action) => this.registerAction(action)); actions.forEach((action) => this.registerAction(action));
} }
handleKeyDown(event: KeyboardEvent) { handleKeyDown(event: React.KeyboardEvent | KeyboardEvent) {
const canvasActions = this.app.props.UIOptions.canvasActions; const canvasActions = this.app.props.UIOptions.canvasActions;
const data = Object.values(this.actions) const data = Object.values(this.actions)
.sort((a, b) => (b.keyPriority || 0) - (a.keyPriority || 0)) .sort((a, b) => (b.keyPriority || 0) - (a.keyPriority || 0))

View File

@ -15,11 +15,13 @@ export type ActionResult =
} }
| false; | false;
type AppAPI = { canvas: HTMLCanvasElement | null; focusContainer(): void };
type ActionFn = ( type ActionFn = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>, appState: Readonly<AppState>,
formData: any, formData: any,
app: { canvas: HTMLCanvasElement | null }, app: AppAPI,
) => ActionResult | Promise<ActionResult>; ) => ActionResult | Promise<ActionResult>;
export type UpdaterFn = (res: ActionResult) => void; export type UpdaterFn = (res: ActionResult) => void;
@ -105,7 +107,7 @@ export interface Action {
perform: ActionFn; perform: ActionFn;
keyPriority?: number; keyPriority?: number;
keyTest?: ( keyTest?: (
event: KeyboardEvent, event: React.KeyboardEvent | KeyboardEvent,
appState: AppState, appState: AppState,
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
) => boolean; ) => boolean;
@ -120,6 +122,6 @@ export interface Action {
export interface ActionsManagerInterface { export interface ActionsManagerInterface {
actions: Record<ActionName, Action>; actions: Record<ActionName, Action>;
registerAction: (action: Action) => void; registerAction: (action: Action) => void;
handleKeyDown: (event: KeyboardEvent) => boolean; handleKeyDown: (event: React.KeyboardEvent | KeyboardEvent) => boolean;
renderAction: (name: ActionName) => React.ReactElement | null; renderAction: (name: ActionName) => React.ReactElement | null;
} }

View File

@ -445,12 +445,16 @@ class App extends React.Component<AppProps, AppState> {
return ( return (
<div <div
className={clsx("excalidraw", { className={clsx("excalidraw excalidraw-container", {
"excalidraw--view-mode": viewModeEnabled, "excalidraw--view-mode": viewModeEnabled,
"excalidraw--mobile": this.isMobile, "excalidraw--mobile": this.isMobile,
})} })}
ref={this.excalidrawContainerRef} ref={this.excalidrawContainerRef}
onDrop={this.handleAppOnDrop} onDrop={this.handleAppOnDrop}
tabIndex={0}
onKeyDown={
this.props.handleKeyboardGlobally ? undefined : this.onKeyDown
}
> >
<IsMobileContext.Provider value={this.isMobile}> <IsMobileContext.Provider value={this.isMobile}>
<LayerUI <LayerUI
@ -485,6 +489,7 @@ class App extends React.Component<AppProps, AppState> {
} }
libraryReturnUrl={this.props.libraryReturnUrl} libraryReturnUrl={this.props.libraryReturnUrl}
UIOptions={this.props.UIOptions} UIOptions={this.props.UIOptions}
focusContainer={this.focusContainer}
/> />
<div className="excalidraw-textEditorContainer" /> <div className="excalidraw-textEditorContainer" />
<div className="excalidraw-contextMenuContainer" /> <div className="excalidraw-contextMenuContainer" />
@ -509,6 +514,10 @@ class App extends React.Component<AppProps, AppState> {
); );
} }
public focusContainer = () => {
this.excalidrawContainerRef.current?.focus();
};
public getSceneElementsIncludingDeleted = () => { public getSceneElementsIncludingDeleted = () => {
return this.scene.getElementsIncludingDeleted(); return this.scene.getElementsIncludingDeleted();
}; };
@ -655,6 +664,8 @@ class App extends React.Component<AppProps, AppState> {
} catch (error) { } catch (error) {
window.alert(t("alerts.errorLoadingLibrary")); window.alert(t("alerts.errorLoadingLibrary"));
console.error(error); console.error(error);
} finally {
this.focusContainer();
} }
}; };
@ -795,6 +806,10 @@ class App extends React.Component<AppProps, AppState> {
this.scene.addCallback(this.onSceneUpdated); this.scene.addCallback(this.onSceneUpdated);
this.addEventListeners(); this.addEventListeners();
if (this.excalidrawContainerRef.current) {
this.focusContainer();
}
if ("ResizeObserver" in window && this.excalidrawContainerRef?.current) { if ("ResizeObserver" in window && this.excalidrawContainerRef?.current) {
this.resizeObserver = new ResizeObserver(() => { this.resizeObserver = new ResizeObserver(() => {
// compute isMobile state // compute isMobile state
@ -854,7 +869,6 @@ class App extends React.Component<AppProps, AppState> {
EVENT.SCROLL, EVENT.SCROLL,
this.onScroll, this.onScroll,
); );
document.removeEventListener(EVENT.KEYDOWN, this.onKeyDown, false); document.removeEventListener(EVENT.KEYDOWN, this.onKeyDown, false);
document.removeEventListener( document.removeEventListener(
EVENT.MOUSE_MOVE, EVENT.MOUSE_MOVE,
@ -890,7 +904,9 @@ class App extends React.Component<AppProps, AppState> {
private addEventListeners() { private addEventListeners() {
this.removeEventListeners(); this.removeEventListeners();
document.addEventListener(EVENT.COPY, this.onCopy); 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.KEYUP, this.onKeyUp, { passive: true });
document.addEventListener( document.addEventListener(
EVENT.MOUSE_MOVE, EVENT.MOUSE_MOVE,
@ -1434,152 +1450,156 @@ class App extends React.Component<AppProps, AppState> {
// Input handling // Input handling
private onKeyDown = withBatchedUpdates((event: KeyboardEvent) => { private onKeyDown = withBatchedUpdates(
// normalize `event.key` when CapsLock is pressed #2372 (event: React.KeyboardEvent | KeyboardEvent) => {
if ( // normalize `event.key` when CapsLock is pressed #2372
"Proxy" in window && if (
((!event.shiftKey && /^[A-Z]$/.test(event.key)) || "Proxy" in window &&
(event.shiftKey && /^[a-z]$/.test(event.key))) ((!event.shiftKey && /^[A-Z]$/.test(event.key)) ||
) { (event.shiftKey && /^[a-z]$/.test(event.key)))
event = new Proxy(event, { ) {
get(ev: any, prop) { event = new Proxy(event, {
const value = ev[prop]; get(ev: any, prop) {
if (typeof value === "function") { const value = ev[prop];
// fix for Proxies hijacking `this` if (typeof value === "function") {
return value.bind(ev); // fix for Proxies hijacking `this`
} return value.bind(ev);
return prop === "key" }
? // CapsLock inverts capitalization based on ShiftKey, so invert return prop === "key"
// it back ? // CapsLock inverts capitalization based on ShiftKey, so invert
event.shiftKey // it back
? ev.key.toUpperCase() event.shiftKey
: ev.key.toLowerCase() ? ev.key.toUpperCase()
: value; : 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;
} }
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 ( if (
selectedElements.length === 1 && (isWritableElement(event.target) && event.key !== KEYS.ESCAPE) ||
isLinearElement(selectedElements[0]) // 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; return;
} }
} else if (
!event.ctrlKey && if (event.key === KEYS.QUESTION_MARK) {
!event.altKey && this.setState({
!event.metaKey && showHelpDialog: true,
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) { if (this.actionManager.handleKeyDown(event)) {
isHoldingSpace = true; return;
setCursor(this.canvas, CURSOR_TYPE.GRABBING); }
}
}); 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) => { private onKeyUp = withBatchedUpdates((event: KeyboardEvent) => {
if (event.key === KEYS.SPACE) { if (event.key === KEYS.SPACE) {
@ -1615,7 +1635,7 @@ class App extends React.Component<AppProps, AppState> {
setCursorForShape(this.canvas, elementType); setCursorForShape(this.canvas, elementType);
} }
if (isToolIcon(document.activeElement)) { if (isToolIcon(document.activeElement)) {
document.activeElement.blur(); this.focusContainer();
} }
if (!isLinearElementType(elementType)) { if (!isLinearElementType(elementType)) {
this.setState({ suggestedBindings: [] }); this.setState({ suggestedBindings: [] });
@ -1745,6 +1765,8 @@ class App extends React.Component<AppProps, AppState> {
if (this.state.elementLocked) { if (this.state.elementLocked) {
setCursorForShape(this.canvas, this.state.elementType); setCursorForShape(this.canvas, this.state.elementType);
} }
this.focusContainer();
}), }),
element, element,
}); });

View File

@ -115,6 +115,7 @@ const Picker = ({
onClose(); onClose();
} }
event.nativeEvent.stopImmediatePropagation(); event.nativeEvent.stopImmediatePropagation();
event.stopPropagation();
}; };
return ( return (

View File

@ -18,7 +18,6 @@ export const Dialog = (props: {
autofocus?: boolean; autofocus?: boolean;
}) => { }) => {
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>(); const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
useEffect(() => { useEffect(() => {
if (!islandNode) { if (!islandNode) {
return; return;

View File

@ -18,6 +18,7 @@ export const ErrorDialog = ({
if (onClose) { if (onClose) {
onClose(); onClose();
} }
document.querySelector<HTMLElement>(".excalidraw-container")?.focus();
}, [onClose]); }, [onClose]);
return ( return (

View File

@ -88,6 +88,7 @@ function Picker<T>({
onClose(); onClose();
} }
event.nativeEvent.stopImmediatePropagation(); event.nativeEvent.stopImmediatePropagation();
event.stopPropagation();
}; };
return ( return (

View File

@ -72,6 +72,7 @@ interface LayerUIProps {
viewModeEnabled: boolean; viewModeEnabled: boolean;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
UIOptions: AppProps["UIOptions"]; UIOptions: AppProps["UIOptions"];
focusContainer: () => void;
} }
const useOnClickOutside = ( const useOnClickOutside = (
@ -111,6 +112,7 @@ const LibraryMenuItems = ({
setAppState, setAppState,
setLibraryItems, setLibraryItems,
libraryReturnUrl, libraryReturnUrl,
focusContainer,
}: { }: {
library: LibraryItems; library: LibraryItems;
pendingElements: LibraryItem; pendingElements: LibraryItem;
@ -120,6 +122,7 @@ const LibraryMenuItems = ({
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
setLibraryItems: (library: LibraryItems) => void; setLibraryItems: (library: LibraryItems) => void;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
focusContainer: () => void;
}) => { }) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const numCells = library.length + (pendingElements.length > 0 ? 1 : 0); const numCells = library.length + (pendingElements.length > 0 ? 1 : 0);
@ -178,6 +181,7 @@ const LibraryMenuItems = ({
if (window.confirm(t("alerts.resetLibrary"))) { if (window.confirm(t("alerts.resetLibrary"))) {
Library.resetLibrary(); Library.resetLibrary();
setLibraryItems([]); setLibraryItems([]);
focusContainer();
} }
}} }}
/> />
@ -242,6 +246,7 @@ const LibraryMenu = ({
onAddToLibrary, onAddToLibrary,
setAppState, setAppState,
libraryReturnUrl, libraryReturnUrl,
focusContainer,
}: { }: {
pendingElements: LibraryItem; pendingElements: LibraryItem;
onClickOutside: (event: MouseEvent) => void; onClickOutside: (event: MouseEvent) => void;
@ -249,6 +254,7 @@ const LibraryMenu = ({
onAddToLibrary: () => void; onAddToLibrary: () => void;
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
focusContainer: () => void;
}) => { }) => {
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
useOnClickOutside(ref, (event) => { useOnClickOutside(ref, (event) => {
@ -322,6 +328,7 @@ const LibraryMenu = ({
setAppState={setAppState} setAppState={setAppState}
setLibraryItems={setLibraryItems} setLibraryItems={setLibraryItems}
libraryReturnUrl={libraryReturnUrl} libraryReturnUrl={libraryReturnUrl}
focusContainer={focusContainer}
/> />
)} )}
</Island> </Island>
@ -347,6 +354,7 @@ const LayerUI = ({
viewModeEnabled, viewModeEnabled,
libraryReturnUrl, libraryReturnUrl,
UIOptions, UIOptions,
focusContainer,
}: LayerUIProps) => { }: LayerUIProps) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@ -517,6 +525,7 @@ const LayerUI = ({
onAddToLibrary={deselectItems} onAddToLibrary={deselectItems}
setAppState={setAppState} setAppState={setAppState}
libraryReturnUrl={libraryReturnUrl} libraryReturnUrl={libraryReturnUrl}
focusContainer={focusContainer}
/> />
) : null; ) : null;
@ -660,7 +669,15 @@ const LayerUI = ({
/> />
)} )}
{appState.showHelpDialog && ( {appState.showHelpDialog && (
<HelpDialog onClose={() => setAppState({ showHelpDialog: false })} /> <HelpDialog
onClose={() => {
const helpIcon = document.querySelector(
".help-icon",
)! as HTMLElement;
helpIcon.focus();
setAppState({ showHelpDialog: false });
}}
/>
)} )}
{appState.pasteDialog.shown && ( {appState.pasteDialog.shown && (
<PasteChartDialog <PasteChartDialog

View File

@ -52,6 +52,10 @@
border-radius: 6px; border-radius: 6px;
box-sizing: border-box; box-sizing: border-box;
&:focus {
outline: none;
}
@include isMobile { @include isMobile {
max-width: 100%; max-width: 100%;
border: 0; border: 0;

View File

@ -22,6 +22,7 @@ export const Modal = (props: {
const handleKeydown = (event: React.KeyboardEvent) => { const handleKeydown = (event: React.KeyboardEvent) => {
if (event.key === KEYS.ESCAPE) { if (event.key === KEYS.ESCAPE) {
event.nativeEvent.stopImmediatePropagation(); event.nativeEvent.stopImmediatePropagation();
event.stopPropagation();
props.onCloseRequest(); props.onCloseRequest();
} }
}; };
@ -38,6 +39,7 @@ export const Modal = (props: {
<div <div
className="Modal__content" className="Modal__content"
style={{ "--max-width": `${props.maxWidth}px` }} style={{ "--max-width": `${props.maxWidth}px` }}
tabIndex={0}
> >
{props.children} {props.children}
</div> </div>

View File

@ -1,6 +1,7 @@
import "./TextInput.scss"; import "./TextInput.scss";
import React, { Component } from "react"; import React, { Component } from "react";
import { focusNearestParent } from "../utils";
type Props = { type Props = {
value: string; value: string;
@ -17,6 +18,7 @@ export class ProjectName extends Component<Props, State> {
fileName: this.props.value, fileName: this.props.value,
}; };
private handleBlur = (event: any) => { private handleBlur = (event: any) => {
focusNearestParent(event.target);
const value = event.target.value; const value = event.target.value;
if (value !== this.props.value) { if (value !== this.props.value) {
this.props.onChange(value); this.props.onChange(value);

View File

@ -19,6 +19,10 @@
height: 100%; height: 100%;
width: 100%; width: 100%;
&:focus {
outline: none;
}
// serves 2 purposes: // serves 2 purposes:
// 1. prevent selecting text outside the component when double-clicking or // 1. prevent selecting text outside the component when double-clicking or
// dragging inside it (e.g. on canvas) // dragging inside it (e.g. on canvas)

View File

@ -159,11 +159,14 @@ export const textWysiwyg = ({
// so that we don't need to create separate a callback for event handlers // so that we don't need to create separate a callback for event handlers
let submittedViaKeyboard = false; let submittedViaKeyboard = false;
const handleSubmit = () => { 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({ onSubmit({
text: normalizeText(editable.value), text: normalizeText(editable.value),
viaKeyboard: submittedViaKeyboard, viaKeyboard: submittedViaKeyboard,
}); });
cleanup();
}; };
const cleanup = () => { const cleanup = () => {

View File

@ -324,6 +324,7 @@ const ExcalidrawWrapper = () => {
langCode={langCode} langCode={langCode}
renderCustomStats={renderCustomStats} renderCustomStats={renderCustomStats}
detectScroll={false} detectScroll={false}
handleKeyboardGlobally={true}
/> />
{excalidrawAPI && <CollabWrapper excalidrawAPI={excalidrawAPI} />} {excalidrawAPI && <CollabWrapper excalidrawAPI={excalidrawAPI} />}
{errorMessage && ( {errorMessage && (

View File

@ -15,6 +15,14 @@ Please add the latest change on the top under the correct section.
## Excalidraw API ## 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`. - 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). - Add `onPaste` prop to handle custom clipboard behaviours [#3420](https://github.com/excalidraw/excalidraw/pull/3420).

View File

@ -366,6 +366,7 @@ To view the full example visit :point_down:
| [`UIOptions`](#UIOptions) | <pre>{ canvasActions: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L208"> CanvasActions<a/> }</pre> | [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) | | [`UIOptions`](#UIOptions) | <pre>{ canvasActions: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L208"> CanvasActions<a/> }</pre> | [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) | <pre>(data: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/clipboard.ts#L17">ClipboardData</a>, event: ClipboardEvent &#124; null) => boolean</pre> | | Callback to be triggered if passed when the something is pasted in to the scene | | [`onPaste`](#onPaste) | <pre>(data: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/clipboard.ts#L17">ClipboardData</a>, event: ClipboardEvent &#124; null) => boolean</pre> | | 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. | | [`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 ### 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). 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 ### Extra API's
#### `getSceneVersion` #### `getSceneVersion`

View File

@ -31,6 +31,7 @@ const Excalidraw = (props: ExcalidrawProps) => {
renderCustomStats, renderCustomStats,
onPaste, onPaste,
detectScroll = true, detectScroll = true,
handleKeyboardGlobally = false,
} = props; } = props;
const canvasActions = props.UIOptions?.canvasActions; const canvasActions = props.UIOptions?.canvasActions;
@ -82,6 +83,7 @@ const Excalidraw = (props: ExcalidrawProps) => {
UIOptions={UIOptions} UIOptions={UIOptions}
onPaste={onPaste} onPaste={onPaste}
detectScroll={detectScroll} detectScroll={detectScroll}
handleKeyboardGlobally={handleKeyboardGlobally}
/> />
</InitializeApp> </InitializeApp>
); );

View File

@ -1,8 +1,8 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // 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 { Object {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "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 { Object {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "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 { Object {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "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 { Object {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "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 { Object {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",

View File

@ -24,276 +24,282 @@ beforeEach(() => {
const { h } = window; const { h } = window;
describe("add element to the scene when pointer dragging long enough", () => { describe("Test dragCreate", () => {
it("rectangle", async () => { describe("add element to the scene when pointer dragging long enough", () => {
const { getByToolName, container } = await render(<ExcalidrawApp />); it("rectangle", async () => {
// select tool const { getByToolName, container } = await render(<ExcalidrawApp />);
const tool = getByToolName("rectangle"); // select tool
fireEvent.click(tool); const tool = getByToolName("rectangle");
fireEvent.click(tool);
const canvas = container.querySelector("canvas")!; const canvas = container.querySelector("canvas")!;
// start from (30, 20) // start from (30, 20)
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
// move to (60,70) // move to (60,70)
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
// finish (position does not matter) // finish (position does not matter)
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(8); expect(renderScene).toHaveBeenCalledTimes(8);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.elements[0].type).toEqual("rectangle"); expect(h.elements[0].type).toEqual("rectangle");
expect(h.elements[0].x).toEqual(30); expect(h.elements[0].x).toEqual(30);
expect(h.elements[0].y).toEqual(20); expect(h.elements[0].y).toEqual(20);
expect(h.elements[0].width).toEqual(30); // 60 - 30 expect(h.elements[0].width).toEqual(30); // 60 - 30
expect(h.elements[0].height).toEqual(50); // 70 - 20 expect(h.elements[0].height).toEqual(50); // 70 - 20
expect(h.elements.length).toMatchSnapshot(); expect(h.elements.length).toMatchSnapshot();
h.elements.forEach((element) => expect(element).toMatchSnapshot()); h.elements.forEach((element) => expect(element).toMatchSnapshot());
});
it("ellipse", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
// 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(<ExcalidrawApp />);
// 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(<ExcalidrawApp />);
// 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(<ExcalidrawApp />);
// 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 () => { describe("do not add element to the scene if size is too small", () => {
const { getByToolName, container } = await render(<ExcalidrawApp />); beforeAll(() => {
// select tool mockBoundingClientRect();
const tool = getByToolName("ellipse"); });
fireEvent.click(tool); afterAll(() => {
restoreOriginalGetBoundingClientRect();
});
const canvas = container.querySelector("canvas")!; it("rectangle", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />);
// select tool
const tool = getByToolName("rectangle");
fireEvent.click(tool);
// start from (30, 20) const canvas = container.querySelector("canvas")!;
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
// move to (60,70) // start from (30, 20)
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
// finish (position does not matter) // finish (position does not matter)
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(8); expect(renderScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0);
});
expect(h.elements.length).toEqual(1); it("ellipse", async () => {
expect(h.elements[0].type).toEqual("ellipse"); const { getByToolName, container } = await render(<ExcalidrawApp />);
expect(h.elements[0].x).toEqual(30); // select tool
expect(h.elements[0].y).toEqual(20); const tool = getByToolName("ellipse");
expect(h.elements[0].width).toEqual(30); // 60 - 30 fireEvent.click(tool);
expect(h.elements[0].height).toEqual(50); // 70 - 20
expect(h.elements.length).toMatchSnapshot(); const canvas = container.querySelector("canvas")!;
h.elements.forEach((element) => expect(element).toMatchSnapshot());
});
it("diamond", async () => { // start from (30, 20)
const { getByToolName, container } = await render(<ExcalidrawApp />); fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
// select tool
const tool = getByToolName("diamond");
fireEvent.click(tool);
const canvas = container.querySelector("canvas")!; // finish (position does not matter)
fireEvent.pointerUp(canvas);
// start from (30, 20) expect(renderScene).toHaveBeenCalledTimes(6);
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0);
});
// move to (60,70) it("diamond", async () => {
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); const { getByToolName, container } = await render(<ExcalidrawApp />);
// select tool
const tool = getByToolName("diamond");
fireEvent.click(tool);
// finish (position does not matter) const canvas = container.querySelector("canvas")!;
fireEvent.pointerUp(canvas);
expect(renderScene).toHaveBeenCalledTimes(8); // start from (30, 20)
expect(h.state.selectionElement).toBeNull(); fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
expect(h.elements.length).toEqual(1); // finish (position does not matter)
expect(h.elements[0].type).toEqual("diamond"); fireEvent.pointerUp(canvas);
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(); expect(renderScene).toHaveBeenCalledTimes(6);
h.elements.forEach((element) => expect(element).toMatchSnapshot()); expect(h.state.selectionElement).toBeNull();
}); expect(h.elements.length).toEqual(0);
});
it("arrow", async () => { it("arrow", async () => {
const { getByToolName, container } = await render(<ExcalidrawApp />); const { getByToolName, container } = await render(<ExcalidrawApp />);
// select tool // select tool
const tool = getByToolName("arrow"); const tool = getByToolName("arrow");
fireEvent.click(tool); fireEvent.click(tool);
const canvas = container.querySelector("canvas")!; const canvas = container.querySelector("canvas")!;
// start from (30, 20) // start from (30, 20)
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
// move to (60,70) // finish (position does not matter)
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); fireEvent.pointerUp(canvas);
// finish (position does not matter) // we need to finalize it because arrows and lines enter multi-mode
fireEvent.pointerUp(canvas); fireEvent.keyDown(document, {
key: KEYS.ENTER,
});
expect(renderScene).toHaveBeenCalledTimes(8); expect(renderScene).toHaveBeenCalledTimes(7);
expect(h.state.selectionElement).toBeNull(); 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(<ExcalidrawApp />);
// 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"); // start from (30, 20)
expect(element.x).toEqual(30); fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
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(); // finish (position does not matter)
h.elements.forEach((element) => expect(element).toMatchSnapshot()); fireEvent.pointerUp(canvas);
});
it("line", async () => { // we need to finalize it because arrows and lines enter multi-mode
const { getByToolName, container } = await render(<ExcalidrawApp />); fireEvent.keyDown(document, {
// select tool key: KEYS.ENTER,
const tool = getByToolName("line"); });
fireEvent.click(tool);
const canvas = container.querySelector("canvas")!; expect(renderScene).toHaveBeenCalledTimes(7);
expect(h.state.selectionElement).toBeNull();
// start from (30, 20) expect(h.elements.length).toEqual(0);
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(<ExcalidrawApp />);
// 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(<ExcalidrawApp />);
// 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(<ExcalidrawApp />);
// 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(<ExcalidrawApp />);
// 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(<ExcalidrawApp />);
// 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);
}); });
}); });

View File

@ -99,7 +99,9 @@ describe("multi point mode in linear elements", () => {
// done // done
fireEvent.pointerDown(canvas); fireEvent.pointerDown(canvas);
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
fireEvent.keyDown(document, { key: KEYS.ENTER }); fireEvent.keyDown(document, {
key: KEYS.ENTER,
});
expect(renderScene).toHaveBeenCalledTimes(14); expect(renderScene).toHaveBeenCalledTimes(14);
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
@ -140,7 +142,9 @@ describe("multi point mode in linear elements", () => {
// done // done
fireEvent.pointerDown(canvas); fireEvent.pointerDown(canvas);
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
fireEvent.keyDown(document, { key: KEYS.ENTER }); fireEvent.keyDown(document, {
key: KEYS.ENTER,
});
expect(renderScene).toHaveBeenCalledTimes(14); expect(renderScene).toHaveBeenCalledTimes(14);
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);

View File

@ -413,11 +413,23 @@ describe("regression tests", () => {
it("zoom hotkeys", () => { it("zoom hotkeys", () => {
expect(h.state.zoom.value).toBe(1); expect(h.state.zoom.value).toBe(1);
fireEvent.keyDown(document, { code: CODES.EQUAL, ctrlKey: true }); fireEvent.keyDown(document, {
fireEvent.keyUp(document, { code: CODES.EQUAL, ctrlKey: true }); code: CODES.EQUAL,
ctrlKey: true,
});
fireEvent.keyUp(document, {
code: CODES.EQUAL,
ctrlKey: true,
});
expect(h.state.zoom.value).toBeGreaterThan(1); expect(h.state.zoom.value).toBeGreaterThan(1);
fireEvent.keyDown(document, { code: CODES.MINUS, ctrlKey: true }); fireEvent.keyDown(document, {
fireEvent.keyUp(document, { code: CODES.MINUS, ctrlKey: true }); code: CODES.MINUS,
ctrlKey: true,
});
fireEvent.keyUp(document, {
code: CODES.MINUS,
ctrlKey: true,
});
expect(h.state.zoom.value).toBe(1); expect(h.state.zoom.value).toBe(1);
}); });

View File

@ -100,7 +100,9 @@ describe("select single element on the scene", () => {
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
fireEvent.keyDown(document, { key: KEYS.ESCAPE }); fireEvent.keyDown(document, {
key: KEYS.ESCAPE,
});
} }
const tool = getByToolName("selection"); const tool = getByToolName("selection");
@ -127,7 +129,9 @@ describe("select single element on the scene", () => {
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
fireEvent.keyDown(document, { key: KEYS.ESCAPE }); fireEvent.keyDown(document, {
key: KEYS.ESCAPE,
});
} }
const tool = getByToolName("selection"); const tool = getByToolName("selection");
@ -154,7 +158,9 @@ describe("select single element on the scene", () => {
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
fireEvent.keyDown(document, { key: KEYS.ESCAPE }); fireEvent.keyDown(document, {
key: KEYS.ESCAPE,
});
} }
const tool = getByToolName("selection"); const tool = getByToolName("selection");
@ -181,7 +187,9 @@ describe("select single element on the scene", () => {
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
fireEvent.pointerUp(canvas); 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.pointerDown(canvas, { clientX: 30, clientY: 20 });
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
fireEvent.keyDown(document, { key: KEYS.ESCAPE }); fireEvent.keyDown(document, {
key: KEYS.ESCAPE,
});
} }
/* /*

View File

@ -196,6 +196,7 @@ export interface ExcalidrawProps {
) => JSX.Element; ) => JSX.Element;
UIOptions?: UIOptions; UIOptions?: UIOptions;
detectScroll?: boolean; detectScroll?: boolean;
handleKeyboardGlobally?: boolean;
} }
export type SceneData = { export type SceneData = {
@ -230,4 +231,5 @@ export type AppProps = ExcalidrawProps & {
canvasActions: Required<CanvasActions>; canvasActions: Required<CanvasActions>;
}; };
detectScroll: boolean; detectScroll: boolean;
handleKeyboardGlobally: boolean;
}; };

View File

@ -427,3 +427,14 @@ export const getNearestScrollableContainer = (
} }
return document; return document;
}; };
export const focusNearestParent = (element: HTMLInputElement) => {
let parent = element.parentElement;
while (parent) {
if (parent.tabIndex > -1) {
parent.focus();
return;
}
parent = parent.parentElement;
}
};