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:
parent
153ca6a7c6
commit
d126d04d17
@ -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
|
||||
|
@ -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,
|
||||
|
@ -12,7 +12,11 @@ import { MODES } from "../constants";
|
||||
|
||||
// This is the <App> 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))
|
||||
|
@ -15,11 +15,13 @@ export type ActionResult =
|
||||
}
|
||||
| false;
|
||||
|
||||
type AppAPI = { canvas: HTMLCanvasElement | null; focusContainer(): void };
|
||||
|
||||
type ActionFn = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: Readonly<AppState>,
|
||||
formData: any,
|
||||
app: { canvas: HTMLCanvasElement | null },
|
||||
app: AppAPI,
|
||||
) => ActionResult | Promise<ActionResult>;
|
||||
|
||||
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<ActionName, Action>;
|
||||
registerAction: (action: Action) => void;
|
||||
handleKeyDown: (event: KeyboardEvent) => boolean;
|
||||
handleKeyDown: (event: React.KeyboardEvent | KeyboardEvent) => boolean;
|
||||
renderAction: (name: ActionName) => React.ReactElement | null;
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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)
|
||||
|
@ -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 = () => {
|
||||
|
@ -324,6 +324,7 @@ const ExcalidrawWrapper = () => {
|
||||
langCode={langCode}
|
||||
renderCustomStats={renderCustomStats}
|
||||
detectScroll={false}
|
||||
handleKeyboardGlobally={true}
|
||||
/>
|
||||
{excalidrawAPI && <CollabWrapper excalidrawAPI={excalidrawAPI} />}
|
||||
{errorMessage && (
|
||||
|
@ -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).
|
||||
|
||||
|
@ -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) |
|
||||
| [`onPaste`](#onPaste) | <pre>(data: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/clipboard.ts#L17">ClipboardData</a>, event: ClipboardEvent | 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. |
|
||||
| [`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`
|
||||
|
@ -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}
|
||||
/>
|
||||
</InitializeApp>
|
||||
);
|
||||
|
@ -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",
|
||||
|
@ -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(<ExcalidrawApp />);
|
||||
// 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(<ExcalidrawApp />);
|
||||
// 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(<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 () => {
|
||||
const { getByToolName, container } = await render(<ExcalidrawApp />);
|
||||
// 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(<ExcalidrawApp />);
|
||||
// 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(<ExcalidrawApp />);
|
||||
// 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(<ExcalidrawApp />);
|
||||
// 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(<ExcalidrawApp />);
|
||||
// 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(<ExcalidrawApp />);
|
||||
// select tool
|
||||
const tool = getByToolName("arrow");
|
||||
fireEvent.click(tool);
|
||||
it("arrow", async () => {
|
||||
const { getByToolName, container } = await render(<ExcalidrawApp />);
|
||||
// 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(<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");
|
||||
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(<ExcalidrawApp />);
|
||||
// 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(<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);
|
||||
expect(renderScene).toHaveBeenCalledTimes(7);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
});
|
||||
|
||||
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
|
@ -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<CanvasActions>;
|
||||
};
|
||||
detectScroll: boolean;
|
||||
handleKeyboardGlobally: boolean;
|
||||
};
|
||||
|
11
src/utils.ts
11
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;
|
||||
}
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user