From 5ca083436d44a51a0705d43ea22d323839d5fe8e Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:46:08 +0200 Subject: [PATCH] feat(packages/excalidraw): add `props.activeTool` and related (#11665) --- packages/excalidraw/CHANGELOG.md | 12 +- .../excalidraw/actions/actionFinalize.tsx | 31 +- packages/excalidraw/components/App.cursor.ts | 140 +++++ packages/excalidraw/components/App.tsx | 519 ++++++++++++------ packages/excalidraw/components/IconButton.tsx | 2 + packages/excalidraw/components/LockButton.tsx | 2 + .../excalidraw/components/MobileToolbar.tsx | 7 + .../excalidraw/components/ToolPopover.tsx | 5 + packages/excalidraw/components/Toolbar.scss | 5 +- packages/excalidraw/components/Toolbar.tsx | 24 +- packages/excalidraw/components/Tools.tsx | 9 + .../components/canvases/InteractiveCanvas.tsx | 10 +- packages/excalidraw/css/styles.scss | 7 + packages/excalidraw/css/variables.module.scss | 23 + packages/excalidraw/cursor.ts | 106 ---- packages/excalidraw/index.tsx | 22 +- .../excalidraw/tests/interactivity.test.tsx | 357 +++++++++++- packages/excalidraw/tests/tool.test.tsx | 234 +++++++- packages/excalidraw/types.ts | 71 ++- 19 files changed, 1282 insertions(+), 304 deletions(-) create mode 100644 packages/excalidraw/components/App.cursor.ts delete mode 100644 packages/excalidraw/cursor.ts diff --git a/packages/excalidraw/CHANGELOG.md b/packages/excalidraw/CHANGELOG.md index cfe8df7fd2..2e2ad18c7d 100644 --- a/packages/excalidraw/CHANGELOG.md +++ b/packages/excalidraw/CHANGELOG.md @@ -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 `` and `