mirror of
https://github.com/excalidraw/excalidraw.git
synced 2026-10-19 16:15:56 +00:00
feat(packages/excalidraw): add props.activeTool and related (#11665)
This commit is contained in:
@@ -15,9 +15,19 @@ Please add the latest change on the top under the correct section.
|
||||
|
||||
## Excalidraw API
|
||||
|
||||
### Host-controlled active tool (2026-07-14)
|
||||
|
||||
- Added `activeTool` prop (`{ type: ToolType } | { type: "custom"; customType: string }`) for forcing the active editor tool (controlled). While set, user- and API-driven tool switching is ignored — `setActiveTool` refuses non-matching activations with a console warning, non-forced toolbar buttons render disabled, and the tool-lock toggle (`Q`) is inert — and the editor snaps back if internal flows reset the tool (e.g. `restore()` on scene load). The forced tool behaves as if locked — it doesn't revert to selection after use and drawn elements aren't auto-selected — without mutating `appState.activeTool.locked`, so the user's persisted padlock preference stays untouched. Unset the prop to return tool control to the editor (the current tool stays active). The forced tool must be activatable to take effect — not disabled via `UIOptions.tools`, and (while non-interactive) allowed via `interaction.enabled.tools`; otherwise the editor stays on the `selection` tool and applies the forced tool once it becomes activatable. `image` cannot be forced (its activation opens the file picker). Composes with `interaction.enabled.tools` for presentation-style hosts: force `laser` for the presenter, `selection` + `interaction={false}` for viewers.
|
||||
|
||||
### User-driven tools while non-interactive (2026-07-14)
|
||||
|
||||
- Added `interaction.enabled.tools` (`{ laser?: boolean; custom?: boolean }`) — the listed tools stay user-driven while the editor is otherwise non-interactive: pointer input keeps driving the tool when it's the active tool. With `laser` enabled, pointer strokes draw laser trails and pointer positions keep broadcasting through `onPointerUpdate`, so e.g. collaborators see a presenter's laser & cursor while the presenter's editor is otherwise inert. With `custom` enabled, custom tools (`activeTool.type === "custom"`) keep receiving `onPointerDown` / `onPointerUp` — activate them with `locked: true` or they revert to the selection tool (and go inert) after the first pointer interaction. Enabling a tool does not enable user-driven tool _switching_ — the keyboard stays disabled and tool selection remains host-driven (`ExcalidrawAPI.setActiveTool`). Composes with `enabled.navigation`: the enabled tool wins the primary-pointer drag, while wheel input (and wheel-button drag) still pans/zooms. The editor consumes touch input over the canvas while the active tool is enabled (strokes don't scroll the page).
|
||||
|
||||
- While the editor is non-interactive, `ExcalidrawAPI.setActiveTool` only activates tools allowed via `interaction.enabled.tools` — activating any other tool is refused with a console warning (previously the switch went through even though the tool couldn't be used, e.g. updating the canvas cursor). The same invariant is maintained for the tool state itself: when the active tool loses its exception (e.g. `enabled.tools` changes, or the editor becomes fully non-interactive mid-session), it resets to the default `selection` tool, so stale tool state — e.g. a presenter's laser after handing off — doesn't leak through `onChange` / collab pointer payloads.
|
||||
|
||||
### Interaction and UI control (2026-07-05) [#11605](https://github.com/excalidraw/excalidraw/pull/11605)
|
||||
|
||||
- Added `interaction` prop (`boolean | { enabled?: { navigation?: boolean; links?: boolean; embeds?: boolean; interactiveContent?: boolean; browserZoom?: boolean } }`). When set to `false`, the editor becomes fully inert for the user — no panning, zooming, selecting, editing, keyboard shortcuts, clipboard, or drag&drop. It implies view mode (`appState.viewModeEnabled` reports `true` and cannot be unset while non-interactive), and the page scrolls naturally over the component since wheel/touch input is no longer captured (the browser's own zoom is still prevented over the editor by default). Interactions under `enabled` are opt-in exceptions: with `navigation` allowed, the user can pan (pointer drag, wheel) and zoom (ctrl/cmd + wheel, pinch, and the canvas zoom & zoom-to-fit shortcuts — ctrl/cmd +/-/0, shift+1/2/3) the canvas view-mode style — respecting `appState.scrollConstraints`, so it composes with viewport locking for constrained viewers, while the rest of the keyboard stays disabled and the editor consumes wheel & touch input again (the page no longer scrolls over the editor); with `links` allowed, link icons render and linked elements stay clickable (view-mode behavior: clicking anywhere on a linked element opens its link) while the editor stays otherwise non-interactive — when links are not allowed, link icons are not rendered at all; with `embeds` allowed, embeddable & iframe elements stay interactive (hover & click activates them, view-mode style); `interactiveContent` is an additive shorthand enabling `links` & `embeds` (and future interactive content kinds) together; with `browserZoom` allowed, the browser's own zoom gestures (ctrl/cmd + wheel, pinch, keyboard) stay available over the editor (moot when `navigation` is allowed, since the editor consumes the zoom input instead). The scene still renders and remains fully controllable through the imperative API (`updateScene`, `setViewport`, ...), making it suitable for live previews, thumbnails, and annotation overlays.
|
||||
- Added `interaction` prop (`boolean | { enabled?: { navigation?: boolean; links?: boolean; embeds?: boolean; interactiveContent?: boolean; browserZoom?: boolean } }`). When set to `false`, the editor becomes fully inert for the user — no panning, zooming, selecting, editing, keyboard shortcuts, clipboard, or drag&drop. It implies view mode (`appState.viewModeEnabled` reports `true` and cannot be unset while non-interactive), and the page scrolls naturally over the component since wheel/touch input is no longer captured (the browser's own zoom is still prevented over the editor by default). Interactions under `enabled` are opt-in exceptions: with `navigation` allowed, the user can pan (pointer drag, wheel, PageUp/PageDown — shift for horizontal) and zoom (ctrl/cmd + wheel, pinch, and the canvas zoom & zoom-to-fit shortcuts — ctrl/cmd +/-/0, shift+1/2/3) the canvas view-mode style — respecting `appState.scrollConstraints`, so it composes with viewport locking for constrained viewers, while the rest of the keyboard stays disabled and the editor consumes wheel & touch input again (the page no longer scrolls over the editor); with `links` allowed, link icons render and linked elements stay clickable (view-mode behavior: clicking anywhere on a linked element opens its link) while the editor stays otherwise non-interactive — when links are not allowed, link icons are not rendered at all; with `embeds` allowed, embeddable & iframe elements stay interactive (hover & click activates them, view-mode style); `interactiveContent` is an additive shorthand enabling `links` & `embeds` (and future interactive content kinds) together; with `browserZoom` allowed, the browser's own zoom gestures (ctrl/cmd + wheel, pinch, keyboard) stay available over the editor (moot when `navigation` is allowed, since the editor consumes the zoom input instead). The scene still renders and remains fully controllable through the imperative API (`updateScene`, `setViewport`, ...), making it suitable for live previews, thumbnails, and annotation overlays.
|
||||
|
||||
- Added `ui` prop. When set to `false`, Excalidraw's default UI is not rendered — toolbar, default menu and footer controls, sidebars, canvas popups, and context menu. Canvas content (elements, text editing, frame names, embeds) still renders, and the editor remains interactive unless `interaction={false}` is set as well. Host children continue to mount (they may be functional, e.g. a collaboration component), and host-supplied UI — including Excalidraw-exported components such as `<MainMenu>` and `<Footer>` — continues to render together with any supporting dialogs it opens. You're responsible for hiding your own presentational components.
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ import type {
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { resetCursor } from "../cursor";
|
||||
import { done } from "../components/icons";
|
||||
import { TOGGLE_TOOLS } from "../components/Tools";
|
||||
import { IconButton } from "../components/IconButton";
|
||||
@@ -57,7 +56,7 @@ export const actionFinalize = register<FormData>({
|
||||
perform: (elements, appState, data, app) => {
|
||||
let shouldCommit = true;
|
||||
let newElements = elements;
|
||||
const { interactiveCanvas, focusContainer, scene } = app;
|
||||
const { focusContainer, scene } = app;
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
|
||||
if (data && appState.selectedLinearElement) {
|
||||
@@ -317,14 +316,6 @@ export const actionFinalize = register<FormData>({
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(!appState.activeTool.locked &&
|
||||
appState.activeTool.type !== "freedraw") ||
|
||||
!element
|
||||
) {
|
||||
resetCursor(interactiveCanvas);
|
||||
}
|
||||
|
||||
let activeTool: AppState["activeTool"];
|
||||
if (TOGGLE_TOOLS.includes(appState.activeTool.type)) {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
@@ -339,6 +330,18 @@ export const actionFinalize = register<FormData>({
|
||||
});
|
||||
}
|
||||
|
||||
// locked via the tool lock or host-forced (`props.activeTool`)
|
||||
const isToolLocked = app.isToolLocked();
|
||||
|
||||
if (
|
||||
(!isToolLocked && appState.activeTool.type !== "freedraw") ||
|
||||
!element
|
||||
) {
|
||||
// the active tool reverts (see the returned `appState.activeTool`) —
|
||||
// apply the reverted tool's cursor
|
||||
app.cursor.applyForTool(activeTool);
|
||||
}
|
||||
|
||||
let selectedLinearElement =
|
||||
element && isLinearElement(element)
|
||||
? new LinearElementEditor(element, arrayToMap(newElements)) // To select the linear element when user has finished mutipoint editing
|
||||
@@ -364,9 +367,7 @@ export const actionFinalize = register<FormData>({
|
||||
...appState,
|
||||
cursorButton: "up",
|
||||
activeTool:
|
||||
(appState.activeTool.locked ||
|
||||
appState.activeTool.type === "freedraw") &&
|
||||
element
|
||||
(isToolLocked || appState.activeTool.type === "freedraw") && element
|
||||
? appState.activeTool
|
||||
: activeTool,
|
||||
activeEmbeddable: null,
|
||||
@@ -377,9 +378,7 @@ export const actionFinalize = register<FormData>({
|
||||
suggestedBinding: null,
|
||||
frameToHighlight: null,
|
||||
selectedElementIds:
|
||||
element &&
|
||||
!appState.activeTool.locked &&
|
||||
appState.activeTool.type !== "freedraw"
|
||||
element && !isToolLocked && appState.activeTool.type !== "freedraw"
|
||||
? {
|
||||
...appState.selectedElementIds,
|
||||
[element.id]: true,
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { CURSOR_TYPE, MIME_TYPES, THEME } from "@excalidraw/common";
|
||||
|
||||
import { isHandToolActive, isEraserActive } from "../appState";
|
||||
|
||||
import type App from "./App";
|
||||
import type { AppState, DataURL } from "../types";
|
||||
|
||||
const laserPointerCursorSVG_tag = `<svg viewBox="0 0 24 24" stroke-width="1" width="28" height="28" xmlns="http://www.w3.org/2000/svg">`;
|
||||
const laserPointerCursorBackgroundSVG = `<path d="M6.164 11.755a5.314 5.314 0 0 1-4.932-5.298 5.314 5.314 0 0 1 5.311-5.311 5.314 5.314 0 0 1 5.307 5.113l8.773 8.773a3.322 3.322 0 0 1 0 4.696l-.895.895a3.322 3.322 0 0 1-4.696 0l-8.868-8.868Z" style="fill:#fff"/>`;
|
||||
const laserPointerCursorIconSVG = `<path stroke="#1b1b1f" fill="#fff" d="m7.868 11.113 7.773 7.774a2.359 2.359 0 0 0 1.667.691 2.368 2.368 0 0 0 2.357-2.358c0-.625-.248-1.225-.69-1.667L11.201 7.78 9.558 9.469l-1.69 1.643v.001Zm10.273 3.606-3.333 3.333m-3.25-6.583 2 2m-7-7 3 3M3.664 3.625l1 1M2.529 6.922l1.407-.144m5.735-2.932-1.118.866M4.285 9.823l.758-1.194m1.863-6.207-.13 1.408"/>`;
|
||||
|
||||
const laserPointerCursorDataURL_lightMode = `data:${
|
||||
MIME_TYPES.svg
|
||||
},${encodeURIComponent(
|
||||
`${laserPointerCursorSVG_tag}${laserPointerCursorIconSVG}</svg>`,
|
||||
)}`;
|
||||
const laserPointerCursorDataURL_darkMode = `data:${
|
||||
MIME_TYPES.svg
|
||||
},${encodeURIComponent(
|
||||
`${laserPointerCursorSVG_tag}${laserPointerCursorBackgroundSVG}${laserPointerCursorIconSVG}</svg>`,
|
||||
)}`;
|
||||
|
||||
/**
|
||||
* Captures all cursor management for the interactive canvas.
|
||||
*
|
||||
* The canvas cursor is set exclusively imperatively, through this class —
|
||||
* never via inline styles or React-rendered `style.cursor`, which React
|
||||
* would rewrite on rerender whenever the computed value changes, clobbering
|
||||
* cursors set here.
|
||||
*/
|
||||
export class AppCursor {
|
||||
private eraserCanvasCache:
|
||||
| (HTMLCanvasElement & { theme?: AppState["theme"] })
|
||||
| null = null;
|
||||
private eraserPreviewDataURL: DataURL | null = null;
|
||||
|
||||
constructor(private app: App) {}
|
||||
|
||||
private get canvas() {
|
||||
return this.app.interactiveCanvas;
|
||||
}
|
||||
|
||||
set = (cursor: string) => {
|
||||
if (this.canvas) {
|
||||
this.canvas.style.cursor = cursor;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Resets to the resting cursor — the cursor shown when no interaction or
|
||||
* hover affordance overrides it: the view-mode grab cursor when
|
||||
* drag-to-pan applies, the active tool's cursor otherwise.
|
||||
*/
|
||||
reset = () => {
|
||||
if (
|
||||
this.app.state.viewModeEnabled &&
|
||||
!this.app.isActiveToolPointerCapturing()
|
||||
) {
|
||||
if (this.app.isNavigationEnabled()) {
|
||||
this.set(CURSOR_TYPE.GRAB);
|
||||
} else {
|
||||
this.clear();
|
||||
}
|
||||
} else {
|
||||
this.applyForTool();
|
||||
}
|
||||
};
|
||||
|
||||
/** applies the given tool's cursor (defaults to the active tool) */
|
||||
applyForTool = (
|
||||
activeTool: AppState["activeTool"] = this.app.state.activeTool,
|
||||
) => {
|
||||
if (!this.canvas) {
|
||||
return;
|
||||
}
|
||||
if (activeTool.type === "selection") {
|
||||
this.clear();
|
||||
} else if (isHandToolActive({ activeTool })) {
|
||||
this.set(CURSOR_TYPE.GRAB);
|
||||
} else if (isEraserActive({ activeTool })) {
|
||||
this.applyEraser();
|
||||
} else if (activeTool.type === "laser") {
|
||||
const url =
|
||||
this.app.state.theme === THEME.LIGHT
|
||||
? laserPointerCursorDataURL_lightMode
|
||||
: laserPointerCursorDataURL_darkMode;
|
||||
this.set(`url(${url}), auto`);
|
||||
} else if (!["image", "custom"].includes(activeTool.type)) {
|
||||
this.set(CURSOR_TYPE.CROSSHAIR);
|
||||
// do nothing if image tool is selected which suggests there's
|
||||
// an image-preview set as the cursor
|
||||
// Ignore custom type as well and let host decide
|
||||
} else if (activeTool.type !== "image") {
|
||||
this.set(CURSOR_TYPE.AUTO);
|
||||
}
|
||||
};
|
||||
|
||||
/** clears the inline cursor so the environment default (CSS) applies */
|
||||
private clear = () => {
|
||||
if (this.canvas) {
|
||||
this.canvas.style.cursor = "";
|
||||
}
|
||||
};
|
||||
|
||||
private applyEraser = () => {
|
||||
const cursorImageSizePx = 20;
|
||||
const theme = this.app.state.theme;
|
||||
|
||||
if (!this.eraserCanvasCache || this.eraserCanvasCache.theme !== theme) {
|
||||
const isDarkTheme = theme === THEME.DARK;
|
||||
this.eraserCanvasCache = document.createElement("canvas");
|
||||
this.eraserCanvasCache.theme = theme;
|
||||
this.eraserCanvasCache.height = cursorImageSizePx;
|
||||
this.eraserCanvasCache.width = cursorImageSizePx;
|
||||
const context = this.eraserCanvasCache.getContext("2d")!;
|
||||
context.lineWidth = 1;
|
||||
context.beginPath();
|
||||
context.arc(
|
||||
this.eraserCanvasCache.width / 2,
|
||||
this.eraserCanvasCache.height / 2,
|
||||
5,
|
||||
0,
|
||||
2 * Math.PI,
|
||||
);
|
||||
context.fillStyle = isDarkTheme ? "#000" : "#fff";
|
||||
context.fill();
|
||||
context.strokeStyle = isDarkTheme ? "#fff" : "#000";
|
||||
context.stroke();
|
||||
this.eraserPreviewDataURL = this.eraserCanvasCache.toDataURL(
|
||||
MIME_TYPES.svg,
|
||||
) as DataURL;
|
||||
}
|
||||
|
||||
this.set(
|
||||
`url(${this.eraserPreviewDataURL}) ${cursorImageSizePx / 2} ${
|
||||
cursorImageSizePx / 2
|
||||
}, auto`,
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -418,12 +418,6 @@ import {
|
||||
animateToConstraints,
|
||||
isViewportOverscrolled,
|
||||
} from "../viewport";
|
||||
import {
|
||||
setEraserCursor,
|
||||
setCursor,
|
||||
resetCursor,
|
||||
setCursorForShape,
|
||||
} from "../cursor";
|
||||
import { ElementCanvasButtons } from "../components/ElementCanvasButtons";
|
||||
import { LaserTrails } from "../laserTrails";
|
||||
import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
|
||||
@@ -444,6 +438,7 @@ import ConvertElementTypePopup, {
|
||||
} from "./ConvertElementTypePopup";
|
||||
|
||||
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
|
||||
import { AppCursor } from "./App.cursor";
|
||||
import { AppFlowchart } from "./App.flowchart";
|
||||
import BraveMeasureTextError from "./BraveMeasureTextError";
|
||||
import { ContextMenu, CONTEXT_MENU_SEPARATOR } from "./ContextMenu";
|
||||
@@ -721,6 +716,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
public onStateChange: OnStateChange = this.appStateObserver.onStateChange;
|
||||
|
||||
public flowchart: AppFlowchart = new AppFlowchart(this);
|
||||
public cursor: AppCursor = new AppCursor(this);
|
||||
|
||||
bindModeHandler: ReturnType<typeof setTimeout> | null = null;
|
||||
private textWysiwygSubmitHandler: ReturnType<typeof textWysiwyg> | null =
|
||||
@@ -814,8 +810,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
setToast: this.setToast,
|
||||
id: this.id,
|
||||
setActiveTool: this.setActiveTool,
|
||||
setCursor: this.setCursor,
|
||||
resetCursor: this.resetCursor,
|
||||
setCursor: this.cursor.set,
|
||||
resetCursor: this.cursor.reset,
|
||||
getEditorInterface: () => this.editorInterface,
|
||||
updateFrameRendering: this.updateFrameRendering,
|
||||
toggleSidebar: this.toggleSidebar,
|
||||
@@ -843,6 +839,21 @@ class App extends React.Component<AppProps, AppState> {
|
||||
name = `${t("labels.untitled")}-${getDateTime()}`,
|
||||
} = props;
|
||||
|
||||
// seed the host-forced tool so the first render already has it
|
||||
// (`handleForcedToolChange` keeps it in sync from then on)
|
||||
let forcedActiveTool: AppState["activeTool"] | null = null;
|
||||
if (props.activeTool) {
|
||||
if ((props.activeTool.type as string) === "image") {
|
||||
console.warn(`"image" tool cannot be forced via "props.activeTool"`);
|
||||
} else if (!this.isToolSupported(props.activeTool.type, props)) {
|
||||
console.warn(
|
||||
`"${props.activeTool.type}" tool ("props.activeTool") cannot be activated — disabled via "UIOptions.tools", or not enabled while non-interactive (see "interaction.enabled.tools")`,
|
||||
);
|
||||
} else {
|
||||
forcedActiveTool = updateActiveTool(defaultAppState, props.activeTool);
|
||||
}
|
||||
}
|
||||
|
||||
this.state = {
|
||||
...defaultAppState,
|
||||
theme,
|
||||
@@ -854,6 +865,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
viewModeEnabled: this.isInteractionEnabled(props)
|
||||
? viewModeEnabled
|
||||
: true,
|
||||
activeTool: forcedActiveTool ?? defaultAppState.activeTool,
|
||||
zenModeEnabled,
|
||||
objectsSnapModeEnabled,
|
||||
gridModeEnabled: gridModeEnabled ?? defaultAppState.gridModeEnabled,
|
||||
@@ -980,6 +992,72 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the tool can be activated & driven by user input. False when
|
||||
* disabled via `UIOptions.tools`, or when the editor is non-interactive
|
||||
* and the tool isn't kept user-driven via `interaction.enabled.tools`.
|
||||
*
|
||||
* (Once UI tool availability is split from input availability — e.g.
|
||||
* `props.ui.tools` vs `interaction.disabled.tools` — the UI axis moves
|
||||
* out into its own predicate.)
|
||||
*
|
||||
* We purposely widen the `tool` type so this helper can be called with
|
||||
* any tool without having to type check it.
|
||||
*/
|
||||
public isToolSupported = <T extends ToolType | "custom">(
|
||||
tool: T,
|
||||
props: Pick<AppProps, "interaction" | "UIOptions"> = this.props,
|
||||
): boolean => {
|
||||
if (
|
||||
props.UIOptions.tools?.[
|
||||
tool as Extract<T, keyof AppProps["UIOptions"]["tools"]>
|
||||
] === false
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (this.isInteractionEnabled(props)) {
|
||||
return true;
|
||||
}
|
||||
const tools =
|
||||
typeof props.interaction === "object" && props.interaction !== null
|
||||
? props.interaction.enabled?.tools
|
||||
: undefined;
|
||||
if (tool === "laser") {
|
||||
return tools?.laser === true;
|
||||
}
|
||||
if (tool === "custom") {
|
||||
return tools?.custom === true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the active tool is locked in place — via the tool lock
|
||||
* (padlock / Q) or by being host-forced (`props.activeTool`). A locked
|
||||
* tool doesn't revert to the selection tool after use, and elements drawn
|
||||
* with it aren't selected. Forcing deliberately does not mutate
|
||||
* `activeTool.locked`, which is the user's persisted padlock preference.
|
||||
*/
|
||||
public isToolLocked(): boolean {
|
||||
return this.state.activeTool.locked || this.props.activeTool != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the active tool captures the primary pointer instead of the
|
||||
* view-mode drag-to-pan — the laser always does; while non-interactive,
|
||||
* any tool allowed via `interaction.enabled.tools` does. (Editing tools
|
||||
* capture the pointer trivially since view mode implies they're not
|
||||
* active; this predicate only matters where view-mode gates apply.)
|
||||
*/
|
||||
public isActiveToolPointerCapturing(): boolean {
|
||||
if (!this.isInteractionEnabled()) {
|
||||
// an active tool that isn't allowed via `interaction.enabled.tools`
|
||||
// is inert — including the laser
|
||||
return this.isToolSupported(this.state.activeTool.type);
|
||||
}
|
||||
return this.state.activeTool.type === "laser";
|
||||
}
|
||||
|
||||
/** Whether Excalidraw's default UI is rendered. */
|
||||
public isDefaultUIEnabled(props: Pick<AppProps, "ui"> = this.props): boolean {
|
||||
return props.ui !== false;
|
||||
@@ -1437,7 +1515,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
scenePointer.y,
|
||||
))
|
||||
) {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
|
||||
this.cursor.set(CURSOR_TYPE.POINTER);
|
||||
this.setState({
|
||||
activeEmbeddable: { element: hitElement, state: "hover" },
|
||||
});
|
||||
@@ -2281,6 +2359,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
"excalidraw--non-interactive": !this.isInteractionEnabled(),
|
||||
"excalidraw--navigation":
|
||||
!this.isInteractionEnabled() && this.isNavigationEnabled(),
|
||||
"excalidraw--tools":
|
||||
!this.isInteractionEnabled() &&
|
||||
this.isToolSupported(this.state.activeTool.type),
|
||||
"excalidraw--embeds":
|
||||
!this.isInteractionEnabled() && this.isEmbedsEnabled(),
|
||||
"excalidraw--allow-browser-zoom":
|
||||
@@ -3047,13 +3128,48 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
};
|
||||
|
||||
// dispatches only `navigation`-flagged action shortcuts (canvas zoom &
|
||||
// zoom-to-fit — see `ActionManager.handleKeyDown` gates); the rest of the
|
||||
// keyboard handling stays disabled while non-interactive
|
||||
// handles only the navigation keyboard: page-scroll keys and
|
||||
// `navigation`-flagged action shortcuts (canvas zoom & zoom-to-fit — see
|
||||
// `ActionManager.handleKeyDown` gates); the rest of the keyboard handling
|
||||
// stays disabled while non-interactive
|
||||
private handleNavigationModeKeyDown = (event: KeyboardEvent) => {
|
||||
if (this.maybeHandlePageScrollKeyDown(event)) {
|
||||
// the editor consumes the input — the page must not scroll along
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
this.actionManager.handleKeyDown(event);
|
||||
};
|
||||
|
||||
/**
|
||||
* PageUp/PageDown scroll the canvas by a page — vertically, or
|
||||
* horizontally with shift. Respects `appState.scrollConstraints`
|
||||
* (via `translateCanvas`).
|
||||
*/
|
||||
private maybeHandlePageScrollKeyDown = (
|
||||
event: KeyboardEvent | React.KeyboardEvent,
|
||||
): boolean => {
|
||||
if (event.key !== KEYS.PAGE_UP && event.key !== KEYS.PAGE_DOWN) {
|
||||
return false;
|
||||
}
|
||||
let offset =
|
||||
(event.shiftKey ? this.state.width : this.state.height) /
|
||||
this.state.zoom.value;
|
||||
if (event.key === KEYS.PAGE_DOWN) {
|
||||
offset = -offset;
|
||||
}
|
||||
if (event.shiftKey) {
|
||||
this.translateCanvas((state) => ({
|
||||
scrollX: state.scrollX + offset,
|
||||
}));
|
||||
} else {
|
||||
this.translateCanvas((state) => ({
|
||||
scrollY: state.scrollY + offset,
|
||||
}));
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
private preventBrowserZoomKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
@@ -3144,7 +3260,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.deselectElements();
|
||||
if (!this.isInteractionEnabled()) {
|
||||
this.setState({ originSnapOffset: null });
|
||||
resetCursor(this.interactiveCanvas);
|
||||
this.cursor.reset();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3194,7 +3310,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (!this.isLinksEnabled()) {
|
||||
this.hitLinkElement = undefined;
|
||||
hideHyperlinkToolip();
|
||||
resetCursor(this.interactiveCanvas);
|
||||
this.cursor.reset();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3204,11 +3320,39 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
this.isToolSupported(prevState.activeTool.type, prevProps) !==
|
||||
this.isToolSupported(this.state.activeTool.type)
|
||||
) {
|
||||
if (!this.isToolSupported(this.state.activeTool.type)) {
|
||||
// end a possibly mid-stroke laser trail (the stroke's own window
|
||||
// listeners tear down on the next pointerup)
|
||||
this.laserTrails.endPath();
|
||||
}
|
||||
this.cursor.reset();
|
||||
}
|
||||
|
||||
// invariant: while non-interactive, the active tool is either
|
||||
// input-enabled (`interaction.enabled.tools`) or the neutral default —
|
||||
// reset stale tool state (e.g. a presenter's laser after handing off)
|
||||
// so it doesn't leak through `onChange` / collab pointer payloads or
|
||||
// linger until interaction is re-enabled
|
||||
if (
|
||||
!this.isInteractionEnabled() &&
|
||||
!this.isToolSupported(this.state.activeTool.type) &&
|
||||
this.state.activeTool.type !== "selection"
|
||||
) {
|
||||
this.setState({
|
||||
activeTool: updateActiveTool(this.state, { type: "selection" }),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
this.isBrowserZoomEnabled(prevProps) !== this.isBrowserZoomEnabled() ||
|
||||
this.isNavigationEnabled(prevProps) !== this.isNavigationEnabled()
|
||||
) {
|
||||
this.addEventListeners();
|
||||
this.cursor.reset();
|
||||
}
|
||||
|
||||
if (prevState.viewModeEnabled !== this.state.viewModeEnabled) {
|
||||
@@ -3218,6 +3362,62 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (!this.state.viewModeEnabled) {
|
||||
this.deselectElements();
|
||||
}
|
||||
this.cursor.reset();
|
||||
}
|
||||
};
|
||||
|
||||
/** whether the two values reference the same tool (incl. custom subtype) */
|
||||
private isSameForcedTool = (
|
||||
a: { type: string; customType?: string | null } | null | undefined,
|
||||
b: { type: string; customType?: string | null } | null | undefined,
|
||||
) =>
|
||||
a?.type === b?.type &&
|
||||
(a?.type === "custom" ? a.customType ?? null : null) ===
|
||||
(b?.type === "custom" ? b.customType ?? null : null);
|
||||
|
||||
/**
|
||||
* Keeps `state.activeTool` synced to the host-controlled
|
||||
* `props.activeTool`. `setActiveTool` refuses non-matching activations
|
||||
* while forced (user input, API); this backstop covers the writers that
|
||||
* bypass the funnel (`actionFinalize`/`actionDeselect`, `restore()` on
|
||||
* scene load, ...) and re-applies the tool once it becomes activatable
|
||||
* (e.g. `interaction` config changes).
|
||||
*/
|
||||
private handleForcedToolChange = (
|
||||
prevProps: AppProps,
|
||||
prevState: AppState,
|
||||
) => {
|
||||
const forcedTool = this.props.activeTool;
|
||||
if (!forcedTool) {
|
||||
return;
|
||||
}
|
||||
|
||||
const forcedToolChanged = !this.isSameForcedTool(
|
||||
prevProps.activeTool,
|
||||
forcedTool,
|
||||
);
|
||||
|
||||
if ((forcedTool.type as string) === "image") {
|
||||
if (forcedToolChanged) {
|
||||
console.warn(`"image" tool cannot be forced via "props.activeTool"`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isSameForcedTool(forcedTool, this.state.activeTool)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// (re)force only on relevant changes so that a standing refusal (tool
|
||||
// disabled, or not enabled while non-interactive) warns once instead of
|
||||
// on every update
|
||||
if (
|
||||
forcedToolChanged ||
|
||||
prevState.activeTool !== this.state.activeTool ||
|
||||
this.isToolSupported(forcedTool.type, prevProps) !==
|
||||
this.isToolSupported(forcedTool.type, this.props)
|
||||
) {
|
||||
this.setActiveTool(forcedTool);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3896,6 +4096,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
this.handleInteractionStateChange(prevProps, prevState);
|
||||
this.handleForcedToolChange(prevProps, prevState);
|
||||
|
||||
this.appStateObserver.flush(prevState);
|
||||
|
||||
@@ -3967,7 +4168,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.state.activeTool.type === "eraser" &&
|
||||
prevState.theme !== this.state.theme
|
||||
) {
|
||||
setEraserCursor(this.interactiveCanvas, this.state.theme);
|
||||
this.cursor.applyForTool();
|
||||
}
|
||||
|
||||
// Hide hyperlink popup if shown when element type is not selection
|
||||
@@ -4784,6 +4985,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
};
|
||||
|
||||
toggleLock = (source: "keyboard" | "ui" = "ui") => {
|
||||
if (this.props.activeTool) {
|
||||
// the active tool — including its lock state — is host-controlled
|
||||
return;
|
||||
}
|
||||
if (!this.state.activeTool.locked) {
|
||||
trackEvent(
|
||||
"toolbar",
|
||||
@@ -5585,23 +5790,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === KEYS.PAGE_UP || event.key === KEYS.PAGE_DOWN) {
|
||||
let offset =
|
||||
(event.shiftKey ? this.state.width : this.state.height) /
|
||||
this.state.zoom.value;
|
||||
if (event.key === KEYS.PAGE_DOWN) {
|
||||
offset = -offset;
|
||||
}
|
||||
if (event.shiftKey) {
|
||||
this.translateCanvas((state) => ({
|
||||
scrollX: state.scrollX + offset,
|
||||
}));
|
||||
} else {
|
||||
this.translateCanvas((state) => ({
|
||||
scrollY: state.scrollY + offset,
|
||||
}));
|
||||
}
|
||||
}
|
||||
this.maybeHandlePageScrollKeyDown(event);
|
||||
|
||||
if (this.state.openDialog?.name === "elementLinkSelector") {
|
||||
return;
|
||||
@@ -5835,7 +6024,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
if (event.key === KEYS.SPACE && gesture.pointers.size === 0) {
|
||||
isHoldingSpace = true;
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
|
||||
this.cursor.set(CURSOR_TYPE.GRAB);
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
@@ -5930,11 +6119,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.state.activeTool.type !== "laser") ||
|
||||
this.state.openDialog?.name === "elementLinkSelector"
|
||||
) {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
|
||||
this.cursor.set(CURSOR_TYPE.GRAB);
|
||||
} else if (isSelectionLikeTool(this.state.activeTool.type)) {
|
||||
resetCursor(this.interactiveCanvas);
|
||||
this.cursor.reset();
|
||||
} else {
|
||||
setCursorForShape(this.interactiveCanvas, this.state);
|
||||
this.cursor.applyForTool();
|
||||
this.setState({
|
||||
selectedElementIds: makeNextSelectedElementIds({}, this.state),
|
||||
selectedGroupIds: {},
|
||||
@@ -6050,16 +6239,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.flowchart.handleKeyEvent(event);
|
||||
});
|
||||
|
||||
// We purposely widen the `tool` type so this helper can be called with
|
||||
// any tool without having to type check it
|
||||
private isToolSupported = <T extends ToolType | "custom">(tool: T) => {
|
||||
return (
|
||||
this.props.UIOptions.tools?.[
|
||||
tool as Extract<T, keyof AppProps["UIOptions"]["tools"]>
|
||||
] !== false
|
||||
);
|
||||
};
|
||||
|
||||
setActiveTool = (
|
||||
tool: ({ type: ToolType } | { type: "custom"; customType: string }) & {
|
||||
locked?: boolean;
|
||||
@@ -6081,7 +6260,19 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
if (!this.isToolSupported(tool.type)) {
|
||||
console.warn(
|
||||
`"${tool.type}" tool is disabled via "UIOptions.canvasActions.tools.${tool.type}"`,
|
||||
this.isInteractionEnabled()
|
||||
? `"${tool.type}" tool is disabled via "UIOptions.canvasActions.tools.${tool.type}"`
|
||||
: `"${tool.type}" tool cannot be activated while the editor is non-interactive (see "interaction.enabled.tools")`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this.props.activeTool &&
|
||||
!this.isSameForcedTool(this.props.activeTool, tool)
|
||||
) {
|
||||
console.warn(
|
||||
`"${tool.type}" tool activation ignored — the active tool is controlled by the host via "props.activeTool"`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -6107,12 +6298,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
})
|
||||
: updateActiveTool(this.state, tool);
|
||||
if (nextActiveTool.type === "hand") {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
|
||||
this.cursor.set(CURSOR_TYPE.GRAB);
|
||||
} else if (!isHoldingSpace) {
|
||||
setCursorForShape(this.interactiveCanvas, {
|
||||
...this.state,
|
||||
activeTool: nextActiveTool,
|
||||
});
|
||||
this.cursor.applyForTool(nextActiveTool);
|
||||
}
|
||||
if (isToolIcon(document.activeElement)) {
|
||||
this.focusContainer();
|
||||
@@ -6176,13 +6364,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.setState({ openDialog: dialogType });
|
||||
};
|
||||
|
||||
private setCursor = (cursor: string) => {
|
||||
setCursor(this.interactiveCanvas, cursor);
|
||||
};
|
||||
|
||||
private resetCursor = () => {
|
||||
resetCursor(this.interactiveCanvas);
|
||||
};
|
||||
/**
|
||||
* returns whether user is making a gesture with >= 2 fingers (points)
|
||||
* on o touch screen (not on a trackpad). Currently only relates to Darwin
|
||||
@@ -6388,8 +6569,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
});
|
||||
|
||||
if (this.state.activeTool.locked) {
|
||||
setCursorForShape(this.interactiveCanvas, this.state);
|
||||
if (this.isToolLocked()) {
|
||||
this.cursor.applyForTool();
|
||||
}
|
||||
|
||||
this.focusContainer();
|
||||
@@ -6544,7 +6725,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
// due to the pointerdown
|
||||
activeTextElement,
|
||||
);
|
||||
this.resetCursor();
|
||||
this.cursor.reset();
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -7189,7 +7370,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return;
|
||||
}
|
||||
|
||||
resetCursor(this.interactiveCanvas);
|
||||
this.cursor.reset();
|
||||
|
||||
const selectedGroupIds = getSelectedGroupIds(this.state);
|
||||
|
||||
@@ -7218,7 +7399,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
}
|
||||
|
||||
resetCursor(this.interactiveCanvas);
|
||||
this.cursor.reset();
|
||||
if (!event[KEYS.CTRL_OR_CMD] && !this.state.viewModeEnabled) {
|
||||
const hitElement = this.getElementAtPosition(sceneX, sceneY);
|
||||
|
||||
@@ -7400,7 +7581,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.hitLinkElement &&
|
||||
!this.state.selectedElementIds[this.hitLinkElement.id]
|
||||
) {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
|
||||
this.cursor.set(CURSOR_TYPE.POINTER);
|
||||
|
||||
showHyperlinkTooltip(
|
||||
this.hitLinkElement,
|
||||
@@ -7483,11 +7664,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
? this.getElementLinkAtPosition(scenePointer, hitElementMightBeLocked)
|
||||
: undefined;
|
||||
if (!this.applyElementLinkHoverAffordance()) {
|
||||
if (this.isNavigationEnabled()) {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
|
||||
} else {
|
||||
resetCursor(this.interactiveCanvas);
|
||||
}
|
||||
this.cursor.reset();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -7676,6 +7853,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
event: React.PointerEvent<HTMLCanvasElement>,
|
||||
) => {
|
||||
if (!this.isInteractionEnabled()) {
|
||||
if (this.isToolSupported(this.state.activeTool.type)) {
|
||||
// keep broadcasting the pointer (`props.onPointerUpdate`) between
|
||||
// strokes of the enabled tool, so e.g. a presenter's cursor stays
|
||||
// visible to collaborators while the laser isn't drawing
|
||||
this.savePointer(event.clientX, event.clientY, this.state.cursorButton);
|
||||
}
|
||||
if (this.isNavigationEnabled()) {
|
||||
// two-finger pinch zoom/pan (single-pointer panning is handled by
|
||||
// the pan session set up on pointerdown)
|
||||
@@ -7719,9 +7902,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
!this.state.multiElement
|
||||
) {
|
||||
if (isOverScrollBar) {
|
||||
resetCursor(this.interactiveCanvas);
|
||||
this.cursor.set(CURSOR_TYPE.AUTO);
|
||||
} else {
|
||||
setCursorForShape(this.interactiveCanvas, this.state);
|
||||
this.cursor.applyForTool();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7851,7 +8034,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
const { lastCommittedPoint } = selectedLinearElement;
|
||||
|
||||
setCursorForShape(this.interactiveCanvas, this.state);
|
||||
this.cursor.applyForTool();
|
||||
|
||||
if (lastPoint === lastCommittedPoint) {
|
||||
if (
|
||||
@@ -7891,7 +8074,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
{ informMutation: false, isDragging: false },
|
||||
);
|
||||
} else {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
|
||||
this.cursor.set(CURSOR_TYPE.POINTER);
|
||||
// in this branch, we're inside the commit zone, and no uncommitted
|
||||
// point exists. Thus do nothing (don't add/remove points).
|
||||
}
|
||||
@@ -7903,7 +8086,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
lastCommittedPoint,
|
||||
) < LINE_CONFIRM_THRESHOLD
|
||||
) {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
|
||||
this.cursor.set(CURSOR_TYPE.POINTER);
|
||||
this.scene.mutateElement(
|
||||
multiElement,
|
||||
{
|
||||
@@ -7933,7 +8116,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
} else {
|
||||
if (isPathALoop(points, this.state.zoom.value)) {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
|
||||
this.cursor.set(CURSOR_TYPE.POINTER);
|
||||
}
|
||||
|
||||
// Update arrow points
|
||||
@@ -8017,7 +8200,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
const selectedElements = this.scene.getSelectedElements(this.state);
|
||||
|
||||
if (this.isHittingTextAutoResizeHandle(selectedElements, scenePointer)) {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
|
||||
this.cursor.set(CURSOR_TYPE.POINTER);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -8064,8 +8247,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
elementWithTransformHandleType &&
|
||||
elementWithTransformHandleType.transformHandleType
|
||||
) {
|
||||
setCursor(
|
||||
this.interactiveCanvas,
|
||||
this.cursor.set(
|
||||
getCursorForResizingElement(elementWithTransformHandleType),
|
||||
);
|
||||
return;
|
||||
@@ -8085,8 +8267,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.editorInterface,
|
||||
);
|
||||
if (transformHandleType) {
|
||||
setCursor(
|
||||
this.interactiveCanvas,
|
||||
this.cursor.set(
|
||||
getCursorForResizingElement({
|
||||
transformHandleType,
|
||||
}),
|
||||
@@ -8141,8 +8322,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
) {
|
||||
this.setState({ showHyperlinkPopup: "info" });
|
||||
} else if (this.state.activeTool.type === "text") {
|
||||
setCursor(
|
||||
this.interactiveCanvas,
|
||||
this.cursor.set(
|
||||
isTextElement(hitElement) ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR,
|
||||
);
|
||||
} else if (
|
||||
@@ -8152,13 +8332,13 @@ class App extends React.Component<AppProps, AppState> {
|
||||
selectedElements,
|
||||
)
|
||||
) {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
|
||||
this.cursor.set(CURSOR_TYPE.MOVE);
|
||||
} else if (this.state.viewModeEnabled) {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
|
||||
this.cursor.set(CURSOR_TYPE.GRAB);
|
||||
} else if (this.state.openDialog?.name === "elementLinkSelector") {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
|
||||
this.cursor.set(CURSOR_TYPE.AUTO);
|
||||
} else if (isOverScrollBar) {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
|
||||
this.cursor.set(CURSOR_TYPE.AUTO);
|
||||
} else if (
|
||||
// if using cmd/ctrl, we're not dragging
|
||||
!event[KEYS.CTRL_OR_CMD] &&
|
||||
@@ -8183,12 +8363,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.state.activeTool.type !== "lasso" ||
|
||||
selectedElements.length > 0
|
||||
) {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
|
||||
this.cursor.set(CURSOR_TYPE.MOVE);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
|
||||
this.cursor.set(CURSOR_TYPE.AUTO);
|
||||
}
|
||||
|
||||
if (this.state.selectedLinearElement) {
|
||||
@@ -8294,7 +8474,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
hoverPointIndex === element.points.length - 1
|
||||
: hoverPointIndex >= 0;
|
||||
if (isHoveringAPointHandle || segmentMidPointHoveredCoords) {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
|
||||
this.cursor.set(CURSOR_TYPE.POINTER);
|
||||
} else if (this.hitElement(scenePointerX, scenePointerY, element)) {
|
||||
if (
|
||||
// Elbow arrows can only be moved when unconnected
|
||||
@@ -8305,7 +8485,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.state.activeTool.type !== "lasso" ||
|
||||
Object.keys(this.state.selectedElementIds).length > 0
|
||||
) {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
|
||||
this.cursor.set(CURSOR_TYPE.MOVE);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8319,7 +8499,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.state.activeTool.type !== "lasso" ||
|
||||
Object.keys(this.state.selectedElementIds).length > 0
|
||||
) {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
|
||||
this.cursor.set(CURSOR_TYPE.MOVE);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8377,17 +8557,20 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
// Set cursor to pointer when hovering over a focus point
|
||||
if (hoveredFocusPointBinding) {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
|
||||
this.cursor.set(CURSOR_TYPE.POINTER);
|
||||
}
|
||||
} else {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
|
||||
this.cursor.set(CURSOR_TYPE.AUTO);
|
||||
}
|
||||
}
|
||||
|
||||
private handleCanvasPointerDown = (
|
||||
event: React.PointerEvent<HTMLElement>,
|
||||
) => {
|
||||
if (!this.isInteractionEnabled()) {
|
||||
if (
|
||||
!this.isInteractionEnabled() &&
|
||||
!this.isToolSupported(this.state.activeTool.type)
|
||||
) {
|
||||
if (this.isLinksEnabled() || this.isEmbedsEnabled()) {
|
||||
// needed by handleElementLinkClick & handleIframeLikeCenterClick
|
||||
// (drag-distance & hit checks)
|
||||
@@ -8403,6 +8586,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// with the active tool allowed via `interaction.enabled.tools`, the
|
||||
// pointer keeps driving it through the full flow below — safe while
|
||||
// non-interactive because that implies view mode, whose gates constrain
|
||||
// everything except the tool-usage path (laser & custom tools)
|
||||
|
||||
const selectedElements = this.scene.getSelectedElements(this.state);
|
||||
|
||||
@@ -8560,6 +8747,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
if (
|
||||
event.button === POINTER_BUTTON.ERASER &&
|
||||
// must not switch tools while non-interactive (reachable when the
|
||||
// active tool is allowed via `interaction.enabled.tools`) or while
|
||||
// the active tool is host-controlled
|
||||
this.isInteractionEnabled() &&
|
||||
!this.props.activeTool &&
|
||||
this.state.activeTool.type !== TOOL_TYPE.eraser
|
||||
) {
|
||||
this.setState(
|
||||
@@ -8798,7 +8990,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
pointerDownState,
|
||||
);
|
||||
} else if (this.state.activeTool.type === "custom") {
|
||||
setCursorForShape(this.interactiveCanvas, this.state);
|
||||
this.cursor.applyForTool();
|
||||
} else if (
|
||||
this.state.activeTool.type === TOOL_TYPE.frame ||
|
||||
this.state.activeTool.type === TOOL_TYPE.magicframe
|
||||
@@ -8850,7 +9042,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
onPointerUp(_event || event.nativeEvent),
|
||||
);
|
||||
|
||||
if (!this.state.viewModeEnabled || this.state.activeTool.type === "laser") {
|
||||
if (!this.state.viewModeEnabled || this.isActiveToolPointerCapturing()) {
|
||||
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
|
||||
window.addEventListener(EVENT.POINTER_UP, onPointerUp);
|
||||
window.addEventListener(EVENT.KEYDOWN, onKeyDown);
|
||||
@@ -8962,11 +9154,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (
|
||||
!(
|
||||
gesture.pointers.size <= 1 &&
|
||||
(event.button === POINTER_BUTTON.WHEEL ||
|
||||
(((event.button === POINTER_BUTTON.WHEEL ||
|
||||
(event.button === POINTER_BUTTON.MAIN && isHoldingSpace) ||
|
||||
isHandToolActive(this.state) ||
|
||||
(this.state.viewModeEnabled &&
|
||||
this.state.activeTool.type !== "laser"))
|
||||
isHandToolActive(this.state)) &&
|
||||
// reachable while non-interactive when the active tool is allowed
|
||||
// via `interaction.enabled.tools` — panning must remain gated on
|
||||
// `navigation` then
|
||||
(this.isInteractionEnabled() || this.isNavigationEnabled())) ||
|
||||
(this.state.viewModeEnabled && !this.isActiveToolPointerCapturing()))
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
@@ -8992,7 +9187,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
? false
|
||||
: /Linux/.test(window.navigator.platform);
|
||||
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRABBING);
|
||||
this.cursor.set(CURSOR_TYPE.GRABBING);
|
||||
let { clientX: lastX, clientY: lastY } = event;
|
||||
const onPointerMove = withBatchedUpdatesThrottled((event: PointerEvent) => {
|
||||
const deltaX = lastX - event.clientX;
|
||||
@@ -9044,14 +9239,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
lastPointerUp = null;
|
||||
isPanning = false;
|
||||
if (!isHoldingSpace) {
|
||||
if (
|
||||
this.state.viewModeEnabled &&
|
||||
this.state.activeTool.type !== "laser"
|
||||
) {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
|
||||
} else {
|
||||
setCursorForShape(this.interactiveCanvas, this.state);
|
||||
}
|
||||
this.cursor.reset();
|
||||
}
|
||||
this.setState({
|
||||
cursorButton: "up",
|
||||
@@ -9266,7 +9454,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
const onPointerUp = withBatchedUpdates(() => {
|
||||
lastPointerUp = null;
|
||||
isDraggingScrollBar = false;
|
||||
setCursorForShape(this.interactiveCanvas, this.state);
|
||||
this.cursor.applyForTool();
|
||||
this.setState({
|
||||
cursorButton: "up",
|
||||
});
|
||||
@@ -9790,13 +9978,18 @@ class App extends React.Component<AppProps, AppState> {
|
||||
initialCaretSceneCoords: { x: sceneX, y: sceneY },
|
||||
});
|
||||
|
||||
resetCursor(this.interactiveCanvas);
|
||||
if (!this.state.activeTool.locked) {
|
||||
this.setState({
|
||||
activeTool: updateActiveTool(this.state, {
|
||||
type: this.state.preferredSelectionTool.type,
|
||||
}),
|
||||
});
|
||||
if (!this.isToolLocked()) {
|
||||
this.setState(
|
||||
{
|
||||
activeTool: updateActiveTool(this.state, {
|
||||
type: this.state.preferredSelectionTool.type,
|
||||
}),
|
||||
},
|
||||
// reset once the tool revert has settled
|
||||
() => this.cursor.reset(),
|
||||
);
|
||||
} else {
|
||||
this.cursor.reset();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -10141,7 +10334,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
),
|
||||
}));
|
||||
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
|
||||
this.cursor.set(CURSOR_TYPE.POINTER);
|
||||
} else {
|
||||
const [gridX, gridY] = getGridPoint(
|
||||
pointerDownState.origin.x,
|
||||
@@ -10287,7 +10480,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
};
|
||||
}
|
||||
|
||||
nextSelectedElementIds = !this.state.activeTool.locked
|
||||
nextSelectedElementIds = !this.isToolLocked()
|
||||
? makeNextSelectedElementIds({ [element.id]: true }, prevState)
|
||||
: prevState.selectedElementIds;
|
||||
|
||||
@@ -11753,25 +11946,28 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
}
|
||||
this.setState({ suggestedBinding: null });
|
||||
if (!activeTool.locked) {
|
||||
resetCursor(this.interactiveCanvas);
|
||||
this.setState((prevState) => ({
|
||||
newElement: null,
|
||||
activeTool: updateActiveTool(this.state, {
|
||||
type: this.state.preferredSelectionTool.type,
|
||||
if (!this.isToolLocked()) {
|
||||
this.setState(
|
||||
(prevState) => ({
|
||||
newElement: null,
|
||||
activeTool: updateActiveTool(this.state, {
|
||||
type: this.state.preferredSelectionTool.type,
|
||||
}),
|
||||
selectedElementIds: makeNextSelectedElementIds(
|
||||
{
|
||||
...prevState.selectedElementIds,
|
||||
[newElement.id]: true,
|
||||
},
|
||||
prevState,
|
||||
),
|
||||
selectedLinearElement: new LinearElementEditor(
|
||||
newElement,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
),
|
||||
}),
|
||||
selectedElementIds: makeNextSelectedElementIds(
|
||||
{
|
||||
...prevState.selectedElementIds,
|
||||
[newElement.id]: true,
|
||||
},
|
||||
prevState,
|
||||
),
|
||||
selectedLinearElement: new LinearElementEditor(
|
||||
newElement,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
),
|
||||
}));
|
||||
// reset once the tool revert has settled
|
||||
() => this.cursor.reset(),
|
||||
);
|
||||
} else {
|
||||
this.setState({
|
||||
newElement: null,
|
||||
@@ -11798,7 +11994,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
}
|
||||
|
||||
this.resetCursor();
|
||||
this.cursor.reset();
|
||||
|
||||
this.handleTextWysiwyg(newElement, {
|
||||
isExistingElement: true,
|
||||
@@ -12303,7 +12499,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
}
|
||||
// reset cursor
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
|
||||
this.cursor.set(CURSOR_TYPE.AUTO);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -12336,7 +12532,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activeTool.locked && activeTool.type !== "freedraw" && newElement) {
|
||||
if (
|
||||
!this.isToolLocked() &&
|
||||
activeTool.type !== "freedraw" &&
|
||||
newElement
|
||||
) {
|
||||
this.setState((prevState) => ({
|
||||
selectedElementIds: makeNextSelectedElementIds(
|
||||
{
|
||||
@@ -12385,20 +12585,23 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
if (
|
||||
!activeTool.locked &&
|
||||
!this.isToolLocked() &&
|
||||
activeTool.type !== "freedraw" &&
|
||||
(activeTool.type !== "lasso" ||
|
||||
// if lasso is turned on but from selection => reset to selection
|
||||
(activeTool.type === "lasso" && activeTool.fromSelection))
|
||||
) {
|
||||
resetCursor(this.interactiveCanvas);
|
||||
this.setState({
|
||||
newElement: null,
|
||||
suggestedBinding: null,
|
||||
activeTool: updateActiveTool(this.state, {
|
||||
type: this.state.preferredSelectionTool.type,
|
||||
}),
|
||||
});
|
||||
this.setState(
|
||||
{
|
||||
newElement: null,
|
||||
suggestedBinding: null,
|
||||
activeTool: updateActiveTool(this.state, {
|
||||
type: this.state.preferredSelectionTool.type,
|
||||
}),
|
||||
},
|
||||
// reset once the tool revert has settled
|
||||
() => this.cursor.reset(),
|
||||
);
|
||||
} else {
|
||||
this.setState({
|
||||
newElement: null,
|
||||
@@ -12508,7 +12711,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
const mimeType = imageFile.type;
|
||||
|
||||
setCursor(this.interactiveCanvas, "wait");
|
||||
this.cursor.set("wait");
|
||||
|
||||
if (mimeType === MIME_TYPES.svg) {
|
||||
try {
|
||||
@@ -12804,6 +13007,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (canvas !== null) {
|
||||
this.interactiveCanvas = canvas;
|
||||
|
||||
// reflect state that landed before the canvas existed — the cursor is
|
||||
// otherwise only set imperatively on *changes* (e.g. the host-forced
|
||||
// tool seeded from `props.activeTool`, or view-mode drag-to-pan)
|
||||
this.cursor.reset();
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// NOTE wheel, touchstart, touchend events must be registered outside
|
||||
// of react because react binds them them passively (so we can't prevent
|
||||
@@ -13754,8 +13962,13 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
private savePointer = (x: number, y: number, button: "up" | "down") => {
|
||||
// don't broadcast pointer updates (props.onPointerUpdate) when
|
||||
// non-interactive
|
||||
if (!this.isInteractionEnabled()) {
|
||||
// non-interactive, unless the active tool stays user-driven via
|
||||
// `interaction.enabled.tools` — collaborators render e.g. a presenter's
|
||||
// laser through these updates
|
||||
if (
|
||||
!this.isInteractionEnabled() &&
|
||||
!this.isToolSupported(this.state.activeTool.type)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!x || !y) {
|
||||
|
||||
@@ -168,6 +168,8 @@ export const IconButton = React.forwardRef(
|
||||
aria-keyshortcuts={props["aria-keyshortcuts"]}
|
||||
aria-pressed={props.checked}
|
||||
data-testid={props["data-testid"]}
|
||||
disabled={!!props.disabled}
|
||||
aria-disabled={!!props.disabled}
|
||||
onPointerDown={(event) => {
|
||||
lastPointerTypeRef.current = event.pointerType || null;
|
||||
}}
|
||||
|
||||
@@ -8,6 +8,7 @@ type LockButtonProps = {
|
||||
checked: boolean;
|
||||
onChange?(): void;
|
||||
isMobile?: boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const LockButton = (props: LockButtonProps) => {
|
||||
@@ -17,6 +18,7 @@ export const LockButton = (props: LockButtonProps) => {
|
||||
type="toggle"
|
||||
icon={props.checked ? LockedIcon : UnlockedIcon}
|
||||
checked={props.checked}
|
||||
disabled={props.disabled}
|
||||
title={`${props.title} — Q`}
|
||||
aria-label={`${props.title}`}
|
||||
data-testid="toolbar-lock"
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
FreedrawToolButton,
|
||||
HandToolButton,
|
||||
ImageToolButton,
|
||||
isToolButtonDisabled,
|
||||
SelectionToolPopover,
|
||||
TextToolButton,
|
||||
TOOLS,
|
||||
@@ -246,6 +247,7 @@ export const MobileToolbar = ({ app, setAppState }: MobileToolbarProps) => {
|
||||
shortcut={KEYS.T.toLocaleUpperCase()}
|
||||
data-testid="toolbar-text"
|
||||
selected={activeTool.type === "text"}
|
||||
disabled={isToolButtonDisabled(app, "text")}
|
||||
>
|
||||
{t("toolBar.text")}
|
||||
</DropdownMenu.Item>
|
||||
@@ -257,6 +259,7 @@ export const MobileToolbar = ({ app, setAppState }: MobileToolbarProps) => {
|
||||
icon={ImageIcon}
|
||||
data-testid="toolbar-image"
|
||||
selected={activeTool.type === "image"}
|
||||
disabled={isToolButtonDisabled(app, "image")}
|
||||
>
|
||||
{t("toolBar.image")}
|
||||
</DropdownMenu.Item>
|
||||
@@ -268,6 +271,7 @@ export const MobileToolbar = ({ app, setAppState }: MobileToolbarProps) => {
|
||||
shortcut={KEYS.F.toLocaleUpperCase()}
|
||||
data-testid="toolbar-frame"
|
||||
selected={frameToolSelected}
|
||||
disabled={isToolButtonDisabled(app, "frame")}
|
||||
>
|
||||
{t("toolBar.frame")}
|
||||
</DropdownMenu.Item>
|
||||
@@ -277,6 +281,7 @@ export const MobileToolbar = ({ app, setAppState }: MobileToolbarProps) => {
|
||||
icon={EmbedIcon}
|
||||
data-testid="toolbar-embeddable"
|
||||
selected={embeddableToolSelected}
|
||||
disabled={isToolButtonDisabled(app, "embeddable")}
|
||||
>
|
||||
{t("toolBar.embeddable")}
|
||||
</DropdownMenu.Item>
|
||||
@@ -286,6 +291,7 @@ export const MobileToolbar = ({ app, setAppState }: MobileToolbarProps) => {
|
||||
data-testid="toolbar-laser"
|
||||
selected={laserToolSelected}
|
||||
shortcut={KEYS.K.toLocaleUpperCase()}
|
||||
disabled={isToolButtonDisabled(app, "laser")}
|
||||
>
|
||||
{t("toolBar.laser")}
|
||||
</DropdownMenu.Item>
|
||||
@@ -307,6 +313,7 @@ export const MobileToolbar = ({ app, setAppState }: MobileToolbarProps) => {
|
||||
icon={MagicIcon}
|
||||
data-testid="toolbar-magicframe"
|
||||
badge={<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>}
|
||||
disabled={isToolButtonDisabled(app, "magicframe")}
|
||||
>
|
||||
{t("toolBar.magicframe")}
|
||||
</DropdownMenu.Item>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Popover } from "radix-ui";
|
||||
import { trackEvent } from "../analytics";
|
||||
|
||||
import { IconButton } from "./IconButton";
|
||||
import { isToolButtonDisabled } from "./Tools";
|
||||
|
||||
import "./ToolPopover.scss";
|
||||
|
||||
@@ -69,6 +70,9 @@ export const ToolPopover = ({
|
||||
type="toggle"
|
||||
icon={displayedOption.icon}
|
||||
checked={isActive}
|
||||
disabled={options.every((option) =>
|
||||
isToolButtonDisabled(app, option.type),
|
||||
)}
|
||||
title={capitalizeString(displayedOption.title)}
|
||||
aria-label={capitalizeString(displayedOption.title)}
|
||||
data-testid={dataTestId}
|
||||
@@ -91,6 +95,7 @@ export const ToolPopover = ({
|
||||
type="toggle"
|
||||
icon={icon}
|
||||
checked={currentType === type}
|
||||
disabled={isToolButtonDisabled(app, type)}
|
||||
title={capitalizeString(type)}
|
||||
aria-label={capitalizeString(type)}
|
||||
data-testid={`toolbar-${type}`}
|
||||
|
||||
@@ -47,10 +47,11 @@
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
// same state colors as checked tool buttons (`ToolIcon--checked`)
|
||||
&--selected,
|
||||
&--selected:hover {
|
||||
background: var(--color-primary-light);
|
||||
color: var(--color-primary);
|
||||
background: var(--color-surface-primary-container);
|
||||
color: var(--color-on-primary-container);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
FreedrawToolButton,
|
||||
HandToolButton,
|
||||
ImageToolButton,
|
||||
isToolButtonDisabled,
|
||||
LassoToolButton,
|
||||
LineToolButton,
|
||||
RectangleToolButton,
|
||||
@@ -106,6 +107,7 @@ const ExtraToolsDropdown = ({
|
||||
shortcut={KEYS.F.toLocaleUpperCase()}
|
||||
data-testid="toolbar-frame"
|
||||
selected={frameToolSelected}
|
||||
disabled={isToolButtonDisabled(app, "frame")}
|
||||
>
|
||||
{t("toolBar.frame")}
|
||||
</DropdownMenu.Item>
|
||||
@@ -114,6 +116,7 @@ const ExtraToolsDropdown = ({
|
||||
icon={EmbedIcon}
|
||||
data-testid="toolbar-embeddable"
|
||||
selected={embeddableToolSelected}
|
||||
disabled={isToolButtonDisabled(app, "embeddable")}
|
||||
>
|
||||
{t("toolBar.embeddable")}
|
||||
</DropdownMenu.Item>
|
||||
@@ -123,6 +126,7 @@ const ExtraToolsDropdown = ({
|
||||
data-testid="toolbar-laser"
|
||||
selected={laserToolSelected}
|
||||
shortcut={KEYS.K.toLocaleUpperCase()}
|
||||
disabled={isToolButtonDisabled(app, "laser")}
|
||||
>
|
||||
{t("toolBar.laser")}
|
||||
</DropdownMenu.Item>
|
||||
@@ -132,6 +136,7 @@ const ExtraToolsDropdown = ({
|
||||
icon={LassoIcon}
|
||||
data-testid="toolbar-lasso"
|
||||
selected={lassoToolSelected}
|
||||
disabled={isToolButtonDisabled(app, "lasso")}
|
||||
>
|
||||
{t("toolBar.lasso")}
|
||||
</DropdownMenu.Item>
|
||||
@@ -153,6 +158,7 @@ const ExtraToolsDropdown = ({
|
||||
icon={MagicIcon}
|
||||
data-testid="toolbar-magicframe"
|
||||
badge={<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>}
|
||||
disabled={isToolButtonDisabled(app, "magicframe")}
|
||||
>
|
||||
{t("toolBar.magicframe")}
|
||||
</DropdownMenu.Item>
|
||||
@@ -213,13 +219,19 @@ export const Toolbar = ({
|
||||
penDetected={appState.penDetected}
|
||||
/>
|
||||
)}
|
||||
<LockButton
|
||||
checked={appState.activeTool.locked}
|
||||
onChange={onLockToggle}
|
||||
title={t("toolBar.lock")}
|
||||
/>
|
||||
{app.props.activeTool == null && (
|
||||
<>
|
||||
<LockButton
|
||||
checked={appState.activeTool.locked}
|
||||
onChange={onLockToggle}
|
||||
title={t("toolBar.lock")}
|
||||
// the active tool — including its lock state — is host-controlled
|
||||
disabled={app.props.activeTool != null}
|
||||
/>
|
||||
|
||||
<div className="App-toolbar__divider" />
|
||||
<div className="App-toolbar__divider" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<HandToolButton {...toolProps} hideKeyBinding />
|
||||
{isCompactStylesPanel ? (
|
||||
|
||||
@@ -189,6 +189,14 @@ export const findShapeByKey = (key: string, app: AppClassProperties) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a toolbar entry activating the given tool renders disabled — true
|
||||
* when the active tool is host-controlled (`props.activeTool`) and the entry
|
||||
* doesn't activate the forced tool (`setActiveTool` refuses it).
|
||||
*/
|
||||
export const isToolButtonDisabled = (app: AppClassProperties, type: string) =>
|
||||
app.props.activeTool != null && app.props.activeTool.type !== type;
|
||||
|
||||
export type ToolButtonComponentProps = {
|
||||
app: AppClassProperties;
|
||||
activeTool: UIAppState["activeTool"];
|
||||
@@ -245,6 +253,7 @@ const createToolButton = (
|
||||
type="toggle"
|
||||
icon={config.icon}
|
||||
checked={activeTool.type === type}
|
||||
disabled={isToolButtonDisabled(app, type)}
|
||||
title={shortcut ? `${label} — ${shortcut}` : label}
|
||||
keyBindingLabel={
|
||||
hideKeyBinding || hideShortcut
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
|
||||
import {
|
||||
CURSOR_TYPE,
|
||||
isShallowEqual,
|
||||
sceneCoordsToViewportCoords,
|
||||
type EditorInterface,
|
||||
@@ -203,15 +202,12 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
|
||||
return (
|
||||
<canvas
|
||||
className="excalidraw__canvas interactive"
|
||||
// NOTE no `cursor` style here — the cursor is managed imperatively
|
||||
// (see `AppCursor`); an inline style would clobber it whenever its
|
||||
// computed value changes across rerenders
|
||||
style={{
|
||||
width: props.appState.width,
|
||||
height: props.appState.height,
|
||||
cursor:
|
||||
props.navigationEnabled &&
|
||||
props.appState.viewModeEnabled &&
|
||||
props.appState.activeTool.type !== "laser"
|
||||
? CURSOR_TYPE.GRAB
|
||||
: CURSOR_TYPE.AUTO,
|
||||
}}
|
||||
width={props.appState.width * props.scale}
|
||||
height={props.appState.height * props.scale}
|
||||
|
||||
@@ -644,6 +644,13 @@ body.excalidraw-cursor-resize * {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
// with the active tool allowed (`interaction.enabled.tools`), touch
|
||||
// input drives the tool again — e.g. laser strokes must not scroll the
|
||||
// page
|
||||
&.excalidraw--tools canvas {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
// override inline pointer-events set when embeddable is active
|
||||
// (also covers custom `renderEmbeddable` content) — unless embeds are
|
||||
// an allowed interaction, in which case the inline (activation-driven)
|
||||
|
||||
@@ -79,6 +79,29 @@ $color-blue-8: #1971c2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// disabled toggle buttons (e.g. non-forced tools while `props.activeTool`
|
||||
// is set) — greyed out, without hover/active affordances
|
||||
&.ToolIcon_type_toggle:disabled {
|
||||
cursor: default;
|
||||
--keybinding-color: var(--color-disabled);
|
||||
|
||||
.ToolIcon__icon {
|
||||
svg {
|
||||
color: var(--color-disabled);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
background: initial;
|
||||
border: none;
|
||||
|
||||
svg {
|
||||
color: var(--color-disabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@mixin outlineButtonStyles {
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import { CURSOR_TYPE, MIME_TYPES, THEME } from "@excalidraw/common";
|
||||
|
||||
import { isHandToolActive, isEraserActive } from "./appState";
|
||||
|
||||
import type { AppState, DataURL } from "./types";
|
||||
|
||||
const laserPointerCursorSVG_tag = `<svg viewBox="0 0 24 24" stroke-width="1" width="28" height="28" xmlns="http://www.w3.org/2000/svg">`;
|
||||
const laserPointerCursorBackgroundSVG = `<path d="M6.164 11.755a5.314 5.314 0 0 1-4.932-5.298 5.314 5.314 0 0 1 5.311-5.311 5.314 5.314 0 0 1 5.307 5.113l8.773 8.773a3.322 3.322 0 0 1 0 4.696l-.895.895a3.322 3.322 0 0 1-4.696 0l-8.868-8.868Z" style="fill:#fff"/>`;
|
||||
const laserPointerCursorIconSVG = `<path stroke="#1b1b1f" fill="#fff" d="m7.868 11.113 7.773 7.774a2.359 2.359 0 0 0 1.667.691 2.368 2.368 0 0 0 2.357-2.358c0-.625-.248-1.225-.69-1.667L11.201 7.78 9.558 9.469l-1.69 1.643v.001Zm10.273 3.606-3.333 3.333m-3.25-6.583 2 2m-7-7 3 3M3.664 3.625l1 1M2.529 6.922l1.407-.144m5.735-2.932-1.118.866M4.285 9.823l.758-1.194m1.863-6.207-.13 1.408"/>`;
|
||||
|
||||
const laserPointerCursorDataURL_lightMode = `data:${
|
||||
MIME_TYPES.svg
|
||||
},${encodeURIComponent(
|
||||
`${laserPointerCursorSVG_tag}${laserPointerCursorIconSVG}</svg>`,
|
||||
)}`;
|
||||
const laserPointerCursorDataURL_darkMode = `data:${
|
||||
MIME_TYPES.svg
|
||||
},${encodeURIComponent(
|
||||
`${laserPointerCursorSVG_tag}${laserPointerCursorBackgroundSVG}${laserPointerCursorIconSVG}</svg>`,
|
||||
)}`;
|
||||
|
||||
export const resetCursor = (interactiveCanvas: HTMLCanvasElement | null) => {
|
||||
if (interactiveCanvas) {
|
||||
interactiveCanvas.style.cursor = "";
|
||||
}
|
||||
};
|
||||
|
||||
export const setCursor = (
|
||||
interactiveCanvas: HTMLCanvasElement | null,
|
||||
cursor: string,
|
||||
) => {
|
||||
if (interactiveCanvas) {
|
||||
interactiveCanvas.style.cursor = cursor;
|
||||
}
|
||||
};
|
||||
|
||||
let eraserCanvasCache: any;
|
||||
let previewDataURL: string;
|
||||
export const setEraserCursor = (
|
||||
interactiveCanvas: HTMLCanvasElement | null,
|
||||
theme: AppState["theme"],
|
||||
) => {
|
||||
const cursorImageSizePx = 20;
|
||||
|
||||
const drawCanvas = () => {
|
||||
const isDarkTheme = theme === THEME.DARK;
|
||||
eraserCanvasCache = document.createElement("canvas");
|
||||
eraserCanvasCache.theme = theme;
|
||||
eraserCanvasCache.height = cursorImageSizePx;
|
||||
eraserCanvasCache.width = cursorImageSizePx;
|
||||
const context = eraserCanvasCache.getContext("2d")!;
|
||||
context.lineWidth = 1;
|
||||
context.beginPath();
|
||||
context.arc(
|
||||
eraserCanvasCache.width / 2,
|
||||
eraserCanvasCache.height / 2,
|
||||
5,
|
||||
0,
|
||||
2 * Math.PI,
|
||||
);
|
||||
context.fillStyle = isDarkTheme ? "#000" : "#fff";
|
||||
context.fill();
|
||||
context.strokeStyle = isDarkTheme ? "#fff" : "#000";
|
||||
context.stroke();
|
||||
previewDataURL = eraserCanvasCache.toDataURL(MIME_TYPES.svg) as DataURL;
|
||||
};
|
||||
if (!eraserCanvasCache || eraserCanvasCache.theme !== theme) {
|
||||
drawCanvas();
|
||||
}
|
||||
|
||||
setCursor(
|
||||
interactiveCanvas,
|
||||
`url(${previewDataURL}) ${cursorImageSizePx / 2} ${
|
||||
cursorImageSizePx / 2
|
||||
}, auto`,
|
||||
);
|
||||
};
|
||||
|
||||
export const setCursorForShape = (
|
||||
interactiveCanvas: HTMLCanvasElement | null,
|
||||
appState: Pick<AppState, "activeTool" | "theme">,
|
||||
) => {
|
||||
if (!interactiveCanvas) {
|
||||
return;
|
||||
}
|
||||
if (appState.activeTool.type === "selection") {
|
||||
resetCursor(interactiveCanvas);
|
||||
} else if (isHandToolActive(appState)) {
|
||||
interactiveCanvas.style.cursor = CURSOR_TYPE.GRAB;
|
||||
} else if (isEraserActive(appState)) {
|
||||
setEraserCursor(interactiveCanvas, appState.theme);
|
||||
// do nothing if image tool is selected which suggests there's
|
||||
// a image-preview set as the cursor
|
||||
// Ignore custom type as well and let host decide
|
||||
} else if (appState.activeTool.type === "laser") {
|
||||
const url =
|
||||
appState.theme === THEME.LIGHT
|
||||
? laserPointerCursorDataURL_lightMode
|
||||
: laserPointerCursorDataURL_darkMode;
|
||||
interactiveCanvas.style.cursor = `url(${url}), auto`;
|
||||
} else if (!["image", "custom"].includes(appState.activeTool.type)) {
|
||||
interactiveCanvas.style.cursor = CURSOR_TYPE.CROSSHAIR;
|
||||
} else if (appState.activeTool.type !== "image") {
|
||||
interactiveCanvas.style.cursor = CURSOR_TYPE.AUTO;
|
||||
}
|
||||
};
|
||||
@@ -84,6 +84,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
||||
viewModeEnabled,
|
||||
interaction,
|
||||
ui,
|
||||
activeTool,
|
||||
zenModeEnabled,
|
||||
gridModeEnabled,
|
||||
libraryReturnUrl,
|
||||
@@ -219,6 +220,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
||||
viewModeEnabled={viewModeEnabled}
|
||||
interaction={interaction}
|
||||
ui={ui}
|
||||
activeTool={activeTool}
|
||||
zenModeEnabled={zenModeEnabled}
|
||||
gridModeEnabled={gridModeEnabled}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
@@ -263,6 +265,7 @@ const areEqual = (prevProps: ExcalidrawProps, nextProps: ExcalidrawProps) => {
|
||||
UIOptions: prevUIOptions = {},
|
||||
imageOptions: prevImageOptions,
|
||||
interaction: prevInteraction,
|
||||
activeTool: prevActiveTool,
|
||||
...prev
|
||||
} = prevProps;
|
||||
const {
|
||||
@@ -270,9 +273,22 @@ const areEqual = (prevProps: ExcalidrawProps, nextProps: ExcalidrawProps) => {
|
||||
UIOptions: nextUIOptions = {},
|
||||
imageOptions: nextImageOptions,
|
||||
interaction: nextInteraction,
|
||||
activeTool: nextActiveTool,
|
||||
...next
|
||||
} = nextProps;
|
||||
|
||||
// compare `activeTool` semantically so that hosts inlining the object
|
||||
// (`activeTool={{ type: "laser" }}`) don't bust the memo every render
|
||||
const isActiveToolSame =
|
||||
prevActiveTool === nextActiveTool ||
|
||||
(prevActiveTool?.type === nextActiveTool?.type &&
|
||||
(prevActiveTool?.type === "custom" ? prevActiveTool.customType : null) ===
|
||||
(nextActiveTool?.type === "custom" ? nextActiveTool.customType : null));
|
||||
|
||||
if (!isActiveToolSame) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// compare `interaction` semantically so that hosts inlining the config
|
||||
// object (`interaction={{ enabled: { links: true } }}`) don't bust the
|
||||
// memo every render
|
||||
@@ -289,7 +305,11 @@ const areEqual = (prevProps: ExcalidrawProps, nextProps: ExcalidrawProps) => {
|
||||
!!prevInteraction.enabled?.navigation ===
|
||||
!!nextInteraction.enabled?.navigation &&
|
||||
!!prevInteraction.enabled?.browserZoom ===
|
||||
!!nextInteraction.enabled?.browserZoom);
|
||||
!!nextInteraction.enabled?.browserZoom &&
|
||||
!!prevInteraction.enabled?.tools?.laser ===
|
||||
!!nextInteraction.enabled?.tools?.laser &&
|
||||
!!prevInteraction.enabled?.tools?.custom ===
|
||||
!!nextInteraction.enabled?.tools?.custom);
|
||||
|
||||
if (!isInteractionSame) {
|
||||
return false;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { CODES, CURSOR_TYPE } from "@excalidraw/common";
|
||||
import { CODES, CURSOR_TYPE, POINTER_BUTTON } from "@excalidraw/common";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
@@ -202,6 +202,11 @@ describe("interaction={false}", () => {
|
||||
|
||||
pressZoomShortcut(CODES.ZERO);
|
||||
expect(h.state.zoom.value).toBe(zoom);
|
||||
|
||||
// page-scroll keys are inert as well (navigation-only)
|
||||
const { scrollY } = h.state;
|
||||
fireEvent.keyDown(document, { key: "PageDown", code: "PageDown" });
|
||||
expect(h.state.scrollY).toBe(scrollY);
|
||||
});
|
||||
|
||||
it("pointer down+drag neither selects nor pans", () => {
|
||||
@@ -995,6 +1000,11 @@ describe("interaction={{ enabled: { navigation } }}", () => {
|
||||
});
|
||||
|
||||
it("pointer drag pans without selecting", () => {
|
||||
// the grab cursor applies from mount (managed imperatively)
|
||||
expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(
|
||||
CURSOR_TYPE.GRAB,
|
||||
);
|
||||
|
||||
const { scrollX, scrollY } = h.state;
|
||||
|
||||
mouse.downAt(80, 80);
|
||||
@@ -1005,6 +1015,25 @@ describe("interaction={{ enabled: { navigation } }}", () => {
|
||||
expect(h.state.selectedElementIds).toEqual({});
|
||||
});
|
||||
|
||||
it("supports page-scroll keys (PageUp/PageDown)", () => {
|
||||
const { scrollY } = h.state;
|
||||
fireEvent.keyDown(document, { key: "PageDown", code: "PageDown" });
|
||||
expect(h.state.scrollY).toBeLessThan(scrollY);
|
||||
|
||||
fireEvent.keyDown(document, { key: "PageUp", code: "PageUp" });
|
||||
expect(h.state.scrollY).toBe(scrollY);
|
||||
|
||||
// shift scrolls horizontally
|
||||
const { scrollX } = h.state;
|
||||
fireEvent.keyDown(document, {
|
||||
key: "PageDown",
|
||||
code: "PageDown",
|
||||
shiftKey: true,
|
||||
});
|
||||
expect(h.state.scrollX).toBeLessThan(scrollX);
|
||||
expect(h.state.scrollY).toBe(scrollY);
|
||||
});
|
||||
|
||||
it("supports canvas zoom & zoom-to-fit keyboard shortcuts", () => {
|
||||
// ctrl+"=" zooms in (and is prevented from zooming the browser)
|
||||
const zoom = h.state.zoom.value;
|
||||
@@ -1192,3 +1221,329 @@ describe("interaction={{ enabled: { embeds / interactiveContent } }}", () => {
|
||||
expect(h.app.isEmbedsEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("interaction={{ enabled: { tools } }}", () => {
|
||||
const onPointerDownSpy = vi.fn();
|
||||
const onPointerUpSpy = vi.fn();
|
||||
const onPointerUpdateSpy = vi.fn();
|
||||
|
||||
const renderWithInteraction = async (
|
||||
interaction: ExcalidrawProps["interaction"],
|
||||
) => {
|
||||
await render(
|
||||
<Excalidraw
|
||||
interaction={interaction}
|
||||
autoFocus={true}
|
||||
handleKeyboardGlobally={true}
|
||||
onPointerDown={onPointerDownSpy}
|
||||
onPointerUp={onPointerUpSpy}
|
||||
onPointerUpdate={onPointerUpdateSpy}
|
||||
initialData={{
|
||||
elements: [
|
||||
API.createElement({
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 50,
|
||||
height: 50,
|
||||
}),
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(h.state.width).toBe(200));
|
||||
Object.assign(document, {
|
||||
elementFromPoint: () => GlobalTestState.canvas,
|
||||
});
|
||||
};
|
||||
|
||||
const rerenderWithInteraction = (
|
||||
interaction: ExcalidrawProps["interaction"],
|
||||
) => {
|
||||
GlobalTestState.renderResult.rerender(
|
||||
<Excalidraw
|
||||
interaction={interaction}
|
||||
autoFocus={true}
|
||||
handleKeyboardGlobally={true}
|
||||
onPointerDown={onPointerDownSpy}
|
||||
onPointerUp={onPointerUpSpy}
|
||||
onPointerUpdate={onPointerUpdateSpy}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
onPointerDownSpy.mockClear();
|
||||
onPointerUpSpy.mockClear();
|
||||
onPointerUpdateSpy.mockClear();
|
||||
mockBoundingClientRect();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreOriginalGetBoundingClientRect();
|
||||
});
|
||||
|
||||
it("laser: pointer strokes draw the laser trail without editing effects", async () => {
|
||||
await renderWithInteraction({ enabled: { tools: { laser: true } } });
|
||||
|
||||
act(() => {
|
||||
h.app.setActiveTool({ type: "laser" });
|
||||
});
|
||||
expect(h.app.isToolSupported(h.state.activeTool.type)).toBe(true);
|
||||
expect(h.state.viewModeEnabled).toBe(true);
|
||||
expect(h.app.isInteractionEnabled()).toBe(false);
|
||||
|
||||
const { scrollX, scrollY } = h.state;
|
||||
|
||||
// stroke starting on top of the rectangle
|
||||
mouse.downAt(30, 30);
|
||||
expect(h.app.laserTrails.localTrail.hasCurrentTrail).toBe(true);
|
||||
expect(onPointerDownSpy).toHaveBeenCalledTimes(1);
|
||||
expect(onPointerDownSpy.mock.calls[0][0]).toMatchObject({ type: "laser" });
|
||||
|
||||
mouse.moveTo(80, 80);
|
||||
mouse.upAt(80, 80);
|
||||
expect(h.app.laserTrails.localTrail.hasCurrentTrail).toBe(false);
|
||||
|
||||
// no selection, no panning, no scene changes
|
||||
expect(h.state.selectedElementIds).toEqual({});
|
||||
expect([h.state.scrollX, h.state.scrollY]).toEqual([scrollX, scrollY]);
|
||||
expect(h.elements.length).toBe(1);
|
||||
});
|
||||
|
||||
it("laser: pointer positions broadcast via onPointerUpdate", async () => {
|
||||
await renderWithInteraction({ enabled: { tools: { laser: true } } });
|
||||
|
||||
act(() => {
|
||||
h.app.setActiveTool({ type: "laser" });
|
||||
});
|
||||
|
||||
// between strokes (plain hover)
|
||||
mouse.moveTo(50, 50);
|
||||
expect(onPointerUpdateSpy).toHaveBeenCalled();
|
||||
expect(onPointerUpdateSpy.mock.calls.at(-1)![0].pointer.tool).toBe("laser");
|
||||
|
||||
// during a stroke
|
||||
onPointerUpdateSpy.mockClear();
|
||||
mouse.downAt(30, 30);
|
||||
mouse.moveTo(60, 60);
|
||||
mouse.upAt(60, 60);
|
||||
expect(
|
||||
onPointerUpdateSpy.mock.calls.some(
|
||||
([payload]) => payload.button === "down",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(onPointerUpdateSpy.mock.calls.at(-1)![0].button).toBe("up");
|
||||
});
|
||||
|
||||
it("custom: onPointerDown/onPointerUp keep dispatching when enabled", async () => {
|
||||
await renderWithInteraction({ enabled: { tools: { custom: true } } });
|
||||
|
||||
act(() => {
|
||||
h.app.setActiveTool({
|
||||
type: "custom",
|
||||
customType: "comment",
|
||||
locked: true,
|
||||
});
|
||||
});
|
||||
expect(h.app.isToolSupported(h.state.activeTool.type)).toBe(true);
|
||||
|
||||
mouse.downAt(40, 40);
|
||||
expect(onPointerDownSpy).toHaveBeenCalledTimes(1);
|
||||
expect(onPointerDownSpy.mock.calls[0][0]).toMatchObject({
|
||||
type: "custom",
|
||||
customType: "comment",
|
||||
});
|
||||
|
||||
mouse.upAt(40, 40);
|
||||
expect(onPointerUpSpy).toHaveBeenCalledTimes(1);
|
||||
expect(onPointerUpSpy.mock.calls[0][0]).toMatchObject({
|
||||
type: "custom",
|
||||
customType: "comment",
|
||||
});
|
||||
|
||||
// tool stays active (activated with `locked: true`), scene untouched
|
||||
expect(h.state.activeTool.type).toBe("custom");
|
||||
expect(h.state.selectedElementIds).toEqual({});
|
||||
expect(h.elements.length).toBe(1);
|
||||
});
|
||||
|
||||
it("pointer input stays inert when the active tool is not enabled", async () => {
|
||||
await renderWithInteraction({ enabled: { tools: { laser: true } } });
|
||||
|
||||
// default (selection) tool is not in the enabled set
|
||||
expect(h.state.activeTool.type).toBe("selection");
|
||||
expect(h.app.isToolSupported(h.state.activeTool.type)).toBe(false);
|
||||
|
||||
mouse.downAt(30, 30);
|
||||
mouse.moveTo(80, 80);
|
||||
mouse.upAt(80, 80);
|
||||
expect(h.state.selectedElementIds).toEqual({});
|
||||
expect(onPointerDownSpy).not.toHaveBeenCalled();
|
||||
expect(onPointerUpdateSpy).not.toHaveBeenCalled();
|
||||
|
||||
// a custom tool isn't covered by `tools.laser` — the switch itself is
|
||||
// refused
|
||||
act(() => {
|
||||
h.app.setActiveTool({
|
||||
type: "custom",
|
||||
customType: "comment",
|
||||
locked: true,
|
||||
});
|
||||
});
|
||||
expect(h.state.activeTool.type).toBe("selection");
|
||||
expect(h.app.isToolSupported(h.state.activeTool.type)).toBe(false);
|
||||
mouse.reset();
|
||||
mouse.downAt(40, 40);
|
||||
expect(onPointerDownSpy).not.toHaveBeenCalled();
|
||||
expect(h.app.laserTrails.localTrail.hasCurrentTrail).toBe(false);
|
||||
});
|
||||
|
||||
it("setActiveTool only activates enabled tools while non-interactive", async () => {
|
||||
await renderWithInteraction({ enabled: { tools: { laser: true } } });
|
||||
|
||||
// enabled tool activates
|
||||
act(() => {
|
||||
h.app.setActiveTool({ type: "laser" });
|
||||
});
|
||||
expect(h.state.activeTool.type).toBe("laser");
|
||||
|
||||
// tools outside the enabled set are refused
|
||||
act(() => {
|
||||
h.app.setActiveTool({ type: "rectangle" });
|
||||
});
|
||||
expect(h.state.activeTool.type).toBe("laser");
|
||||
|
||||
act(() => {
|
||||
h.app.setActiveTool({ type: "hand" });
|
||||
});
|
||||
expect(h.state.activeTool.type).toBe("laser");
|
||||
});
|
||||
|
||||
it("active tool resets to selection when it loses its interaction exception", async () => {
|
||||
await renderWithInteraction({ enabled: { tools: { laser: true } } });
|
||||
|
||||
act(() => {
|
||||
h.app.setActiveTool({ type: "laser" });
|
||||
});
|
||||
expect(h.state.activeTool.type).toBe("laser");
|
||||
|
||||
// exception removed (e.g. presenter → viewer) → neutral default
|
||||
rerenderWithInteraction(false);
|
||||
expect(h.state.activeTool.type).toBe("selection");
|
||||
expect(queryContainer(".excalidraw--tools")).toBe(null);
|
||||
|
||||
// re-enabling the exception doesn't restore the tool — tool selection
|
||||
// stays host-driven
|
||||
rerenderWithInteraction({ enabled: { tools: { laser: true } } });
|
||||
expect(h.state.activeTool.type).toBe("selection");
|
||||
});
|
||||
|
||||
it("interaction={false} refuses tool switching and keeps the laser inert", async () => {
|
||||
await renderWithInteraction(false);
|
||||
|
||||
act(() => {
|
||||
h.app.setActiveTool({ type: "laser" });
|
||||
});
|
||||
// the switch is refused — no tool is enabled while fully non-interactive
|
||||
expect(h.state.activeTool.type).toBe("selection");
|
||||
expect(h.app.isToolSupported(h.state.activeTool.type)).toBe(false);
|
||||
|
||||
mouse.downAt(30, 30);
|
||||
mouse.moveTo(80, 80);
|
||||
mouse.upAt(80, 80);
|
||||
expect(h.app.laserTrails.localTrail.hasCurrentTrail).toBe(false);
|
||||
expect(onPointerDownSpy).not.toHaveBeenCalled();
|
||||
expect(onPointerUpdateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("container gets the excalidraw--tools class only while the active tool is enabled", async () => {
|
||||
await renderWithInteraction({ enabled: { tools: { laser: true } } });
|
||||
|
||||
// selection active — not in the enabled set
|
||||
expect(queryContainer(".excalidraw--tools")).toBe(null);
|
||||
|
||||
act(() => {
|
||||
h.app.setActiveTool({ type: "laser" });
|
||||
});
|
||||
expect(queryContainer(".excalidraw--tools")).not.toBe(null);
|
||||
|
||||
rerenderWithInteraction(false);
|
||||
expect(queryContainer(".excalidraw--tools")).toBe(null);
|
||||
});
|
||||
|
||||
it("editor is otherwise inert (keyboard, context menu, wheel, eraser button)", async () => {
|
||||
await renderWithInteraction({ enabled: { tools: { laser: true } } });
|
||||
|
||||
act(() => {
|
||||
h.app.setActiveTool({ type: "laser" });
|
||||
});
|
||||
|
||||
// letter shortcuts don't switch tools
|
||||
fireEvent.keyDown(document, { key: "r", code: "KeyR" });
|
||||
fireEvent.keyDown(document, { key: "v", code: "KeyV" });
|
||||
expect(h.state.activeTool.type).toBe("laser");
|
||||
|
||||
// the pen's hardware eraser button doesn't switch tools either
|
||||
fireEvent.pointerDown(GlobalTestState.interactiveCanvas, {
|
||||
button: POINTER_BUTTON.ERASER,
|
||||
clientX: 30,
|
||||
clientY: 30,
|
||||
});
|
||||
expect(h.state.activeTool.type).toBe("laser");
|
||||
fireEvent.pointerUp(GlobalTestState.interactiveCanvas, {
|
||||
button: POINTER_BUTTON.ERASER,
|
||||
clientX: 30,
|
||||
clientY: 30,
|
||||
});
|
||||
|
||||
rightClickCanvas();
|
||||
expect(h.state.contextMenu).toBe(null);
|
||||
|
||||
// wheel stays with the page (no navigation enabled)
|
||||
const { scrollX, scrollY } = h.state;
|
||||
const zoom = h.state.zoom.value;
|
||||
wheelPan();
|
||||
wheelZoom();
|
||||
expect([h.state.scrollX, h.state.scrollY]).toEqual([scrollX, scrollY]);
|
||||
expect(h.state.zoom.value).toBe(zoom);
|
||||
});
|
||||
|
||||
it("composes with navigation: drag draws the laser, wheel pans", async () => {
|
||||
await renderWithInteraction({
|
||||
enabled: { navigation: true, tools: { laser: true } },
|
||||
});
|
||||
|
||||
act(() => {
|
||||
h.app.setActiveTool({ type: "laser" });
|
||||
});
|
||||
|
||||
wheelPan();
|
||||
const { scrollX, scrollY } = h.state;
|
||||
expect([scrollX, scrollY]).not.toEqual([0, 0]);
|
||||
|
||||
// primary-pointer drag goes to the laser, not panning
|
||||
mouse.downAt(30, 30);
|
||||
expect(h.app.laserTrails.localTrail.hasCurrentTrail).toBe(true);
|
||||
mouse.moveTo(80, 80);
|
||||
mouse.upAt(80, 80);
|
||||
expect([h.state.scrollX, h.state.scrollY]).toEqual([scrollX, scrollY]);
|
||||
});
|
||||
|
||||
it("disabling the tool exception mid-stroke ends the trail", async () => {
|
||||
await renderWithInteraction({ enabled: { tools: { laser: true } } });
|
||||
|
||||
act(() => {
|
||||
h.app.setActiveTool({ type: "laser" });
|
||||
});
|
||||
|
||||
mouse.downAt(30, 30);
|
||||
expect(h.app.laserTrails.localTrail.hasCurrentTrail).toBe(true);
|
||||
|
||||
rerenderWithInteraction(false);
|
||||
expect(h.app.laserTrails.localTrail.hasCurrentTrail).toBe(false);
|
||||
|
||||
mouse.upAt(30, 30);
|
||||
expect(h.app.laserTrails.localTrail.hasCurrentTrail).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import React from "react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { resolvablePromise } from "@excalidraw/common";
|
||||
import {
|
||||
CURSOR_TYPE,
|
||||
resolvablePromise,
|
||||
updateActiveTool,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
|
||||
import { findShapeByKey } from "../components/Tools";
|
||||
|
||||
import { API } from "./helpers/api";
|
||||
import { Pointer } from "./helpers/ui";
|
||||
import { act, render } from "./test-utils";
|
||||
import { act, fireEvent, GlobalTestState, render } from "./test-utils";
|
||||
|
||||
import type { AppClassProperties, ExcalidrawImperativeAPI } from "../types";
|
||||
|
||||
@@ -102,3 +108,227 @@ describe("findShapeByKey()", () => {
|
||||
expect(findShapeByKey("X", app)).toBe("freedraw");
|
||||
});
|
||||
});
|
||||
|
||||
describe("props.activeTool (forced tool)", () => {
|
||||
const h = window.h;
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
const queryToolButton = (type: string) =>
|
||||
GlobalTestState.renderResult.container.querySelector<HTMLButtonElement>(
|
||||
`[data-testid="toolbar-${type}"]`,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
mouse.reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("forces the tool from the first render, behaving as locked", async () => {
|
||||
await render(<Excalidraw activeTool={{ type: "rectangle" }} />);
|
||||
expect(h.state.activeTool.type).toBe("rectangle");
|
||||
// forcing behaves as locked without mutating the user's padlock state
|
||||
expect(h.state.activeTool.locked).toBe(false);
|
||||
// the cursor reflects the forced tool without any pointer interaction
|
||||
expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(
|
||||
CURSOR_TYPE.CROSSHAIR,
|
||||
);
|
||||
|
||||
// drawing doesn't revert to selection, and the drawn element isn't
|
||||
// auto-selected (locked-tool behavior)
|
||||
mouse.down(10, 10);
|
||||
mouse.up(20, 20);
|
||||
expect(h.state.activeTool.type).toBe("rectangle");
|
||||
expect(h.elements.length).toBe(1);
|
||||
expect(h.state.selectedElementIds).toEqual({});
|
||||
});
|
||||
|
||||
it("does not clobber the user's padlock preference", async () => {
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
|
||||
// user locks the tool (Q)
|
||||
fireEvent.keyDown(document, { key: "q", code: "KeyQ" });
|
||||
expect(h.state.activeTool.locked).toBe(true);
|
||||
|
||||
// forcing & unforcing leaves the preference alone
|
||||
GlobalTestState.renderResult.rerender(
|
||||
<Excalidraw
|
||||
activeTool={{ type: "freedraw" }}
|
||||
handleKeyboardGlobally={true}
|
||||
/>,
|
||||
);
|
||||
expect(h.state.activeTool.type).toBe("freedraw");
|
||||
expect(h.state.activeTool.locked).toBe(true);
|
||||
|
||||
GlobalTestState.renderResult.rerender(
|
||||
<Excalidraw handleKeyboardGlobally={true} />,
|
||||
);
|
||||
expect(h.state.activeTool.locked).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores user & API tool switching without side effects", async () => {
|
||||
await render(
|
||||
<Excalidraw
|
||||
activeTool={{ type: "selection" }}
|
||||
handleKeyboardGlobally={true}
|
||||
/>,
|
||||
);
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 50,
|
||||
height: 50,
|
||||
});
|
||||
API.setElements([rect]);
|
||||
API.setAppState({ selectedElementIds: { [rect.id]: true } });
|
||||
|
||||
// the letter shortcut is refused before it can clear the selection
|
||||
fireEvent.keyDown(document, { key: "r", code: "KeyR" });
|
||||
expect(h.state.activeTool.type).toBe("selection");
|
||||
expect(h.state.selectedElementIds).toEqual({ [rect.id]: true });
|
||||
|
||||
// the imperative API is refused as well — the prop is the single owner
|
||||
act(() => {
|
||||
h.app.setActiveTool({ type: "freedraw" });
|
||||
});
|
||||
expect(h.state.activeTool.type).toBe("selection");
|
||||
});
|
||||
|
||||
it("keeps the tool in sync with prop changes, until unset", async () => {
|
||||
await render(<Excalidraw activeTool={{ type: "rectangle" }} />);
|
||||
expect(h.state.activeTool.type).toBe("rectangle");
|
||||
|
||||
GlobalTestState.renderResult.rerender(
|
||||
<Excalidraw activeTool={{ type: "custom", customType: "comment" }} />,
|
||||
);
|
||||
expect(h.state.activeTool.type).toBe("custom");
|
||||
expect(h.state.activeTool.customType).toBe("comment");
|
||||
|
||||
// customType-only changes are picked up
|
||||
GlobalTestState.renderResult.rerender(
|
||||
<Excalidraw activeTool={{ type: "custom", customType: "pin" }} />,
|
||||
);
|
||||
expect(h.state.activeTool.customType).toBe("pin");
|
||||
|
||||
// unset → uncontrolled: the current tool stays, switching works again
|
||||
GlobalTestState.renderResult.rerender(<Excalidraw />);
|
||||
expect(h.state.activeTool.customType).toBe("pin");
|
||||
act(() => {
|
||||
h.app.setActiveTool({ type: "freedraw" });
|
||||
});
|
||||
expect(h.state.activeTool.type).toBe("freedraw");
|
||||
});
|
||||
|
||||
it("snaps back when internal flows reset the tool", async () => {
|
||||
await render(<Excalidraw activeTool={{ type: "laser" }} />);
|
||||
expect(h.state.activeTool.type).toBe("laser");
|
||||
|
||||
// simulate a funnel-bypassing writer (restore on scene load,
|
||||
// actionFinalize, ...)
|
||||
act(() => {
|
||||
API.setAppState({
|
||||
activeTool: updateActiveTool(h.state, { type: "selection" }),
|
||||
});
|
||||
});
|
||||
expect(h.state.activeTool.type).toBe("laser");
|
||||
});
|
||||
|
||||
it("renders non-forced toolbar buttons disabled and gates the tool lock", async () => {
|
||||
await render(
|
||||
<Excalidraw
|
||||
activeTool={{ type: "rectangle" }}
|
||||
handleKeyboardGlobally={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(queryToolButton("rectangle")!.disabled).toBe(false);
|
||||
expect(queryToolButton("rectangle")!.getAttribute("aria-pressed")).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(queryToolButton("freedraw")!.disabled).toBe(true);
|
||||
expect(queryToolButton("eraser")!.disabled).toBe(true);
|
||||
expect(queryToolButton("lock")).toBe(null);
|
||||
|
||||
// the extra-tools dropdown items are disabled as well
|
||||
fireEvent.click(
|
||||
GlobalTestState.renderResult.container.querySelector(
|
||||
".App-toolbar__extra-tools-trigger",
|
||||
)!,
|
||||
);
|
||||
const frameItem = document.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="toolbar-frame"]',
|
||||
);
|
||||
expect(frameItem).not.toBe(null);
|
||||
expect(frameItem!.disabled).toBe(true);
|
||||
const laserItem = document.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="toolbar-laser"]',
|
||||
);
|
||||
expect(laserItem!.disabled).toBe(true);
|
||||
|
||||
// Q (toggle tool lock) is ignored — locking is implied while forced
|
||||
expect(h.state.activeTool.locked).toBe(false);
|
||||
fireEvent.keyDown(document, { key: "q", code: "KeyQ" });
|
||||
expect(h.state.activeTool.locked).toBe(false);
|
||||
expect(h.state.activeTool.type).toBe("rectangle");
|
||||
});
|
||||
|
||||
it("cannot force the image tool", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
await render(<Excalidraw activeTool={{ type: "image" } as any} />);
|
||||
expect(h.state.activeTool.type).toBe("selection");
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`"image" tool cannot be forced`),
|
||||
);
|
||||
});
|
||||
|
||||
it("composes with interaction.enabled.tools (presenter → viewer)", async () => {
|
||||
await render(
|
||||
<Excalidraw
|
||||
activeTool={{ type: "laser" }}
|
||||
interaction={{ enabled: { tools: { laser: true } } }}
|
||||
/>,
|
||||
);
|
||||
// presenter: forced laser while otherwise non-interactive
|
||||
expect(h.state.activeTool.type).toBe("laser");
|
||||
expect(h.app.isInteractionEnabled()).toBe(false);
|
||||
expect(h.app.isToolSupported("laser")).toBe(true);
|
||||
// the laser cursor applies without any pointer interaction
|
||||
expect(GlobalTestState.interactiveCanvas.style.cursor).toContain("url(");
|
||||
|
||||
// viewer: fully inert, tool resolves to the default selection
|
||||
GlobalTestState.renderResult.rerender(
|
||||
<Excalidraw activeTool={{ type: "selection" }} interaction={false} />,
|
||||
);
|
||||
expect(h.state.activeTool.type).toBe("selection");
|
||||
|
||||
// back to presenter
|
||||
GlobalTestState.renderResult.rerender(
|
||||
<Excalidraw
|
||||
activeTool={{ type: "laser" }}
|
||||
interaction={{ enabled: { tools: { laser: true } } }}
|
||||
/>,
|
||||
);
|
||||
expect(h.state.activeTool.type).toBe("laser");
|
||||
});
|
||||
|
||||
it("forcing a non-activatable tool resolves to selection until activatable", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
await render(
|
||||
<Excalidraw activeTool={{ type: "laser" }} interaction={false} />,
|
||||
);
|
||||
expect(h.state.activeTool.type).toBe("selection");
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
|
||||
// the tool becomes activatable → the forced tool applies
|
||||
GlobalTestState.renderResult.rerender(
|
||||
<Excalidraw
|
||||
activeTool={{ type: "laser" }}
|
||||
interaction={{ enabled: { tools: { laser: true } } }}
|
||||
/>,
|
||||
);
|
||||
expect(h.state.activeTool.type).toBe("laser");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -664,13 +664,13 @@ export type InteractionConfig = {
|
||||
*/
|
||||
interactiveContent?: boolean;
|
||||
/**
|
||||
* Canvas navigation — panning (pointer drag, wheel) and zooming
|
||||
* (ctrl/cmd + wheel, pinch, and the canvas zoom & zoom-to-fit shortcuts:
|
||||
* ctrl/cmd +/-/0, shift+1/2/3), same as in view mode. Respects
|
||||
* `appState.scrollConstraints` if set, so it composes with viewport
|
||||
* locking. The rest of the keyboard stays disabled. Note the editor
|
||||
* consumes wheel & touch input again when enabled, so the page no
|
||||
* longer scrolls over the editor.
|
||||
* Canvas navigation — panning (pointer drag, wheel, PageUp/PageDown)
|
||||
* and zooming (ctrl/cmd + wheel, pinch, and the canvas zoom &
|
||||
* zoom-to-fit shortcuts: ctrl/cmd +/-/0, shift+1/2/3), same as in view
|
||||
* mode. Respects `appState.scrollConstraints` if set, so it composes
|
||||
* with viewport locking. The rest of the keyboard stays disabled. Note
|
||||
* the editor consumes wheel & touch input again when enabled, so the
|
||||
* page no longer scrolls over the editor.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
@@ -686,6 +686,36 @@ export type InteractionConfig = {
|
||||
* @default false
|
||||
*/
|
||||
browserZoom?: boolean;
|
||||
/**
|
||||
* Tools that stay user-driven while the editor is otherwise
|
||||
* non-interactive: pointer input keeps driving the listed tool when it's
|
||||
* the active tool. Does not enable user-driven tool *switching* — the
|
||||
* keyboard stays disabled and tool selection remains host-driven
|
||||
* (`ExcalidrawAPI.setActiveTool`).
|
||||
*
|
||||
* Composes with `navigation`: the enabled tool wins the primary-pointer
|
||||
* drag, while wheel input (and wheel-button drag) still pans/zooms.
|
||||
*/
|
||||
tools?: {
|
||||
/**
|
||||
* The laser pointer stays usable — pointer strokes draw laser trails
|
||||
* and pointer positions keep broadcasting via `onPointerUpdate`, so
|
||||
* e.g. collaborators see a presenter's laser & cursor.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
laser?: boolean;
|
||||
/**
|
||||
* Custom tools (`activeTool.type === "custom"`) stay usable — the
|
||||
* editor keeps dispatching `onPointerDown` / `onPointerUp` for them.
|
||||
* Tool behavior is host-implemented; activate custom tools with
|
||||
* `locked: true` or they revert to the selection tool (and go inert)
|
||||
* after the first pointer interaction.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
custom?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -790,6 +820,27 @@ export interface ExcalidrawProps {
|
||||
* @default true
|
||||
*/
|
||||
ui?: boolean;
|
||||
/**
|
||||
* Forces the active editor tool (controlled). While set, user- and
|
||||
* API-driven tool switching is ignored — `setActiveTool` refuses with a
|
||||
* console warning, non-forced toolbar buttons render disabled — and the
|
||||
* editor snaps back if internal flows reset the tool. The forced tool
|
||||
* behaves as if locked (see the tool lock / padlock): it doesn't revert to
|
||||
* the selection tool after use, and elements drawn with it aren't
|
||||
* auto-selected — without mutating `appState.activeTool.locked`, so the
|
||||
* user's persisted padlock preference stays untouched. Unset to return
|
||||
* tool control to the editor (the current tool stays active).
|
||||
*
|
||||
* The forced tool must be activatable to take effect: not disabled via
|
||||
* `UIOptions.tools`, and — while the editor is non-interactive — allowed
|
||||
* via `interaction.enabled.tools`. Otherwise the editor stays on (or, when
|
||||
* non-interactive, resets to) the `selection` tool, and the forced tool is
|
||||
* applied once it becomes activatable. `image` cannot be forced (its
|
||||
* activation opens the file picker).
|
||||
*/
|
||||
activeTool?:
|
||||
| { type: Exclude<ToolType, "image"> }
|
||||
| { type: "custom"; customType: string };
|
||||
zenModeEnabled?: boolean;
|
||||
gridModeEnabled?: boolean;
|
||||
objectsSnapModeEnabled?: boolean;
|
||||
@@ -993,6 +1044,8 @@ export type AppClassProperties = {
|
||||
getName: App["getName"];
|
||||
dismissLinearEditor: App["dismissLinearEditor"];
|
||||
flowchart: App["flowchart"];
|
||||
cursor: App["cursor"];
|
||||
isToolLocked: App["isToolLocked"];
|
||||
getEffectiveGridSize: App["getEffectiveGridSize"];
|
||||
setPlugins: App["setPlugins"];
|
||||
plugins: App["plugins"];
|
||||
@@ -1135,8 +1188,8 @@ export interface ExcalidrawImperativeAPI {
|
||||
addFiles: (data: BinaryFileData[]) => void;
|
||||
id: string;
|
||||
setActiveTool: InstanceType<typeof App>["setActiveTool"];
|
||||
setCursor: InstanceType<typeof App>["setCursor"];
|
||||
resetCursor: InstanceType<typeof App>["resetCursor"];
|
||||
setCursor: InstanceType<typeof App>["cursor"]["set"];
|
||||
resetCursor: InstanceType<typeof App>["cursor"]["reset"];
|
||||
toggleSidebar: InstanceType<typeof App>["toggleSidebar"];
|
||||
getEditorInterface: () => EditorInterface;
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user