mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
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 2c8ec6be8e
.
* 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:
parent
153ca6a7c6
commit
d126d04d17
26 changed files with 537 additions and 409 deletions
|
@ -445,12 +445,16 @@ class App extends React.Component<AppProps, AppState> {
|
|||
|
||||
return (
|
||||
<div
|
||||
className={clsx("excalidraw", {
|
||||
className={clsx("excalidraw excalidraw-container", {
|
||||
"excalidraw--view-mode": viewModeEnabled,
|
||||
"excalidraw--mobile": this.isMobile,
|
||||
})}
|
||||
ref={this.excalidrawContainerRef}
|
||||
onDrop={this.handleAppOnDrop}
|
||||
tabIndex={0}
|
||||
onKeyDown={
|
||||
this.props.handleKeyboardGlobally ? undefined : this.onKeyDown
|
||||
}
|
||||
>
|
||||
<IsMobileContext.Provider value={this.isMobile}>
|
||||
<LayerUI
|
||||
|
@ -485,6 +489,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
}
|
||||
libraryReturnUrl={this.props.libraryReturnUrl}
|
||||
UIOptions={this.props.UIOptions}
|
||||
focusContainer={this.focusContainer}
|
||||
/>
|
||||
<div className="excalidraw-textEditorContainer" />
|
||||
<div className="excalidraw-contextMenuContainer" />
|
||||
|
@ -509,6 +514,10 @@ class App extends React.Component<AppProps, AppState> {
|
|||
);
|
||||
}
|
||||
|
||||
public focusContainer = () => {
|
||||
this.excalidrawContainerRef.current?.focus();
|
||||
};
|
||||
|
||||
public getSceneElementsIncludingDeleted = () => {
|
||||
return this.scene.getElementsIncludingDeleted();
|
||||
};
|
||||
|
@ -655,6 +664,8 @@ class App extends React.Component<AppProps, AppState> {
|
|||
} catch (error) {
|
||||
window.alert(t("alerts.errorLoadingLibrary"));
|
||||
console.error(error);
|
||||
} finally {
|
||||
this.focusContainer();
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -795,6 +806,10 @@ class App extends React.Component<AppProps, AppState> {
|
|||
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<AppProps, AppState> {
|
|||
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<AppProps, AppState> {
|
|||
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<AppProps, AppState> {
|
|||
|
||||
// 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<AppProps, AppState> {
|
|||
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<AppProps, AppState> {
|
|||
if (this.state.elementLocked) {
|
||||
setCursorForShape(this.canvas, this.state.elementType);
|
||||
}
|
||||
|
||||
this.focusContainer();
|
||||
}),
|
||||
element,
|
||||
});
|
||||
|
|
|
@ -115,6 +115,7 @@ const Picker = ({
|
|||
onClose();
|
||||
}
|
||||
event.nativeEvent.stopImmediatePropagation();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
@ -18,7 +18,6 @@ export const Dialog = (props: {
|
|||
autofocus?: boolean;
|
||||
}) => {
|
||||
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!islandNode) {
|
||||
return;
|
||||
|
|
|
@ -18,6 +18,7 @@ export const ErrorDialog = ({
|
|||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
document.querySelector<HTMLElement>(".excalidraw-container")?.focus();
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
|
|
|
@ -88,6 +88,7 @@ function Picker<T>({
|
|||
onClose();
|
||||
}
|
||||
event.nativeEvent.stopImmediatePropagation();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
@ -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<any, AppState>["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<any, AppState>["setState"];
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
focusContainer: () => void;
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useOnClickOutside(ref, (event) => {
|
||||
|
@ -322,6 +328,7 @@ const LibraryMenu = ({
|
|||
setAppState={setAppState}
|
||||
setLibraryItems={setLibraryItems}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
focusContainer={focusContainer}
|
||||
/>
|
||||
)}
|
||||
</Island>
|
||||
|
@ -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 && (
|
||||
<HelpDialog onClose={() => setAppState({ showHelpDialog: false })} />
|
||||
<HelpDialog
|
||||
onClose={() => {
|
||||
const helpIcon = document.querySelector(
|
||||
".help-icon",
|
||||
)! as HTMLElement;
|
||||
helpIcon.focus();
|
||||
setAppState({ showHelpDialog: false });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{appState.pasteDialog.shown && (
|
||||
<PasteChartDialog
|
||||
|
|
|
@ -52,6 +52,10 @@
|
|||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@include isMobile {
|
||||
max-width: 100%;
|
||||
border: 0;
|
||||
|
|
|
@ -22,6 +22,7 @@ export const Modal = (props: {
|
|||
const handleKeydown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === KEYS.ESCAPE) {
|
||||
event.nativeEvent.stopImmediatePropagation();
|
||||
event.stopPropagation();
|
||||
props.onCloseRequest();
|
||||
}
|
||||
};
|
||||
|
@ -38,6 +39,7 @@ export const Modal = (props: {
|
|||
<div
|
||||
className="Modal__content"
|
||||
style={{ "--max-width": `${props.maxWidth}px` }}
|
||||
tabIndex={0}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
|
|
|
@ -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<Props, State> {
|
|||
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);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue