From 1e6836dbc9844d9ac28ebb84bce61f7b1e5b1938 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mime=20=C4=8Cuvalo?= Date: Fri, 5 Jun 2026 10:35:13 +0100 Subject: [PATCH 1/9] fix(tldraw): correct fill dropdown trigger tooltip (#9023) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In order to stop the fill dropdown trigger from showing a misleading tooltip, this PR makes the trigger describe what the dropdown opens instead of the currently selected value. Closes #8955. The fill style panel shows the main fills (`none`, `semi`, `solid`) as buttons and the extra fills (`pattern`, `lined fill`, `fill`) behind a dropdown trigger. The trigger's tooltip was always built from the current selection, so hovering it with a main fill selected showed something like "Fill — Solid" — a value that isn't even in that dropdown. Now the tooltip only names the selected value when that value is actually one of the dropdown's items; otherwise it just shows the style name ("Fill"). This mirrors the existing icon logic, which already falls back to the first item when the current value isn't in the dropdown. Standalone dropdown pickers (geo, spline, arrowhead kind) always hold the current value, so their tooltips are unchanged. ### Change type - [x] `bugfix` ### Test plan 1. Select a shape with a fill (or set the fill to `none`, `semi`, or `solid`). 2. Hover the fill dropdown trigger (the last item in the fill row). 3. The tooltip reads "Fill", not the name of the currently selected fill. 4. Open the dropdown and select an extra fill (e.g. pattern); the trigger tooltip then reads "Fill — Pattern". - [x] End to end tests ### Release notes - Fix the fill style dropdown trigger showing a misleading tooltip for the currently selected fill. --- apps/examples/e2e/fixtures/menus/StylePanel.ts | 2 ++ .../examples/e2e/tests/test-style-panel.spec.ts | 17 +++++++++++++++++ .../StylePanel/StylePanelDropdownPicker.tsx | 9 ++++++++- 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/apps/examples/e2e/fixtures/menus/StylePanel.ts b/apps/examples/e2e/fixtures/menus/StylePanel.ts index 9c9d01a790d7..73c56209a4e9 100644 --- a/apps/examples/e2e/fixtures/menus/StylePanel.ts +++ b/apps/examples/e2e/fixtures/menus/StylePanel.ts @@ -4,6 +4,7 @@ export class StylePanel { readonly stylesArray: string[] readonly colors: { [key: string]: Locator } readonly fill: { [key: string]: Locator } + readonly fillExtra: Locator readonly dash: { [key: string]: Locator } readonly size: { [key: string]: Locator } readonly font: { [key: string]: Locator } @@ -39,6 +40,7 @@ export class StylePanel { semi: this.page.getByTestId('style.fill.semi'), solid: this.page.getByTestId('style.fill.solid'), } + this.fillExtra = this.page.getByTestId('style.fill-extra') this.dash = { draw: this.page.getByTestId('style.dash.draw'), dashed: this.page.getByTestId('style.dash.dashed'), diff --git a/apps/examples/e2e/tests/test-style-panel.spec.ts b/apps/examples/e2e/tests/test-style-panel.spec.ts index dd64733628cb..a1b82993eaa8 100644 --- a/apps/examples/e2e/tests/test-style-panel.spec.ts +++ b/apps/examples/e2e/tests/test-style-panel.spec.ts @@ -31,6 +31,23 @@ test.describe('Style selection behaviour', () => { await stylePanel.isActive(blue) }) + test('the fill dropdown trigger tooltip describes the dropdown, not the current fill', async ({ + isMobile, + stylePanel, + toolbar, + }) => { + const { solid } = stylePanel.fill + if (isMobile) { + await toolbar.mobileStylesButton.click() + } + + // when the current fill lives outside the dropdown (the default "none", or "solid"), + // the trigger tooltip should just name the style, not a value the dropdown can't show + await expect(stylePanel.fillExtra).toHaveAttribute('aria-label', 'Fill') + await solid.click() + await expect(stylePanel.fillExtra).toHaveAttribute('aria-label', 'Fill') + }) + test('selecting a style changes the style of the shapes', async ({ page, stylePanel, diff --git a/packages/tldraw/src/lib/ui/components/StylePanel/StylePanelDropdownPicker.tsx b/packages/tldraw/src/lib/ui/components/StylePanel/StylePanelDropdownPicker.tsx index cd2829f11b9c..0981b103c356 100644 --- a/packages/tldraw/src/lib/ui/components/StylePanel/StylePanelDropdownPicker.tsx +++ b/packages/tldraw/src/lib/ui/components/StylePanel/StylePanelDropdownPicker.tsx @@ -73,10 +73,17 @@ function StylePanelDropdownPickerInlineInner( const stylePanelName = msg(`style-panel.${stylePanelType}` as TLUiTranslationKey) + // The current value isn't always present in this dropdown's items (for example the fill + // dropdown only holds the "extra" fills, so a "solid" selection lives elsewhere). When the + // selected value isn't one of these items, describe what the dropdown opens rather than a + // value it can't show. + const valueInItems = value.type !== 'mixed' && items.some((item) => item.value === value.value) const titleStr = value.type === 'mixed' ? msg('style-panel.mixed') - : stylePanelName + ' — ' + msg(`${uiType}-style.${value.value}` as TLUiTranslationKey) + : valueInItems + ? stylePanelName + ' — ' + msg(`${uiType}-style.${value.value}` as TLUiTranslationKey) + : stylePanelName const labelStr = label ? msg(label) : '' const popoverId = `style panel ${id}` From 7e69fa3c70ec5f15d8d048f4c2209ee7ca4278f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mitja=20Bezen=C5=A1ek?= Date: Fri, 5 Jun 2026 11:52:55 +0200 Subject: [PATCH 2/9] improvement(dotcom): let owners demote themselves to admin (#9035) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets group owners demote themselves to admin from the group settings dialog, but only when the group has another owner. The role dropdown now appears on your own member row when `ownersCount > 1`; with a single owner you still see read-only role text. The backend mutator `setGroupMemberRole` already permits self-targeting and blocks demoting the last owner, so this is a client-only change to surface the dropdown in the valid case. ### Change type - [ ] `bugfix` - [x] `improvement` - [ ] `feature` - [ ] `api` - [ ] `other` ### Test plan 1. Create a group with two owners. 2. Open group settings as one of the owners. 3. On your own row, change your role from Owner to Admin — succeeds. 4. With only one owner, confirm your own row shows read-only role text (no dropdown). - [ ] Unit tests - [ ] End to end tests ### Release notes - Group owners can now demote themselves to admin, as long as another owner remains. --- .../client/src/tla/components/dialogs/GroupSettingsDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dotcom/client/src/tla/components/dialogs/GroupSettingsDialog.tsx b/apps/dotcom/client/src/tla/components/dialogs/GroupSettingsDialog.tsx index b26ad261cc96..0991aec80823 100644 --- a/apps/dotcom/client/src/tla/components/dialogs/GroupSettingsDialog.tsx +++ b/apps/dotcom/client/src/tla/components/dialogs/GroupSettingsDialog.tsx @@ -269,7 +269,7 @@ export function GroupSettingsDialog({ groupId, onClose }: GroupSettingsDialogPro {member.userName} {member.userId === app.getUser().id ? ` (${youMsg})` : ''} - {isOwner && member.userId !== app.getUser().id ? ( + {isOwner && (member.userId !== app.getUser().id || ownersCount > 1) ? ( Date: Fri, 5 Jun 2026 11:54:48 +0200 Subject: [PATCH 3/9] other(dotcom): disable zero-cache startup message in dev (#9036) In order to stop the Zero Cloud promo banner from printing on every local zero-cache startup, this PR sets `ZERO_ENABLE_STARTUP_MESSAGE=0` in the dev `.env`, alongside the existing `DO_NOT_TRACK` opt-out. ### Change type - [x] `other` ### Test plan 1. Run the zero-cache dev server (`yarn dev` in `apps/dotcom/zero-cache`). 2. Confirm the "Cloud Zero is now available" startup message no longer prints. --- apps/dotcom/zero-cache/.env | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/dotcom/zero-cache/.env b/apps/dotcom/zero-cache/.env index 0309b998f120..d5e6759ff788 100644 --- a/apps/dotcom/zero-cache/.env +++ b/apps/dotcom/zero-cache/.env @@ -8,4 +8,5 @@ ZERO_MUTATE_URL="http://localhost:8787/app/zero/mutate" ZERO_QUERY_URL="http://localhost:8787/app/zero/query" ZERO_ENABLE_CRUD_MUTATIONS=false ZERO_LOG_LEVEL="info" +ZERO_ENABLE_STARTUP_MESSAGE=0 DO_NOT_TRACK=1 From 599637df24f7b9514936790521b3ce272aa7c2f0 Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Fri, 5 Jun 2026 12:20:38 +0100 Subject: [PATCH 4/9] feat(editor): simplify multi-click handling to double-click (#8897) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This simplifies tldraw's multi-click handling. Previously `ClickManager` tracked double, triple, and quadruple clicks, each emitting its own event with `down`, `up`, and `settle` phases. In practice only double-click and the hand tool's zoom shortcuts used the higher counts, and the `settle` phase couldn't tell a multi-click that finished with the pointer held apart from one that finished with it released. This PR: - Removes `triple_click` and `quadruple_click` entirely. `TLCLickEventName` is now just `'double_click'`, and the `onTripleClick` / `onQuadrupleClick` handlers are gone from `TLEventHandlers` and `StateNode`. - Splits the `settle` phase into `settle-down` and `settle-up`. `ClickManager` tracks whether the pointer is pressed when the click timer fires and emits the matching phase. - Fires double-click-to-edit on the `down` phase. Select tool child states (`Idle`, `PointingShape`, `PointingCanvas`, `PointingHandle`, `PointingSelection`, and the crop states) now act on the second pointer-down instead of waiting for pointer-up, so editing and handle behavior trigger immediately. - Updates `HandTool` to keep only double-click zoom-in (on `settle-up`); triple-click zoom-out and quadruple-click reset/zoom-to-fit are removed. ### API changes - Breaking! `TLCLickEventName` no longer includes `'triple_click'` or `'quadruple_click'`; only `'double_click'` remains. - Breaking! `onTripleClick` and `onQuadrupleClick` are removed from `TLEventHandlers` and `StateNode`. - Breaking! `TLClickEventInfo.phase` replaces `'settle'` with `'settle-down'` and `'settle-up'`. Consumers that checked `phase === 'settle'` should switch to `'settle-up'` (or branch on both if they care whether the pointer is still pressed). - `TLClickState` drops `'pendingTriple'` and `'pendingQuadruple'`. ### Change type - [x] `api` ### Test plan 1. With the hand tool, double-click the canvas and release — should zoom in. 2. With the select tool, double-click a shape — should enter editing immediately on the second press. 3. Confirm triple- and quadruple-clicks no longer zoom out or reset zoom with the hand tool. - [x] Unit tests - [x] End to end tests ### Release notes - Simplified multi-click handling: triple- and quadruple-click events are removed, and the `settle` click phase is split into `settle-down` and `settle-up` so tools can tell whether a multi-click finished with the pointer held or released. ### Code changes | Section | LOC change | | ---------------- | ------------ | | Core code | +157 / -112 | | API report | +3 / -15 | | Tests and infra | +107 / -141 | | Documentation | +55 / -63 | --------- Co-authored-by: Max Drake --- .../content/sdk-features/click-detection.mdx | 98 ++++++-------- apps/docs/content/sdk-features/events.mdx | 2 +- .../content/sdk-features/input-handling.mdx | 16 +-- apps/docs/content/sdk-features/options.mdx | 2 +- apps/examples/e2e/tests/test-kbds.spec.ts | 2 +- apps/examples/vercel.json | 3 + packages/editor/api-report.api.md | 14 +- .../ClickManager/ClickManager.test.ts | 128 ++++++++---------- .../managers/ClickManager/ClickManager.ts | 80 ++--------- .../editor/src/lib/editor/tools/StateNode.ts | 2 - .../src/lib/editor/types/event-types.ts | 8 +- packages/tldraw/api-report.api.md | 4 - .../tldraw/src/lib/tools/HandTool/HandTool.ts | 26 +--- .../tools/SelectTool/childStates/Crop/Crop.ts | 4 +- .../childStates/Crop/children/Idle.ts | 4 +- .../childStates/Crop/children/PointingCrop.ts | 16 ++- .../Crop/children/PointingCropHandle.ts | 16 ++- .../lib/tools/SelectTool/childStates/Idle.ts | 2 +- .../SelectTool/childStates/PointingCanvas.ts | 16 ++- .../SelectTool/childStates/PointingHandle.ts | 24 +++- .../childStates/PointingResizeHandle.ts | 22 ++- .../childStates/PointingRotateHandle.ts | 16 ++- .../childStates/PointingSelection.ts | 13 +- .../SelectTool/childStates/PointingShape.ts | 20 ++- packages/tldraw/src/test/ClickManager.test.ts | 61 +++++---- packages/tldraw/src/test/HandTool.test.ts | 39 +----- packages/tldraw/src/test/cropping.test.ts | 7 + .../tldraw/src/test/selection-omnibus.test.ts | 8 +- 28 files changed, 322 insertions(+), 331 deletions(-) diff --git a/apps/docs/content/sdk-features/click-detection.mdx b/apps/docs/content/sdk-features/click-detection.mdx index 3f8e827dbf02..e342644b6998 100644 --- a/apps/docs/content/sdk-features/click-detection.mdx +++ b/apps/docs/content/sdk-features/click-detection.mdx @@ -5,11 +5,9 @@ updated_at: 12/29/2025 keywords: - click - double-click - - triple-click - - quadruple-click + - overflow click - pointer - ClickManager - - multi-click status: published date: 12/20/2025 readability: 9 @@ -19,54 +17,53 @@ accuracy: 10 notes: Could add a simpler opening hook per voice guide ("Have five minutes?"-style). --- -In tldraw, the click detection system lets you respond to double, triple, and quadruple clicks. The [ClickManager](?) tracks consecutive pointer down events using a state machine, dispatching click events when timing and distance thresholds are met. +In tldraw, the click detection system turns a pair of nearby clicks into a `double_click` event. The [ClickManager](?) tracks consecutive pointer down events using a state machine, dispatching a double-click event when timing and distance thresholds are met. -This powers text editing features like word selection on double-click and paragraph selection on triple-click. You can use these events to add custom multi-click behaviors in your own shapes and tools. +After a double-click, extra nearby clicks are treated as overflow. Overflow clicks do not dispatch additional click events; they suppress the sequence long enough to prevent a rapid double-double-click from becoming two double-clicks. ## How it works -When a pointer down event occurs, the manager either starts a new sequence or advances to the next click level. Each state has a timeout that determines how long to wait for the next click before settling on the current level. +When a pointer down event occurs, the manager either starts a new sequence, detects a double-click, or moves the sequence into overflow. Each state has a timeout that determines how long to wait before returning to idle. -Two timeout durations control the detection speed. The first click uses `doubleClickDurationMs` (450ms by default), giving users time to initiate a double-click sequence. Subsequent clicks use the shorter `multiClickDurationMs` (200ms by default), requiring faster input for triple and quadruple clicks. This pattern matches common operating system behavior. +Two timeout durations control the detection speed. The first click uses `doubleClickDurationMs` (450ms by default), giving users time to initiate a double-click sequence. After a double-click, `multiClickDurationMs` (200ms by default) controls both the settle delay and the overflow suppression window. The option name is historical; it now controls only the post-double-click window. ### State transitions The click state machine progresses through these states: -| State | Description | -| ------------------ | ------------------------------------------ | -| `idle` | No active click sequence | -| `pendingDouble` | First click registered, waiting for second | -| `pendingTriple` | Second click registered, waiting for third | -| `pendingQuadruple` | Third click registered, waiting for fourth | -| `pendingOverflow` | Fourth click registered, waiting for fifth | -| `overflow` | More than four clicks detected | +| State | Description | +| ----------------- | --------------------------------------------- | +| `idle` | No active click sequence | +| `pendingDouble` | First click registered, waiting for second | +| `pendingOverflow` | Double-click registered, waiting for overflow | +| `overflow` | Extra clicks detected after the double-click | -When a pointer down event arrives, the state advances to the next pending state and sets a timeout. If the timeout expires before the next click, the manager dispatches a settle event for the current click level and returns to idle. If another pointer down arrives before the timeout, the state advances and a click event is dispatched immediately. +The second pointer down dispatches a `double_click` event immediately and starts waiting for overflow. If the timeout expires before another click, the manager dispatches a double-click settle event and returns to idle. If another pointer down arrives first, the sequence moves to overflow and no further click events are dispatched until the overflow timeout expires. ### Distance validation -Consecutive clicks must occur within a maximum distance of 40 pixels (screen space). If pointer down events are farther apart, the click sequence resets to idle. +Consecutive clicks must occur within a maximum distance of 40 pixels (screen space). If pointer down events are farther apart, the new pointer down starts a fresh click sequence instead. ### Click event phases Click events are dispatched with a `phase` property indicating when in the sequence the event fired: -| Phase | When it fires | -| -------- | -------------------------------------------------------------- | -| `down` | Immediately when a multi-click is detected during pointer down | -| `up` | During pointer up for multi-clicks that are still pending | -| `settle` | When the timeout expires without further clicks | +| Phase | When it fires | +| ------------- | ------------------------------------------------------------------- | +| `down` | On the second pointer down, when the double-click is detected | +| `up` | On the matching pointer up while the double-click is still pending | +| `settle-down` | When the timeout expires without overflow while the pointer is down | +| `settle-up` | When the timeout expires without overflow after the pointer is up | -The phase system lets tools and shapes respond at different points in the click sequence. For example, the hand tool waits for the `settle` phase before zooming in—this avoids triggering a zoom if the user is about to triple-click. +The phase system lets tools respond at different points in the click sequence. Most default selection and cropping behavior responds on the `down` phase, so it starts on the second pointer down. The hand tool's double-click zoom responds on `settle-up`, so an overflow click can still cancel the pending zoom. ### Movement cancellation -If the pointer moves too far during a pending click sequence, the system cancels the sequence and returns to idle. This prevents multi-click detection during click-drag operations. The movement threshold differs for coarse pointers (touchscreens) and fine pointers (mouse, stylus). +If the pointer moves too far during a pending click sequence, the system cancels the sequence and returns to idle. This prevents double-click detection during click-drag operations. The movement threshold differs for coarse pointers (touchscreens) and fine pointers (mouse, stylus). ## Handling click events -Tools receive click events through handler methods defined in the [TLEventHandlers](?) interface. Here's a complete example of a custom tool that zooms in on double-click: +Tools receive click events through handler methods defined in the [TLEventHandlers](?) interface. Here's a complete example of a custom tool that zooms in as soon as a double-click is detected: ```tsx import { StateNode, TLClickEventInfo, Tldraw } from 'tldraw' @@ -76,14 +73,9 @@ class ZoomTool extends StateNode { static override id = 'zoom' override onDoubleClick(info: TLClickEventInfo) { - if (info.phase !== 'settle') return + if (info.phase !== 'down') return this.editor.zoomIn(info.point, { animation: { duration: 200 } }) } - - override onTripleClick(info: TLClickEventInfo) { - if (info.phase !== 'settle') return - this.editor.zoomOut(info.point, { animation: { duration: 200 } }) - } } const customTools = [ZoomTool] @@ -102,9 +94,9 @@ export default function App() { } ``` -Tools can handle [StateNode#onDoubleClick](?), [StateNode#onTripleClick](?), and [StateNode#onQuadrupleClick](?) events. Each receives a [TLClickEventInfo](?) object with details about the click. +Tools can handle [StateNode#onDoubleClick](?) events. The handler receives a [TLClickEventInfo](?) object with details about the click. Use `phase: 'down'` for behavior that should start on the second pointer down, or a settle phase for behavior that should wait until the overflow window has passed. -Shape utilities handle double-clicks through the [ShapeUtil#onDoubleClick](?) method. Return a partial shape object to apply changes: +The select tool routes shape double-clicks to [ShapeUtil#onDoubleClick](?). Return a partial shape object to apply changes: ```tsx override onDoubleClick(shape: MyShape) { @@ -118,31 +110,31 @@ override onDoubleClick(shape: MyShape) { The [TLClickEventInfo](?) type includes these properties: -| Property | Type | Description | -| ----------- | ------------------------------------------------------- | ------------------------------------------------------ | -| `type` | `'click'` | Event type identifier | -| `name` | `'double_click' \| 'triple_click' \| 'quadruple_click'` | Which multi-click event this is | -| `point` | `VecLike` | Pointer position in client space | -| `pointerId` | `number` | Unique identifier for the pointer | -| `button` | `number` | Mouse button (0 = left, 1 = middle, 2 = right) | -| `phase` | `'down' \| 'up' \| 'settle'` | When in the click sequence this fired | -| `target` | `'canvas' \| 'selection' \| 'shape' \| 'handle'` | What was clicked | -| `shape` | `TLShape \| undefined` | The shape, when target is `'shape'` or `'handle'` | -| `handle` | `TLHandle \| undefined` | The handle, when target is `'handle'` | -| `shiftKey` | `boolean` | Whether Shift was held | -| `altKey` | `boolean` | Whether Alt/Option was held | -| `ctrlKey` | `boolean` | Whether Control was held | -| `metaKey` | `boolean` | Whether Meta/Command was held | -| `accelKey` | `boolean` | Platform accelerator key (Cmd on Mac, Ctrl on Windows) | +| Property | Type | Description | +| ----------- | ------------------------------------------------ | ------------------------------------------------------ | +| `type` | `'click'` | Event type identifier | +| `name` | `'double_click'` | Which click event this is | +| `point` | `VecLike` | Pointer position in client space | +| `pointerId` | `number` | Unique identifier for the pointer | +| `button` | `number` | Mouse button (0 = left, 1 = middle, 2 = right) | +| `phase` | `'down' \| 'up' \| 'settle-down' \| 'settle-up'` | When in the click sequence this fired | +| `target` | `'canvas' \| 'selection' \| 'shape' \| 'handle'` | What was clicked | +| `shape` | `TLShape \| undefined` | The shape, when target is `'shape'` or `'handle'` | +| `handle` | `TLHandle \| undefined` | The handle, when target is `'handle'` | +| `shiftKey` | `boolean` | Whether Shift was held | +| `altKey` | `boolean` | Whether Alt/Option was held | +| `ctrlKey` | `boolean` | Whether Control was held | +| `metaKey` | `boolean` | Whether Meta/Command was held | +| `accelKey` | `boolean` | Platform accelerator key (Cmd on Mac, Ctrl on Windows) | ## Timing configuration Click timing is configured through the editor's [options](/sdk-features/options): -| Option | Default | Description | -| ----------------------- | ------- | -------------------------------------------------------- | -| `doubleClickDurationMs` | 450ms | Time window for the first click to become a double-click | -| `multiClickDurationMs` | 200ms | Time window for subsequent clicks in the sequence | +| Option | Default | Description | +| ----------------------- | ------- | --------------------------------------------------------- | +| `doubleClickDurationMs` | 450ms | Time window for the first click to become a double-click | +| `multiClickDurationMs` | 200ms | Double-click settle delay and overflow suppression window | ## Related examples diff --git a/apps/docs/content/sdk-features/events.mdx b/apps/docs/content/sdk-features/events.mdx index 1896c9605d67..c44a5bec0447 100644 --- a/apps/docs/content/sdk-features/events.mdx +++ b/apps/docs/content/sdk-features/events.mdx @@ -88,7 +88,7 @@ Input events have different types: | Type | Names | Description | | ---------- | ----------------------------------------------------------------------------------------- | ----------------------------------- | | `pointer` | `pointer_down`, `pointer_move`, `pointer_up`, `right_click`, `middle_click`, `long_press` | Mouse, touch, and pen interactions | -| `click` | `double_click`, `triple_click`, `quadruple_click` | Multi-click sequences | +| `click` | `double_click` | Double-click sequences | | `keyboard` | `key_down`, `key_up`, `key_repeat` | Keyboard input | | `wheel` | `wheel` | Scroll wheel and trackpad scrolling | | `pinch` | `pinch_start`, `pinch`, `pinch_end` | Two-finger pinch gestures | diff --git a/apps/docs/content/sdk-features/input-handling.mdx b/apps/docs/content/sdk-features/input-handling.mdx index 04c09314611c..cdc4799088ba 100644 --- a/apps/docs/content/sdk-features/input-handling.mdx +++ b/apps/docs/content/sdk-features/input-handling.mdx @@ -151,19 +151,19 @@ When an input event occurs, the editor processes it through these stages: 1. The browser fires a native DOM event 2. The editor's UI layer captures the event and transforms it into a typed event info object 3. For pointer, pinch, and wheel events, the editor calls `updateFromEvent()` on the `InputsManager` -4. For pointer events, the editor dispatches to the `ClickManager` for multi-click detection (double-click, triple-click, etc.) +4. For pointer events, the editor dispatches to the `ClickManager` for double-click detection and overflow suppression 5. The editor sends the event to the state machine via `root.handleEvent()` 6. The state machine propagates the event through active tool states The typed event info objects are: -| Event type | Info type | -| -------------------------- | --------------------- | -| Pointer events | `TLPointerEventInfo` | -| Click events (multi-click) | `TLClickEventInfo` | -| Keyboard events | `TLKeyboardEventInfo` | -| Wheel events | `TLWheelEventInfo` | -| Pinch events | `TLPinchEventInfo` | +| Event type | Info type | +| --------------- | --------------------- | +| Pointer events | `TLPointerEventInfo` | +| Click events | `TLClickEventInfo` | +| Keyboard events | `TLKeyboardEventInfo` | +| Wheel events | `TLWheelEventInfo` | +| Pinch events | `TLPinchEventInfo` | In collaborative sessions, `updateFromEvent()` also updates the user's pointer presence record in the store, broadcasting pointer position to other users. diff --git a/apps/docs/content/sdk-features/options.mdx b/apps/docs/content/sdk-features/options.mdx index f75ecd581ada..507387b4bd02 100644 --- a/apps/docs/content/sdk-features/options.mdx +++ b/apps/docs/content/sdk-features/options.mdx @@ -72,7 +72,7 @@ Configure how the editor interprets user input timing: | Option | Default | Description | | ----------------------- | ------- | ------------------------------------------------- | | `doubleClickDurationMs` | 450 | Maximum interval for double-click detection | -| `multiClickDurationMs` | 200 | Maximum interval for triple/quadruple click | +| `multiClickDurationMs` | 200 | Double-click settle delay and overflow window | | `longPressDurationMs` | 500 | Duration to trigger long press | | `animationMediumMs` | 320 | Duration for camera animations (zoom, pan to fit) | diff --git a/apps/examples/e2e/tests/test-kbds.spec.ts b/apps/examples/e2e/tests/test-kbds.spec.ts index fb055a086666..fe28cde20d4c 100644 --- a/apps/examples/e2e/tests/test-kbds.spec.ts +++ b/apps/examples/e2e/tests/test-kbds.spec.ts @@ -661,7 +661,7 @@ test.describe('Shape Navigation', () => { // Select both shapes await page.keyboard.press('v') - await page.mouse.click(310, 50) + await page.mouse.move(310, 50) await page.mouse.down() await page.mouse.move(310, 50) await page.mouse.move(700, 500) diff --git a/apps/examples/vercel.json b/apps/examples/vercel.json index df3d2ede4ba5..a455590cde5f 100644 --- a/apps/examples/vercel.json +++ b/apps/examples/vercel.json @@ -1,4 +1,7 @@ { + "installCommand": "yarn workspaces focus examples.tldraw.com @tldraw/monorepo config", + "buildCommand": "cd ../../packages/tldraw && yarn prebuild && cd ../../apps/examples && yarn build", + "outputDirectory": "dist", "headers": [ { "source": "/assets/(.*)", diff --git a/packages/editor/api-report.api.md b/packages/editor/api-report.api.md index 4924da788ad3..0d863de683e8 100644 --- a/packages/editor/api-report.api.md +++ b/packages/editor/api-report.api.md @@ -3255,14 +3255,10 @@ export abstract class StateNode implements Partial { // (undocumented) onPointerUp?(info: TLPointerEventInfo): void; // (undocumented) - onQuadrupleClick?(info: TLClickEventInfo): void; - // (undocumented) onRightClick?(info: TLPointerEventInfo): void; // (undocumented) onTick?(info: TLTickEventInfo): void; // (undocumented) - onTripleClick?(info: TLClickEventInfo): void; - // (undocumented) onWheel?(info: TLWheelEventInfo): void; // (undocumented) parent: StateNode; @@ -3546,17 +3542,17 @@ export type TLClickEvent = (info: TLClickEventInfo) => void; export type TLClickEventInfo = TLBaseEventInfo & { button: number; name: TLCLickEventName; - phase: 'down' | 'settle' | 'up'; + phase: 'down' | 'settle-down' | 'settle-up' | 'up'; point: VecLike; pointerId: number; type: 'click'; } & TLPointerEventTarget; // @public (undocumented) -export type TLCLickEventName = 'double_click' | 'quadruple_click' | 'triple_click'; +export type TLCLickEventName = 'double_click'; // @public (undocumented) -export type TLClickState = 'idle' | 'overflow' | 'pendingDouble' | 'pendingOverflow' | 'pendingQuadruple' | 'pendingTriple'; +export type TLClickState = 'idle' | 'overflow' | 'pendingDouble' | 'pendingOverflow'; // @public export type TLClipboardPasteRawInfo = { @@ -4034,14 +4030,10 @@ export interface TLEventHandlers { // (undocumented) onPointerUp: TLPointerEvent; // (undocumented) - onQuadrupleClick: TLClickEvent; - // (undocumented) onRightClick: TLPointerEvent; // (undocumented) onTick: TLTickEvent; // (undocumented) - onTripleClick: TLClickEvent; - // (undocumented) onWheel: TLWheelEvent; } diff --git a/packages/editor/src/lib/editor/managers/ClickManager/ClickManager.test.ts b/packages/editor/src/lib/editor/managers/ClickManager/ClickManager.test.ts index f00492219f75..ab620d5cce3f 100644 --- a/packages/editor/src/lib/editor/managers/ClickManager/ClickManager.test.ts +++ b/packages/editor/src/lib/editor/managers/ClickManager/ClickManager.test.ts @@ -122,7 +122,7 @@ describe('ClickManager', () => { expect(result.type).toBe('click') expect(result.name).toBe('double_click') expect(result.phase).toBe('down') - expect(clickManager.clickState).toBe('pendingTriple') + expect(clickManager.clickState).toBe('pendingOverflow') }) it('should generate double_click up event on pointer_up after double_click down', () => { @@ -139,12 +139,34 @@ describe('ClickManager', () => { expect(result.phase).toBe('up') }) - it('should dispatch double_click settle event after timeout in pendingTriple', () => { + it('should dispatch double_click settle-down event after timeout in pendingOverflow (pointer held)', () => { const firstDown = createPointerEvent('pointer_down', { x: 100, y: 100 }) const secondDown = createPointerEvent('pointer_down', { x: 100, y: 100 }) clickManager.handlePointerEvent(firstDown) clickManager.handlePointerEvent(secondDown) + // no pointer_up between or after — pointer is still down at settle time + + vi.advanceTimersByTime(350) + + expect(editor.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'click', + name: 'double_click', + phase: 'settle-down', + }) + ) + expect(clickManager.clickState).toBe('idle') + }) + + it('should dispatch double_click settle-up event after timeout in pendingOverflow (pointer released)', () => { + const down = createPointerEvent('pointer_down', { x: 100, y: 100 }) + const up = createPointerEvent('pointer_up', { x: 100, y: 100 }) + + clickManager.handlePointerEvent(down) + clickManager.handlePointerEvent(up) + clickManager.handlePointerEvent(down) + clickManager.handlePointerEvent(up) vi.advanceTimersByTime(350) @@ -152,124 +174,84 @@ describe('ClickManager', () => { expect.objectContaining({ type: 'click', name: 'double_click', - phase: 'settle', + phase: 'settle-up', }) ) expect(clickManager.clickState).toBe('idle') }) }) - describe('triple and quadruple click detection', () => { - it('should detect triple click on third pointer_down', () => { + describe('overflow click handling', () => { + it('should enter overflow on the third pointer_down without emitting another click', () => { const firstDown = createPointerEvent('pointer_down', { x: 100, y: 100 }) const secondDown = createPointerEvent('pointer_down', { x: 100, y: 100 }) const thirdDown = createPointerEvent('pointer_down', { x: 100, y: 100 }) clickManager.handlePointerEvent(firstDown) clickManager.handlePointerEvent(secondDown) - const result = clickManager.handlePointerEvent(thirdDown) as TLClickEventInfo + const result = clickManager.handlePointerEvent(thirdDown) - expect(result.type).toBe('click') - expect(result.name).toBe('triple_click') - expect(result.phase).toBe('down') - expect(clickManager.clickState).toBe('pendingQuadruple') + expect(result).toBe(thirdDown) + expect(clickManager.clickState).toBe('overflow') }) - it('should detect quadruple click on fourth pointer_down', () => { + it('should keep overflow active on further clicks', () => { const pointerDown = createPointerEvent('pointer_down', { x: 100, y: 100 }) clickManager.handlePointerEvent(pointerDown) // first clickManager.handlePointerEvent(pointerDown) // second (double_click) - clickManager.handlePointerEvent(pointerDown) // third (triple_click) - const result = clickManager.handlePointerEvent(pointerDown) as TLClickEventInfo // fourth - - expect(result.type).toBe('click') - expect(result.name).toBe('quadruple_click') - expect(result.phase).toBe('down') - expect(clickManager.clickState).toBe('pendingOverflow') - }) - - it('should handle overflow state after quadruple click', () => { - const pointerDown = createPointerEvent('pointer_down', { x: 100, y: 100 }) - - clickManager.handlePointerEvent(pointerDown) // first - clickManager.handlePointerEvent(pointerDown) // second - clickManager.handlePointerEvent(pointerDown) // third - clickManager.handlePointerEvent(pointerDown) // fourth - const result = clickManager.handlePointerEvent(pointerDown) // fifth + clickManager.handlePointerEvent(pointerDown) // third (overflow) + const result = clickManager.handlePointerEvent(pointerDown) // fourth expect(result).toBe(pointerDown) expect(clickManager.clickState).toBe('overflow') }) - it('should generate triple_click up event on pointer_up after triple_click down', () => { - const pointerDown = createPointerEvent('pointer_down', { x: 100, y: 100 }) - const pointerUp = createPointerEvent('pointer_up', { x: 100, y: 100 }) - - clickManager.handlePointerEvent(pointerDown) // first - clickManager.handlePointerEvent(pointerDown) // second - clickManager.handlePointerEvent(pointerDown) // third - const result = clickManager.handlePointerEvent(pointerUp) as TLClickEventInfo - - expect(result.type).toBe('click') - expect(result.name).toBe('triple_click') - expect(result.phase).toBe('up') - }) - - it('should generate quadruple_click up event on pointer_up after quadruple_click down', () => { + it('should not emit double_click up events while in overflow', () => { const pointerDown = createPointerEvent('pointer_down', { x: 100, y: 100 }) const pointerUp = createPointerEvent('pointer_up', { x: 100, y: 100 }) clickManager.handlePointerEvent(pointerDown) // first clickManager.handlePointerEvent(pointerDown) // second clickManager.handlePointerEvent(pointerDown) // third - clickManager.handlePointerEvent(pointerDown) // fourth - const result = clickManager.handlePointerEvent(pointerUp) as TLClickEventInfo + const result = clickManager.handlePointerEvent(pointerUp) - expect(result.type).toBe('click') - expect(result.name).toBe('quadruple_click') - expect(result.phase).toBe('up') + expect(result).toBe(pointerUp) + expect(clickManager.clickState).toBe('overflow') }) - }) - describe('timeout behavior and settle events', () => { - it('should dispatch triple_click settle event after timeout in pendingQuadruple', () => { + it('should return to idle after overflow timeout without dispatching a settle event', () => { const pointerDown = createPointerEvent('pointer_down', { x: 100, y: 100 }) clickManager.handlePointerEvent(pointerDown) // first clickManager.handlePointerEvent(pointerDown) // second - clickManager.handlePointerEvent(pointerDown) // third + clickManager.handlePointerEvent(pointerDown) // third -> overflow vi.advanceTimersByTime(350) - expect(editor.dispatch).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'click', - name: 'triple_click', - phase: 'settle', - }) - ) + expect(editor.dispatch).not.toHaveBeenCalled() expect(clickManager.clickState).toBe('idle') }) + }) - it('should dispatch quadruple_click settle event after timeout in pendingOverflow', () => { - const pointerDown = createPointerEvent('pointer_down', { x: 100, y: 100 }) + describe('timeout behavior and settle events', () => { + it('should track press/release state across the pending window (settle-down then release → settle-up)', () => { + const down = createPointerEvent('pointer_down', { x: 100, y: 100 }) + const up = createPointerEvent('pointer_up', { x: 100, y: 100 }) - clickManager.handlePointerEvent(pointerDown) // first - clickManager.handlePointerEvent(pointerDown) // second - clickManager.handlePointerEvent(pointerDown) // third - clickManager.handlePointerEvent(pointerDown) // fourth + clickManager.handlePointerEvent(down) + clickManager.handlePointerEvent(up) + clickManager.handlePointerEvent(down) // second press — pointer is down... + clickManager.handlePointerEvent(up) // ...but released before timeout vi.advanceTimersByTime(350) expect(editor.dispatch).toHaveBeenCalledWith( expect.objectContaining({ - type: 'click', - name: 'quadruple_click', - phase: 'settle', + name: 'double_click', + phase: 'settle-up', }) ) - expect(clickManager.clickState).toBe('idle') }) it('should use different timeout durations for different states', () => { @@ -316,7 +298,7 @@ describe('ClickManager', () => { expect(result.type).toBe('click') expect(result.name).toBe('double_click') - expect(clickManager.clickState).toBe('pendingTriple') + expect(clickManager.clickState).toBe('pendingOverflow') }) }) @@ -396,7 +378,7 @@ describe('ClickManager', () => { clickManager.handlePointerEvent(pointerDown) clickManager.handlePointerEvent(pointerDown) // double click - expect(clickManager.clickState).toBe('pendingTriple') + expect(clickManager.clickState).toBe('pendingOverflow') clickManager.cancelDoubleClickTimeout() @@ -416,9 +398,7 @@ describe('ClickManager', () => { // Get to overflow state clickManager.handlePointerEvent(pointerDown) // 1 clickManager.handlePointerEvent(pointerDown) // 2 - clickManager.handlePointerEvent(pointerDown) // 3 - clickManager.handlePointerEvent(pointerDown) // 4 - clickManager.handlePointerEvent(pointerDown) // 5 -> overflow + clickManager.handlePointerEvent(pointerDown) // 3 -> overflow expect(clickManager.clickState).toBe('overflow') diff --git a/packages/editor/src/lib/editor/managers/ClickManager/ClickManager.ts b/packages/editor/src/lib/editor/managers/ClickManager/ClickManager.ts index ff5df1f81b6e..706fe56a0dea 100644 --- a/packages/editor/src/lib/editor/managers/ClickManager/ClickManager.ts +++ b/packages/editor/src/lib/editor/managers/ClickManager/ClickManager.ts @@ -4,13 +4,7 @@ import type { Editor } from '../../Editor' import { TLClickEventInfo, TLPointerEventInfo } from '../../types/event-types' /** @public */ -export type TLClickState = - | 'idle' - | 'pendingDouble' - | 'pendingTriple' - | 'pendingQuadruple' - | 'pendingOverflow' - | 'overflow' +export type TLClickState = 'idle' | 'pendingDouble' | 'pendingOverflow' | 'overflow' const MAX_CLICK_DISTANCE = 40 @@ -26,6 +20,8 @@ export class ClickManager { private _previousScreenPoint?: Vec + private _isPressingWhilePending = false + @bind _getClickTimeout(state: TLClickState, id = uniqueId()) { this._clickId = id @@ -34,30 +30,12 @@ export class ClickManager { () => { if (this._clickState === state && this._clickId === id) { switch (this._clickState) { - case 'pendingTriple': { - this.editor.dispatch({ - ...this.lastPointerInfo, - type: 'click', - name: 'double_click', - phase: 'settle', - }) - break - } - case 'pendingQuadruple': { - this.editor.dispatch({ - ...this.lastPointerInfo, - type: 'click', - name: 'triple_click', - phase: 'settle', - }) - break - } case 'pendingOverflow': { this.editor.dispatch({ ...this.lastPointerInfo, type: 'click', - name: 'quadruple_click', - phase: 'settle', + name: 'double_click', + phase: this._isPressingWhilePending ? 'settle-down' : 'settle-up', }) break } @@ -100,6 +78,8 @@ export class ClickManager { if (!this._clickState) return info this._clickScreenPoint = Vec.From(info.point) + this._isPressingWhilePending = true + if ( this._previousScreenPoint && Vec.Dist2(this._previousScreenPoint, this._clickScreenPoint) > MAX_CLICK_DISTANCE ** 2 @@ -113,32 +93,12 @@ export class ClickManager { switch (this._clickState) { case 'pendingDouble': { - this._clickState = 'pendingTriple' - this._clickTimeout = this._getClickTimeout(this._clickState) - return { - ...info, - type: 'click', - name: 'double_click', - phase: 'down', - } - } - case 'pendingTriple': { - this._clickState = 'pendingQuadruple' - this._clickTimeout = this._getClickTimeout(this._clickState) - return { - ...info, - type: 'click', - name: 'triple_click', - phase: 'down', - } - } - case 'pendingQuadruple': { this._clickState = 'pendingOverflow' this._clickTimeout = this._getClickTimeout(this._clickState) return { ...info, type: 'click', - name: 'quadruple_click', + name: 'double_click', phase: 'down', } } @@ -159,30 +119,17 @@ export class ClickManager { } case 'pointer_up': { if (!this._clickState) return info + this._clickScreenPoint = Vec.From(info.point) + this._isPressingWhilePending = false + switch (this._clickState) { - case 'pendingTriple': { - return { - ...this.lastPointerInfo, - type: 'click', - name: 'double_click', - phase: 'up', - } - } - case 'pendingQuadruple': { - return { - ...this.lastPointerInfo, - type: 'click', - name: 'triple_click', - phase: 'up', - } - } case 'pendingOverflow': { return { ...this.lastPointerInfo, type: 'click', - name: 'quadruple_click', + name: 'double_click', phase: 'up', } } @@ -219,5 +166,8 @@ export class ClickManager { cancelDoubleClickTimeout() { this._clickTimeout = clearTimeout(this._clickTimeout) this._clickState = 'idle' + // when a double click is cancelled, we are no longer pending any further + // clicks, so we set this to false even if the user is still pressing + this._isPressingWhilePending = false } } diff --git a/packages/editor/src/lib/editor/tools/StateNode.ts b/packages/editor/src/lib/editor/tools/StateNode.ts index 5ba0087218e6..1f075e2acfea 100644 --- a/packages/editor/src/lib/editor/tools/StateNode.ts +++ b/packages/editor/src/lib/editor/tools/StateNode.ts @@ -268,8 +268,6 @@ export abstract class StateNode implements Partial { onLongPress?(info: TLPointerEventInfo): void onPointerUp?(info: TLPointerEventInfo): void onDoubleClick?(info: TLClickEventInfo): void - onTripleClick?(info: TLClickEventInfo): void - onQuadrupleClick?(info: TLClickEventInfo): void onRightClick?(info: TLPointerEventInfo): void onMiddleClick?(info: TLPointerEventInfo): void onKeyDown?(info: TLKeyboardEventInfo): void diff --git a/packages/editor/src/lib/editor/types/event-types.ts b/packages/editor/src/lib/editor/types/event-types.ts index f7bd763f6055..9f543a24bf93 100644 --- a/packages/editor/src/lib/editor/types/event-types.ts +++ b/packages/editor/src/lib/editor/types/event-types.ts @@ -24,7 +24,7 @@ export type TLPointerEventName = | 'middle_click' /** @public */ -export type TLCLickEventName = 'double_click' | 'triple_click' | 'quadruple_click' +export type TLCLickEventName = 'double_click' /** @public */ export type TLPinchEventName = 'pinch_start' | 'pinch' | 'pinch_end' @@ -72,7 +72,7 @@ export type TLClickEventInfo = TLBaseEventInfo & { point: VecLike pointerId: number button: number - phase: 'down' | 'up' | 'settle' + phase: 'down' | 'up' | 'settle-down' | 'settle-up' } & TLPointerEventTarget /** @public */ @@ -173,8 +173,6 @@ export interface TLEventHandlers { onLongPress: TLPointerEvent onRightClick: TLPointerEvent onDoubleClick: TLClickEvent - onTripleClick: TLClickEvent - onQuadrupleClick: TLClickEvent onMiddleClick: TLPointerEvent onPointerUp: TLPointerEvent onKeyDown: TLKeyboardEvent @@ -206,7 +204,5 @@ export const EVENT_NAME_MAP: Record< complete: 'onComplete', interrupt: 'onInterrupt', double_click: 'onDoubleClick', - triple_click: 'onTripleClick', - quadruple_click: 'onQuadrupleClick', tick: 'onTick', } diff --git a/packages/tldraw/api-report.api.md b/packages/tldraw/api-report.api.md index 99e942d577eb..3a210a178cc5 100644 --- a/packages/tldraw/api-report.api.md +++ b/packages/tldraw/api-report.api.md @@ -2411,10 +2411,6 @@ export class HandTool extends StateNode { static isLockable: boolean; // (undocumented) onDoubleClick(info: TLClickEventInfo): void; - // (undocumented) - onQuadrupleClick(info: TLClickEventInfo): void; - // (undocumented) - onTripleClick(info: TLClickEventInfo): void; } // @public (undocumented) diff --git a/packages/tldraw/src/lib/tools/HandTool/HandTool.ts b/packages/tldraw/src/lib/tools/HandTool/HandTool.ts index 6e427e88704b..6e3e20063303 100644 --- a/packages/tldraw/src/lib/tools/HandTool/HandTool.ts +++ b/packages/tldraw/src/lib/tools/HandTool/HandTool.ts @@ -13,35 +13,11 @@ export class HandTool extends StateNode { } override onDoubleClick(info: TLClickEventInfo) { - if (info.phase === 'settle') { + if (info.phase === 'settle-up') { const currentScreenPoint = this.editor.inputs.getCurrentScreenPoint() this.editor.zoomIn(currentScreenPoint, { animation: { duration: 220, easing: EASINGS.easeOutQuint }, }) } } - - override onTripleClick(info: TLClickEventInfo) { - if (info.phase === 'settle') { - const currentScreenPoint = this.editor.inputs.getCurrentScreenPoint() - this.editor.zoomOut(currentScreenPoint, { - animation: { duration: 320, easing: EASINGS.easeOutQuint }, - }) - } - } - - override onQuadrupleClick(info: TLClickEventInfo) { - if (info.phase === 'settle') { - const zoomLevel = this.editor.getZoomLevel() - const currentScreenPoint = this.editor.inputs.getCurrentScreenPoint() - - if (zoomLevel === 1) { - this.editor.zoomToFit({ animation: { duration: 400, easing: EASINGS.easeOutQuint } }) - } else { - this.editor.resetZoom(currentScreenPoint, { - animation: { duration: 320, easing: EASINGS.easeOutQuint }, - }) - } - } - } } diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/Crop/Crop.ts b/packages/tldraw/src/lib/tools/SelectTool/childStates/Crop/Crop.ts index bbc64484a3e9..79f32e3858c2 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/childStates/Crop/Crop.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/Crop/Crop.ts @@ -22,7 +22,9 @@ export class Crop extends StateNode { override onExit() { if (!this.didExit) { this.didExit = true - this.editor.squashToMark(this.markId) + if (this.editor.getMarkIdMatching(this.markId) === this.markId) { + this.editor.squashToMark(this.markId) + } } } override onCancel() { diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/Crop/children/Idle.ts b/packages/tldraw/src/lib/tools/SelectTool/childStates/Crop/children/Idle.ts index 4aa9f8916b86..3edfee1d8465 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/childStates/Crop/children/Idle.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/Crop/children/Idle.ts @@ -139,9 +139,9 @@ export class Idle extends StateNode { } override onDoubleClick(info: TLClickEventInfo) { - // Without this, the double click's "settle" would trigger the reset + // Without this, the double click's settle event would trigger the reset // after the user double clicked the edge to begin cropping - if (this.editor.inputs.getShiftKey() || info.phase !== 'up') return + if (this.editor.inputs.getShiftKey() || info.phase !== 'down') return const croppingShapeId = this.editor.getCroppingShapeId() if (!croppingShapeId) return diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/Crop/children/PointingCrop.ts b/packages/tldraw/src/lib/tools/SelectTool/childStates/Crop/children/PointingCrop.ts index cdc9652a198f..15e40ea97458 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/childStates/Crop/children/PointingCrop.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/Crop/children/PointingCrop.ts @@ -1,4 +1,4 @@ -import { StateNode, TLPointerEventInfo } from '@tldraw/editor' +import { StateNode, TLClickEventInfo, TLPointerEventInfo } from '@tldraw/editor' export class PointingCrop extends StateNode { static override id = 'pointing_crop' @@ -20,6 +20,20 @@ export class PointingCrop extends StateNode { this.editor.setCurrentTool('select.crop.idle', info) } + override onDoubleClick(info: TLClickEventInfo) { + if ( + this.editor.inputs.getShiftKey() || + info.phase !== 'down' || + info.ctrlKey || + info.shiftKey + ) { + return + } + + this.parent.transition('idle') + this.parent.getCurrent()?.handleEvent(info) + } + startDragging(info: TLPointerEventInfo) { this.editor.setCurrentTool('select.crop.translating_crop', info) } diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/Crop/children/PointingCropHandle.ts b/packages/tldraw/src/lib/tools/SelectTool/childStates/Crop/children/PointingCropHandle.ts index 8f8585d80ff7..de3953ef9083 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/childStates/Crop/children/PointingCropHandle.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/Crop/children/PointingCropHandle.ts @@ -1,4 +1,4 @@ -import { StateNode, TLPointerEventInfo } from '@tldraw/editor' +import { StateNode, TLClickEventInfo, TLPointerEventInfo } from '@tldraw/editor' import { CursorTypeMap } from '../../PointingResizeHandle' type TLPointingCropHandleInfo = TLPointerEventInfo & { @@ -62,6 +62,20 @@ export class PointingCropHandle extends StateNode { this.editor.setCurrentTool('select.idle') } + override onDoubleClick(info: TLClickEventInfo) { + if ( + this.editor.inputs.getShiftKey() || + info.phase !== 'down' || + info.ctrlKey || + info.shiftKey + ) { + return + } + + this.parent.transition('idle') + this.parent.getCurrent()?.handleEvent(info) + } + override onCancel() { this.cancel() } diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/Idle.ts b/packages/tldraw/src/lib/tools/SelectTool/childStates/Idle.ts index cd37bfbcc44b..8b15ad1b5e03 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/childStates/Idle.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/Idle.ts @@ -261,7 +261,7 @@ export class Idle extends StateNode { } override onDoubleClick(info: TLClickEventInfo) { - if (this.editor.inputs.getShiftKey() || info.phase !== 'up') return + if (this.editor.inputs.getShiftKey() || info.phase !== 'down') return // We don't want to double click while toggling shapes if (info.ctrlKey || info.shiftKey) return diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingCanvas.ts b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingCanvas.ts index 9378dce402c3..288d2becedca 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingCanvas.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingCanvas.ts @@ -1,4 +1,4 @@ -import { StateNode, TLPointerEventInfo } from '@tldraw/editor' +import { StateNode, TLClickEventInfo, TLPointerEventInfo } from '@tldraw/editor' import { selectOnCanvasPointerUp } from '../../selection-logic/selectOnCanvasPointerUp' export class PointingCanvas extends StateNode { @@ -27,6 +27,20 @@ export class PointingCanvas extends StateNode { this.complete() } + override onDoubleClick(info: TLClickEventInfo) { + if ( + this.editor.inputs.getShiftKey() || + info.phase !== 'down' || + info.ctrlKey || + info.shiftKey + ) { + return + } + + this.parent.transition('idle') + this.parent.getCurrent()?.handleEvent(info) + } + override onComplete() { this.complete() } diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingHandle.ts b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingHandle.ts index d22a9cb5ec3d..20aa41e6bdb3 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingHandle.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingHandle.ts @@ -1,4 +1,12 @@ -import { Editor, StateNode, TLHandle, TLNoteShape, TLPointerEventInfo, Vec } from '@tldraw/editor' +import { + Editor, + StateNode, + TLClickEventInfo, + TLHandle, + TLNoteShape, + TLPointerEventInfo, + Vec, +} from '@tldraw/editor' import { updateArrowTargetState } from '../../../shapes/arrow/arrowTargetState' import { getArrowBindings } from '../../../shapes/arrow/shared' import { @@ -63,6 +71,20 @@ export class PointingHandle extends StateNode { this.parent.transition('idle', this.info) } + override onDoubleClick(info: TLClickEventInfo) { + if ( + this.editor.inputs.getShiftKey() || + info.phase !== 'down' || + info.ctrlKey || + info.shiftKey + ) { + return + } + + this.parent.transition('idle') + this.parent.getCurrent()?.handleEvent(info) + } + override onPointerMove(info: TLPointerEventInfo) { const { editor } = this if (editor.inputs.getIsDragging()) { diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingResizeHandle.ts b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingResizeHandle.ts index c407ca299473..c06bb32a0c66 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingResizeHandle.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingResizeHandle.ts @@ -1,4 +1,10 @@ -import { StateNode, TLCursorType, TLPointerEventInfo, TLSelectionHandle } from '@tldraw/editor' +import { + StateNode, + TLClickEventInfo, + TLCursorType, + TLPointerEventInfo, + TLSelectionHandle, +} from '@tldraw/editor' export const CursorTypeMap: Record = { bottom: 'ns-resize', @@ -64,6 +70,20 @@ export class PointingResizeHandle extends StateNode { this.complete() } + override onDoubleClick(info: TLClickEventInfo) { + if ( + this.editor.inputs.getShiftKey() || + info.phase !== 'down' || + info.ctrlKey || + info.shiftKey + ) { + return + } + + this.parent.transition('idle') + this.parent.getCurrent()?.handleEvent(info) + } + override onCancel() { this.cancel() } diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingRotateHandle.ts b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingRotateHandle.ts index 28dcf4543282..187cc97f5c17 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingRotateHandle.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingRotateHandle.ts @@ -1,4 +1,4 @@ -import { RotateCorner, StateNode, TLPointerEventInfo } from '@tldraw/editor' +import { RotateCorner, StateNode, TLClickEventInfo, TLPointerEventInfo } from '@tldraw/editor' import { CursorTypeMap } from './PointingResizeHandle' type PointingRotateHandleInfo = Extract & { @@ -49,6 +49,20 @@ export class PointingRotateHandle extends StateNode { this.complete() } + override onDoubleClick(info: TLClickEventInfo) { + if ( + this.editor.inputs.getShiftKey() || + info.phase !== 'down' || + info.ctrlKey || + info.shiftKey + ) { + return + } + + this.parent.transition('idle') + this.parent.getCurrent()?.handleEvent(info) + } + override onCancel() { this.cancel() } diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingSelection.ts b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingSelection.ts index 46ffa82392a0..57f31e29f7fc 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingSelection.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingSelection.ts @@ -32,7 +32,16 @@ export class PointingSelection extends StateNode { this.parent.transition('translating', info) } - override onDoubleClick?(info: TLClickEventInfo) { + override onDoubleClick(info: TLClickEventInfo) { + if ( + this.editor.inputs.getShiftKey() || + info.phase !== 'down' || + info.ctrlKey || + info.shiftKey + ) { + return + } + const hoveredShape = this.editor.getHoveredShape() const hitShape = hoveredShape && !this.editor.isShapeOfType(hoveredShape, 'group') @@ -46,7 +55,7 @@ export class PointingSelection extends StateNode { if (hitShape) { // todo: extract the double click shape logic from idle so that we can share it here this.parent.transition('idle') - this.parent.onDoubleClick?.({ + this.parent.getCurrent()?.handleEvent({ ...info, target: 'shape', shape: this.editor.getShape(hitShape)!, diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingShape.ts b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingShape.ts index f7426aabc4a8..e1711084142e 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingShape.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingShape.ts @@ -1,4 +1,4 @@ -import { StateNode, TLPointerEventInfo, TLShape } from '@tldraw/editor' +import { StateNode, TLClickEventInfo, TLPointerEventInfo, TLShape } from '@tldraw/editor' import { isOverArrowLabel } from '../../../shapes/arrow/arrowLabel' import { getTextLabels } from '../../../utils/shapes/shapes' @@ -195,8 +195,24 @@ export class PointingShape extends StateNode { this.parent.transition('idle', info) } - override onDoubleClick() { + override onDoubleClick(info: TLClickEventInfo) { this.isDoubleClick = true + + if ( + this.editor.inputs.getShiftKey() || + info.phase !== 'down' || + info.ctrlKey || + info.shiftKey + ) { + return + } + + const { shape: _shape, ...canvasInfo } = info as TLClickEventInfo & { target: 'shape' } + this.parent.transition('idle') + this.parent.getCurrent()?.handleEvent({ + ...canvasInfo, + target: 'canvas', + }) } override onPointerMove(info: TLPointerEventInfo) { diff --git a/packages/tldraw/src/test/ClickManager.test.ts b/packages/tldraw/src/test/ClickManager.test.ts index 0be442cb3586..715f3b5accdf 100644 --- a/packages/tldraw/src/test/ClickManager.test.ts +++ b/packages/tldraw/src/test/ClickManager.test.ts @@ -64,12 +64,12 @@ describe('Handles events', () => { expect(events[i]).toMatchObject(eventsBeforeSettle[i]) } - // allow double click to settle + // allow double click to settle as 'settle-up' vi.advanceTimersByTime(500) expect(events).toMatchObject([ ...eventsBeforeSettle, - { name: 'double_click', type: 'click', phase: 'settle' }, + { name: 'double_click', type: 'click', phase: 'settle-up' }, ]) // clear events and click again @@ -84,7 +84,28 @@ describe('Handles events', () => { ]) }) - it('Emits triple click events', () => { + it('Emits settle-down when pointer is still pressed at settle time', () => { + const events: any[] = [] + editor.addListener('event', (info) => events.push(info)) + + editor.pointerDown() + editor.pointerUp() + editor.pointerDown() // second press, no pointerUp — still down at settle time + + vi.advanceTimersByTime(500) + + // a long_press may also fire while holding; find the settle event by type + const settle = events.find( + (e) => e.type === 'click' && e.name === 'double_click' && e.phase?.startsWith('settle') + ) + expect(settle).toMatchObject({ + name: 'double_click', + type: 'click', + phase: 'settle-down', + }) + }) + + it('Suppresses overflow clicks after a double click', () => { const events: any[] = [] editor.addListener('event', (info) => events.push(info)) @@ -103,20 +124,15 @@ describe('Handles events', () => { { name: 'pointer_up' }, { name: 'double_click', type: 'click', phase: 'up' }, { name: 'pointer_down' }, - { name: 'triple_click', type: 'click', phase: 'down' }, { name: 'pointer_up' }, - { name: 'triple_click', type: 'click', phase: 'up' }, ] - expect(eventsBeforeSettle).toMatchObject(eventsBeforeSettle) + expect(events).toMatchObject(eventsBeforeSettle) - // allow double click to settle + // allow overflow to settle without emitting another click event vi.advanceTimersByTime(500) - expect(events).toMatchObject([ - ...eventsBeforeSettle, - { name: 'triple_click', type: 'click', phase: 'settle' }, - ]) + expect(events).toMatchObject(eventsBeforeSettle) // clear events and click again // the interaction should have reset @@ -130,7 +146,7 @@ describe('Handles events', () => { ]) }) - it('Emits quadruple click events', () => { + it('Suppresses double-double clicks until overflow settles', () => { const events: any[] = [] editor.addListener('event', (info) => events.push(info)) @@ -151,24 +167,17 @@ describe('Handles events', () => { { name: 'pointer_up' }, { name: 'double_click', phase: 'up' }, { name: 'pointer_down' }, - { name: 'triple_click', phase: 'down' }, { name: 'pointer_up' }, - { name: 'triple_click', phase: 'up' }, { name: 'pointer_down' }, - { name: 'quadruple_click', phase: 'down' }, { name: 'pointer_up' }, - { name: 'quadruple_click', phase: 'up' }, ] expect(events).toMatchObject(eventsBeforeSettle) - // allow double click to settle + // allow overflow to settle vi.advanceTimersByTime(500) - expect(events).toMatchObject([ - ...eventsBeforeSettle, - { name: 'quadruple_click', type: 'click', phase: 'settle' }, - ]) + expect(events).toMatchObject(eventsBeforeSettle) // clear events and click again // the interaction should have reset @@ -182,7 +191,7 @@ describe('Handles events', () => { ]) }) - it('Emits overflow click events', () => { + it('Keeps suppressing clicks while overflow clicks continue', () => { const events: any[] = [] editor.addListener('event', (info) => events.push(info)) @@ -205,13 +214,9 @@ describe('Handles events', () => { { name: 'pointer_up' }, { name: 'double_click', type: 'click', phase: 'up' }, { name: 'pointer_down' }, - { name: 'triple_click', type: 'click', phase: 'down' }, { name: 'pointer_up' }, - { name: 'triple_click', type: 'click', phase: 'up' }, { name: 'pointer_down' }, - { name: 'quadruple_click', type: 'click', phase: 'down' }, { name: 'pointer_up' }, - { name: 'quadruple_click', type: 'click', phase: 'up' }, { name: 'pointer_down' }, { name: 'pointer_up' }, ] @@ -248,9 +253,9 @@ it('Cancels when click moves', () => { editor.pointerUp(0, 20) expect(event.name).toBe('double_click') editor.pointerDown(0, 45) - expect(event.name).toBe('triple_click') + expect(event.name).toBe('pointer_down') editor.pointerUp(0, 45) - expect(event.name).toBe('triple_click') + expect(event.name).toBe('pointer_up') // has to be 40 away from previous click location editor.pointerDown(0, 86) expect(event.name).toBe('pointer_down') diff --git a/packages/tldraw/src/test/HandTool.test.ts b/packages/tldraw/src/test/HandTool.test.ts index eba513630137..57d53152dd93 100644 --- a/packages/tldraw/src/test/HandTool.test.ts +++ b/packages/tldraw/src/test/HandTool.test.ts @@ -1,6 +1,6 @@ import { vi } from 'vitest' import { HandTool } from '../lib/tools/HandTool/HandTool' -import { TestEditor, createDefaultShapes } from './TestEditor' +import { TestEditor } from './TestEditor' let editor: TestEditor @@ -23,52 +23,23 @@ describe(HandTool, () => { expect(editor.getZoomLevel()).toBe(1) editor.click() editor.click() // double click! + expect(editor.getZoomLevel()).toBe(1) vi.advanceTimersByTime(300) expect(editor.getZoomLevel()).not.toBe(1) // animating vi.advanceTimersByTime(300) expect(editor.getZoomLevel()).toBe(2) // all done }) - it('Triple taps to zoom out', () => { + it('Does not zoom on overflow taps', () => { editor.setCurrentTool('hand') expect(editor.getZoomLevel()).toBe(1) editor.click() editor.click() - editor.click() // triple click! - vi.advanceTimersByTime(300) - expect(editor.getZoomLevel()).not.toBe(1) // animating - vi.advanceTimersByTime(300) - expect(editor.getZoomLevel()).toBe(0.5) // all done - }) - - it('Quadruple taps to reset zoom', () => { - editor.setCurrentTool('hand') - editor.zoomIn() // zoom to 2 - expect(editor.getZoomLevel()).toBe(2) - editor.click() - editor.click() - editor.click() - editor.click() // quad click! + editor.click() // overflow click vi.advanceTimersByTime(300) - expect(editor.getZoomLevel()).not.toBe(2) // animating - vi.advanceTimersByTime(300) - expect(editor.getZoomLevel()).toBe(1) // all done - }) - - it('Quadruple taps from zoom=1 to zoom to fit', () => { - editor.setCurrentTool('hand') expect(editor.getZoomLevel()).toBe(1) - editor.createShapes(createDefaultShapes()) // makes some shapes - editor.click() - editor.click() - editor.click() - editor.click() // quad click! vi.advanceTimersByTime(300) - expect(editor.getZoomLevel()).not.toBe(1) // animating - vi.advanceTimersByTime(300) - const z = editor.getZoomLevel() - editor.zoomToFit() // call zoom to fit manually to compare - expect(editor.getZoomLevel()).toBe(z) // zoom should not have changed + expect(editor.getZoomLevel()).toBe(1) }) }) diff --git a/packages/tldraw/src/test/cropping.test.ts b/packages/tldraw/src/test/cropping.test.ts index 1a59e82d04d3..d18d8db84008 100644 --- a/packages/tldraw/src/test/cropping.test.ts +++ b/packages/tldraw/src/test/cropping.test.ts @@ -8,6 +8,7 @@ vi.useFakeTimers() let editor: TestEditor afterEach(() => { + vi.restoreAllMocks() editor?.dispose() }) @@ -85,6 +86,8 @@ beforeEach(() => { describe('When in the select.idle state', () => { it('double clicking an image should transition to select.crop', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + editor.select(ids.boxA) editor.expectToBeIn('select.idle') @@ -130,6 +133,10 @@ describe('When in the select.idle state', () => { expect(editor.getOnlySelectedShape()!.props).toMatchObject({ crop: { topLeft: { x: 0.1, y: 0.1 }, bottomRight: { x: 0.9, y: 0.9 } }, }) + expect(consoleErrorSpy).not.toHaveBeenCalledWith( + 'Could not find mark to squash to: ', + expect.any(String) + ) }) it('when ONLY ONE image is selected double clicking a selection handle should transition to select.crop', () => { diff --git a/packages/tldraw/src/test/selection-omnibus.test.ts b/packages/tldraw/src/test/selection-omnibus.test.ts index 17099106e2f9..db40d077aef6 100644 --- a/packages/tldraw/src/test/selection-omnibus.test.ts +++ b/packages/tldraw/src/test/selection-omnibus.test.ts @@ -1562,17 +1562,17 @@ describe('When double clicking an editable shape', () => { editor.expectToBeIn('select.editing_shape') }) - it('starts editing a child of a group on triple (not double!) click', () => { + it('starts editing a child of a group after selecting into the group', () => { editor.createShape({ id: ids.box2, type: 'geo', x: 300, y: 0 }) editor.groupShapes([ids.box1, ids.box2], { groupId: ids.group1 }) editor.selectNone() - editor.pointerMove(50, 50).click() // clicks on the shape label + editor.pointerMove(50, 50).click() // selects the group expect(editor.getSelectedShapeIds()).toEqual([ids.group1]) expect(editor.getEditingShapeId()).toBe(null) - editor.pointerMove(50, 50).click() // clicks on the shape label + editor.pointerMove(50, 50).click() // selects the child shape expect(editor.getSelectedShapeIds()).toEqual([ids.box1]) expect(editor.getEditingShapeId()).toBe(null) - editor.pointerMove(50, 50).click() // clicks on the shape label + editor.pointerMove(50, 50).click() // edits the selected child shape expect(editor.getSelectedShapeIds()).toEqual([ids.box1]) expect(editor.getEditingShapeId()).toBe(ids.box1) editor.expectToBeIn('select.editing_shape') From 52bf73b34fcf90c8110d86a5f0a99f479c53bd5d Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Fri, 5 Jun 2026 12:23:12 +0100 Subject: [PATCH 5/9] chore(examples): disable console.createTask to declutter Performance flame chart (#8312) In order to make the Chrome Performance flame chart more readable when profiling the examples app, this PR disables React's `console.createTask` instrumentation by setting it to `undefined` before the app script loads. React uses `console.createTask` to wrap every component render in a "Run console task" entry. While this provides async stack traces in DevTools, it adds significant visual noise to the Performance panel's flame chart, making it harder to identify actual performance bottlenecks. ### Change type - [x] `improvement` ### Test plan 1. Open the examples app in Chrome 2. Record a Performance trace 3. Verify the flame chart no longer shows "Run console task" wrappers around component renders ### Release notes - N/A (internal development tooling change) ### Code changes | Section | LOC change | | --------------- | ---------- | | Apps | +6 / -0 | --- apps/examples/src/index.html | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/examples/src/index.html b/apps/examples/src/index.html index c933b376cdf9..6b8774408cf8 100644 --- a/apps/examples/src/index.html +++ b/apps/examples/src/index.html @@ -10,6 +10,14 @@
+ From 4a316fdfb2bba80206ac5aa94fb23b6886d6c507 Mon Sep 17 00:00:00 2001 From: Kevin Ingersoll Date: Fri, 5 Jun 2026 12:47:50 +0100 Subject: [PATCH 6/9] chore(deps): bump jsdom to v29 (#9019) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In order to run tests against a modern jsdom — and specifically to get a native `PointerEvent` constructor (jsdom 25 has none, which forces hand-rolled mocks) — this PR bumps `jsdom` from v25.0.1 to v29.1.1, fixes the test fallout the upgrade surfaces, and then removes the test polyfills jsdom now implements natively. It's split into two commits so the upgrade and the polyfill cleanup review independently. Relates to #9006, where a new test had to polyfill `PointerEvent` because of the old jsdom. ### What changed between jsdom v25 and v29 Newly available behaviours (relied on or cleaned up here): - **`PointerEvent` constructor** (v27) — implemented (without jsdom ever firing pointer events itself); preserves `pointerId`, `pointerType`, `clientX/Y`, etc. - **`TouchEvent` constructor** (v27) — implemented; preserves `touches`. - **`movementX`/`movementY` on `MouseEvent`** (v27). - **`TextEncoder`/`TextDecoder`** on jsdom `Window`s (v27.4) — and exposed on the vitest global, so the hand-rolled polyfills are now redundant. - Other event constructors (v27): `BeforeUnloadEvent`, `BlobEvent`, `DeviceMotionEvent`, `DeviceOrientationEvent`, `PromiseRejectionEvent`, `TransitionEvent`. - New CSS selector engine (v27), CSSOM overhaul (v29), `blob.text()/arrayBuffer()/bytes()` (v28.1), `getComputedStyle()` specificity and `!important` fixes (v28.1). Breaking changes handled: - **`element.click()` now fires a `PointerEvent` instead of a `MouseEvent`** (v27). No fallout — our suites drive clicks through the editor, not via `element.click()` with `MouseEvent` assertions. - **jsdom now fires real pointer events**, so React's canvas handlers reach the pointer-capture API, which jsdom still doesn't implement. Added no-op `setPointerCapture`/`releasePointerCapture`/`hasPointerCapture` stubs to the shared vitest setup. - **CSSOM overhaul (v29)** changed SVG-export style serialization. Updated the `getSvgString` snapshot — it now serializes the shapes' real styles instead of `getComputedStyle` polyfill placeholders (a fidelity improvement). - **jsdom 28+ ships a global `WebSocket`** (its bundled undici) whose events fail the Node `EventTarget` realm check in the jsdom test environment. sync-core tests now point the global `WebSocket` at the `ws` package, matching the `WebSocketServer` they connect to. - **Minimum Node** rose to v20.19.0+ / v22.13.0+ / v24.0.0+ (v27.0.1, v29.0.0). CI runs Node 24.13.1, so this is fine. ### Polyfills removed vs kept (commit 2) Each shim was removed and the affected suites (editor 809, tldraw 2468, sync-core 608) re-run to confirm they stay green. Removed (jsdom 29 / the vitest jsdom env provides them natively): - `PointerEvent` and `TouchEvent` constructor mocks (jsdom added these in v27) - the `DOMRect` polyfill (jsdom's native `DOMRect` is complete and spec-accurate) - the `TextEncoder`/`TextDecoder` polyfills — not just redundant but counterproductive: the shared `setup.ts` did `import { TextEncoder } from 'util'`, whose broken ESM interop on the CJS builtin bound to `undefined` and *clobbered* the native global, which the per-package `require()` calls then merely restored. - the `requestAnimationFrame`/`cancelAnimationFrame` polyfill (guarded; jsdom provides both) - the `delete global.crypto; global.crypto = require('crypto')` reassignment in `setup.ts` (jsdom's `window.crypto` already exposes `subtle`; the `node:crypto` module does not). The `@peculiar/webcrypto` fallback for crypto-less environments is kept. - the guarded `CSSStyleDeclaration` `getPropertyValue`/`setProperty`/`removeProperty` fallbacks (jsdom implements all three) Kept (jsdom 29 still doesn't provide these, or the shim does more than the native API): - the `DragEvent` mock (jsdom has no `DragEvent` constructor) - `ResizeObserver`, `FontFace`, `document.fonts`, `window.matchMedia`, `URL.createObjectURL`, `HTMLImageElement.prototype.decode`, `CSS.supports`, `Path2D.prototype.roundRect`, `fake-indexeddb` - the `@peculiar/webcrypto` crypto fallback, the sync-core `WebSocket` override, and the tldraw `getComputedStyle` override ### Change type - [x] `other` ### Test plan - jsdom-based suites pass: `packages/editor` (809), `packages/tldraw` (2468), `packages/sync-core` (608), `packages/mermaid` (53), `apps/docs` (18), `apps/dotcom/client` (133). - Each polyfill removal was independently verified by removing it and re-running the affected suites; ones that broke (e.g. the native `WebSocket`, `DragEvent`) were kept. - `yarn typecheck` passes; no public API changes (api-extractor reports unchanged). - [x] Unit tests - [ ] End to end tests ### Code changes | Section | LOC change | | --------------- | ----------- | | Automated files | +4 / -6 | | Config/tooling | +244 / -257 | --- internal/config/vitest/setup.ts | 41 +- package.json | 2 +- packages/editor/setupVitest.js | 54 --- packages/sync-core/setupVitest.js | 9 +- packages/tldraw/setupVitest.js | 38 -- .../__snapshots__/getSvgString.test.ts.snap | 10 +- yarn.lock | 357 +++++++++++------- 7 files changed, 248 insertions(+), 263 deletions(-) diff --git a/internal/config/vitest/setup.ts b/internal/config/vitest/setup.ts index 3472b56c8d38..98c1c4167b4b 100644 --- a/internal/config/vitest/setup.ts +++ b/internal/config/vitest/setup.ts @@ -1,5 +1,3 @@ -import crypto from 'crypto' -import { TextDecoder, TextEncoder } from 'util' import { equals, getObjectSubset, iterableEquality, subsetEquality } from '@jest/expect-utils' import { matcherHint, @@ -13,27 +11,8 @@ if (typeof window !== 'undefined') { await import('vitest-canvas-mock') } -// Polyfill for requestAnimationFrame (equivalent to raf/polyfill) -if (typeof globalThis.requestAnimationFrame === 'undefined') { - globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { - return Number(setTimeout(() => cb(Date.now()), 16)) - } -} - -if (typeof globalThis.cancelAnimationFrame === 'undefined') { - globalThis.cancelAnimationFrame = (id: number) => { - clearTimeout(id) - } -} - -// Global polyfills -global.TextEncoder = TextEncoder as typeof global.TextEncoder -global.TextDecoder = TextDecoder as typeof global.TextDecoder -// @ts-expect-error - cannot delete non-optional property -delete global.crypto -global.crypto = crypto as any - -// Crypto polyfill (needed for ai package) +// Crypto fallback for environments without a native WebCrypto implementation (e.g. the ai package). +// jsdom provides window.crypto with subtle crypto, so this only kicks in elsewhere. if (typeof globalThis.crypto === 'undefined') { // eslint-disable-next-line @typescript-eslint/no-require-imports const { Crypto } = require('@peculiar/webcrypto') @@ -65,6 +44,22 @@ if (typeof CSS.supports === 'undefined') { CSS.supports = () => false } +// Pointer capture polyfill. jsdom implements the PointerEvent constructor but not the pointer +// capture model, so setPointerCapture/releasePointerCapture/hasPointerCapture are missing +// (https://github.com/jsdom/jsdom/pull/2666). Our canvas event handlers capture the pointer on +// pointerdown/up, so stub them out to avoid throwing. +if (typeof Element !== 'undefined') { + Element.prototype.setPointerCapture ??= function () { + // noop + } + Element.prototype.releasePointerCapture ??= function () { + // noop + } + Element.prototype.hasPointerCapture ??= function () { + return false + } +} + function convertNumbersInObject(obj: any, roundToNearest: number): any { if (!obj) return obj if (Array.isArray(obj)) { diff --git a/package.json b/package.json index 484e60f85f4d..f84c6fd063e0 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,7 @@ "docx": "^9.5.1", "fs-extra": "^11.3.0", "husky": "^8.0.3", - "jsdom": "^25.0.1", + "jsdom": "^29.1.1", "json5": "^2.2.3", "lazyrepo": "0.0.0-alpha.27", "license-report": "^6.7.1", diff --git a/packages/editor/setupVitest.js b/packages/editor/setupVitest.js index 6a118c6d5921..1276acfbc1a4 100644 --- a/packages/editor/setupVitest.js +++ b/packages/editor/setupVitest.js @@ -24,10 +24,6 @@ document.fonts = { [Symbol.iterator]: () => [][Symbol.iterator](), } -// Text encoding/decoding polyfills -global.TextEncoder = require('util').TextEncoder -global.TextDecoder = require('util').TextDecoder - // Window fetch mock for network requests window.fetch = async (input, init) => { return { @@ -49,44 +45,6 @@ window.matchMedia = (query) => ({ dispatchEvent: () => {}, }) -// Enhanced DOM API mocking for CSSStyleDeclaration -// This ensures all HTML elements have the required style methods -if (typeof CSSStyleDeclaration !== 'undefined') { - if (!CSSStyleDeclaration.prototype.getPropertyValue) { - CSSStyleDeclaration.prototype.getPropertyValue = function (property) { - return this[property] || '' - } - } - if (!CSSStyleDeclaration.prototype.setProperty) { - CSSStyleDeclaration.prototype.setProperty = function (property, value) { - this[property] = value - } - } - if (!CSSStyleDeclaration.prototype.removeProperty) { - CSSStyleDeclaration.prototype.removeProperty = function (property) { - delete this[property] - } - } -} - -// Mock PointerEvent for jsdom -if (typeof window !== 'undefined' && !window.PointerEvent) { - global.PointerEvent = class PointerEvent extends Event { - pointerId = 1 - pointerType = 'mouse' - clientX = 0 - clientY = 0 - - constructor(type, options = {}) { - super(type, options) - this.pointerId = options.pointerId || 1 - this.pointerType = options.pointerType || 'mouse' - this.clientX = options.clientX || 0 - this.clientY = options.clientY || 0 - } - } -} - // Mock DragEvent for jsdom if (typeof window !== 'undefined' && !window.DragEvent) { global.DragEvent = class DragEvent extends Event { @@ -106,15 +64,3 @@ if (typeof window !== 'undefined' && !window.DragEvent) { } } } - -// Mock TouchEvent for jsdom -if (typeof window !== 'undefined' && !window.TouchEvent) { - global.TouchEvent = class TouchEvent extends Event { - touches = [] - - constructor(type, options = {}) { - super(type, options) - this.touches = options.touches || [] - } - } -} diff --git a/packages/sync-core/setupVitest.js b/packages/sync-core/setupVitest.js index d0ebba3ace33..32aecb92a204 100644 --- a/packages/sync-core/setupVitest.js +++ b/packages/sync-core/setupVitest.js @@ -21,9 +21,12 @@ document.fonts = { forEach: () => {}, } -// Text encoding/decoding polyfills -global.TextEncoder = require('util').TextEncoder -global.TextDecoder = require('util').TextDecoder +// jsdom ships a global WebSocket built on its bundled copy of undici. In this jsdom test +// environment its events are created in a different realm than the Node EventTarget they're +// dispatched on, so connecting throws "The 'event' argument must be an instance of Event". Use +// the `ws` package's WebSocket for client connections instead, matching the WebSocketServer the +// tests connect to. +global.WebSocket = require('ws').WebSocket // Window.matchMedia polyfill for media queries Object.defineProperty(window, 'matchMedia', { diff --git a/packages/tldraw/setupVitest.js b/packages/tldraw/setupVitest.js index 2269f76701f4..2ae97fc53de3 100644 --- a/packages/tldraw/setupVitest.js +++ b/packages/tldraw/setupVitest.js @@ -113,23 +113,6 @@ window.fetch = async (input, init) => { throw new Error(`Unhandled request: ${input}`) } -// DOMRect polyfill for DOM rectangle calculations -window.DOMRect = class DOMRect { - static fromRect(rect) { - return new DOMRect(rect.x, rect.y, rect.width, rect.height) - } - constructor(x, y, width, height) { - this.x = x - this.y = y - this.width = width - this.height = height - } -} - -// Text encoding/decoding polyfills -global.TextEncoder = require('util').TextEncoder -global.TextDecoder = require('util').TextDecoder - // Image API polyfills for jsdom - add decode method to HTMLImageElement prototype if (typeof HTMLImageElement !== 'undefined') { if (!HTMLImageElement.prototype.decode) { @@ -209,24 +192,3 @@ Object.defineProperty(window, 'getComputedStyle', { return style }, }) - -// Enhanced DOM API mocking for CSSStyleDeclaration -// This ensures all HTML elements have the required style methods -if (typeof CSSStyleDeclaration !== 'undefined') { - if (!CSSStyleDeclaration.prototype.getPropertyValue) { - CSSStyleDeclaration.prototype.getPropertyValue = function (property) { - return this[property] || '' - } - } - if (!CSSStyleDeclaration.prototype.setProperty) { - CSSStyleDeclaration.prototype.setProperty = function (property, value) { - this[property] = value - } - } - if (!CSSStyleDeclaration.prototype.removeProperty) { - CSSStyleDeclaration.prototype.removeProperty = function (property) { - delete this[property] - return '' - } - } -} diff --git a/packages/tldraw/src/test/commands/__snapshots__/getSvgString.test.ts.snap b/packages/tldraw/src/test/commands/__snapshots__/getSvgString.test.ts.snap index 18604f8f760e..b9c200b929d2 100644 --- a/packages/tldraw/src/test/commands/__snapshots__/getSvgString.test.ts.snap +++ b/packages/tldraw/src/test/commands/__snapshots__/getSvgString.test.ts.snap @@ -90,15 +90,14 @@ exports[`Matches a snapshot > Basic SVG 1`] = ` y="0" >

Hello world

@@ -230,15 +229,14 @@ exports[`Returns all shapes when no ids are provided > All shapes 1`] = ` y="0" >

Hello world

diff --git a/yarn.lock b/yarn.lock index d21a609ec37b..543e7611669e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -419,16 +419,43 @@ __metadata: languageName: node linkType: hard -"@asamuzakjp/css-color@npm:^3.2.0": - version: 3.2.0 - resolution: "@asamuzakjp/css-color@npm:3.2.0" +"@asamuzakjp/css-color@npm:^5.1.11": + version: 5.1.11 + resolution: "@asamuzakjp/css-color@npm:5.1.11" dependencies: - "@csstools/css-calc": "npm:^2.1.3" - "@csstools/css-color-parser": "npm:^3.0.9" - "@csstools/css-parser-algorithms": "npm:^3.0.4" - "@csstools/css-tokenizer": "npm:^3.0.3" - lru-cache: "npm:^10.4.3" - checksum: 10/870f661460173174fef8bfebea0799ba26566f3aa7b307e5adabb7aae84fed2da68e40080104ed0c83b43c5be632ee409e65396af13bfe948a3ef4c2c729ecd9 + "@asamuzakjp/generational-cache": "npm:^1.0.1" + "@csstools/css-calc": "npm:^3.2.0" + "@csstools/css-color-parser": "npm:^4.1.0" + "@csstools/css-parser-algorithms": "npm:^4.0.0" + "@csstools/css-tokenizer": "npm:^4.0.0" + checksum: 10/2e337cc94b5a3f9741a27f92b4e4b7dc467a76b1dcf66c40e71808fed71695f10c8cf07c8b13313cbb637154314ca1d8626bb9a045fe94b404b242a390cf3bd3 + languageName: node + linkType: hard + +"@asamuzakjp/dom-selector@npm:^7.1.1": + version: 7.1.1 + resolution: "@asamuzakjp/dom-selector@npm:7.1.1" + dependencies: + "@asamuzakjp/generational-cache": "npm:^1.0.1" + "@asamuzakjp/nwsapi": "npm:^2.3.9" + bidi-js: "npm:^1.0.3" + css-tree: "npm:^3.2.1" + is-potential-custom-element-name: "npm:^1.0.1" + checksum: 10/49a065a64db5f53a3008c231d09606e4b67f509fa20148a67419451c2dc91a421202ed17bfc4bc679ad2f0432d7260720d602c1d5c9c5e165931fff5199c3f12 + languageName: node + linkType: hard + +"@asamuzakjp/generational-cache@npm:^1.0.1": + version: 1.0.1 + resolution: "@asamuzakjp/generational-cache@npm:1.0.1" + checksum: 10/e1cf3f1916a334c6153f624982f0eb3d50fa3048435ea5c5b0f441f8f1ab74a0fe992dac214b612d22c0acafad3cd1a1f6b45d99c7b6e3b63cfdf7f6ca5fc144 + languageName: node + linkType: hard + +"@asamuzakjp/nwsapi@npm:^2.3.9": + version: 2.3.9 + resolution: "@asamuzakjp/nwsapi@npm:2.3.9" + checksum: 10/95a6d1c102e1117fe818da087fcc5b914d23e0699855991bae50b891435dd1945ad7d384198f8bcf616207fd85b7ec32e3db6b96e9309d84c6903b8dc4151e34 languageName: node linkType: hard @@ -1615,6 +1642,17 @@ __metadata: languageName: node linkType: hard +"@bramus/specificity@npm:^2.4.2": + version: 2.4.2 + resolution: "@bramus/specificity@npm:2.4.2" + dependencies: + css-tree: "npm:^3.0.0" + bin: + specificity: bin/cli.js + checksum: 10/4255ed6ff12f7db9ec3c21acfd0da2327d30ec29deb199345810cdcad992618f40039c5483eefeb665913bffbc80b690e9f1b954fbbbfa93480c6a22f9c3a69c + languageName: node + linkType: hard + "@bytecodealliance/preview2-shim@npm:0.17.6": version: 0.17.6 resolution: "@bytecodealliance/preview2-shim@npm:0.17.6" @@ -1904,49 +1942,61 @@ __metadata: languageName: node linkType: hard -"@csstools/color-helpers@npm:^5.0.2": - version: 5.0.2 - resolution: "@csstools/color-helpers@npm:5.0.2" - checksum: 10/8763079c54578bd2215c68de0795edb9cfa29bffa29625bff89f3c47d9df420d86296ff3a6fa8c29ca037bbaa64dc10a963461233341de0516a3161a3b549e7b +"@csstools/color-helpers@npm:^6.0.2": + version: 6.0.2 + resolution: "@csstools/color-helpers@npm:6.0.2" + checksum: 10/c47a943e947d76980d0e1071027cb70481ac481968e744a05a7aea7ede9886f10d062b2e3691e03c115d97b053d4140c1ca28e24c1ffe2d21693e126de6522e9 languageName: node linkType: hard -"@csstools/css-calc@npm:^2.1.3, @csstools/css-calc@npm:^2.1.4": - version: 2.1.4 - resolution: "@csstools/css-calc@npm:2.1.4" +"@csstools/css-calc@npm:^3.2.0, @csstools/css-calc@npm:^3.2.1": + version: 3.2.1 + resolution: "@csstools/css-calc@npm:3.2.1" peerDependencies: - "@csstools/css-parser-algorithms": ^3.0.5 - "@csstools/css-tokenizer": ^3.0.4 - checksum: 10/06975b650c0f44c60eeb7afdb3fd236f2dd607b2c622e0bc908d3f54de39eb84e0692833320d03dac04bd6c1ab0154aa3fa0dd442bd9e5f917cf14d8e2ba8d74 + "@csstools/css-parser-algorithms": ^4.0.0 + "@csstools/css-tokenizer": ^4.0.0 + checksum: 10/39042a9382cbd7c4fa241c1a6c10d64c23fa7653970fc11df532e9bc42a366a8b50c3ac3036bd5f2a77b94144e7f804f19d2b52800ad2f48bd9a3840377165d8 languageName: node linkType: hard -"@csstools/css-color-parser@npm:^3.0.9": - version: 3.0.10 - resolution: "@csstools/css-color-parser@npm:3.0.10" +"@csstools/css-color-parser@npm:^4.1.0": + version: 4.1.1 + resolution: "@csstools/css-color-parser@npm:4.1.1" dependencies: - "@csstools/color-helpers": "npm:^5.0.2" - "@csstools/css-calc": "npm:^2.1.4" + "@csstools/color-helpers": "npm:^6.0.2" + "@csstools/css-calc": "npm:^3.2.1" peerDependencies: - "@csstools/css-parser-algorithms": ^3.0.5 - "@csstools/css-tokenizer": ^3.0.4 - checksum: 10/d5619639f067c0a6ac95ecce6ad6adce55a5500599a4444817ac6bb5ed2a9928a08f0978a148d4687de7ebf05c068c1a1c7f9eaa039984830a84148e011cbc05 + "@csstools/css-parser-algorithms": ^4.0.0 + "@csstools/css-tokenizer": ^4.0.0 + checksum: 10/05bcfb0b05070387dbaf3b498091998fc23b4442c04e5469ce7b1af600113032c43946b07d4c6ae04f2e4c2c4d49ecfca8b2a42fe9d53d7cec4bf2642b2a33e8 languageName: node linkType: hard -"@csstools/css-parser-algorithms@npm:^3.0.4": - version: 3.0.5 - resolution: "@csstools/css-parser-algorithms@npm:3.0.5" +"@csstools/css-parser-algorithms@npm:^4.0.0": + version: 4.0.0 + resolution: "@csstools/css-parser-algorithms@npm:4.0.0" peerDependencies: - "@csstools/css-tokenizer": ^3.0.4 - checksum: 10/e93083b5cb36a3c1e7a47ce10cf62961d05bd1e4c608bb3ee50186ff740157ab0ec16a3956f7b86251efd10703034d849693201eea858ae904848c68d2d46ada + "@csstools/css-tokenizer": ^4.0.0 + checksum: 10/000f3ba55f440d9fbece50714e88f9d4479e2bde9e0568333492663f2c6034dc31d0b9ef5d66d196c76be58eea145ca6920aa8bdfdcc6361894806c21b5402d0 languageName: node linkType: hard -"@csstools/css-tokenizer@npm:^3.0.3": - version: 3.0.4 - resolution: "@csstools/css-tokenizer@npm:3.0.4" - checksum: 10/eb6c84c086312f6bb8758dfe2c85addd7475b0927333c5e39a4d59fb210b9810f8c346972046f95e60a721329cffe98895abe451e51de753ad1ca7a8c24ec65f +"@csstools/css-syntax-patches-for-csstree@npm:^1.1.3": + version: 1.1.4 + resolution: "@csstools/css-syntax-patches-for-csstree@npm:1.1.4" + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + checksum: 10/f12bfd62737b49e81d12e83f325c58f9c1861739056c0467288890a2b195c2d800c14ee16b09f4e957d2c9df6da371f225b6b6e640fb5fc25fc68bd3a8ced789 + languageName: node + linkType: hard + +"@csstools/css-tokenizer@npm:^4.0.0": + version: 4.0.0 + resolution: "@csstools/css-tokenizer@npm:4.0.0" + checksum: 10/074ade1a7fc3410b813c8982cf07a56814a55af509c533c2dc80d5689f34d2ba38219f8fa78fa36ea2adc6c5db506ea3c3a667388dda1b59b1281fdd2a2d1e28 languageName: node linkType: hard @@ -3145,6 +3195,18 @@ __metadata: languageName: node linkType: hard +"@exodus/bytes@npm:^1.11.0, @exodus/bytes@npm:^1.15.0, @exodus/bytes@npm:^1.6.0": + version: 1.15.1 + resolution: "@exodus/bytes@npm:1.15.1" + peerDependencies: + "@noble/hashes": ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + "@noble/hashes": + optional: true + checksum: 10/7dde00ae8040ec032ba3c287d210d7d555986caaf81a1215311c180422697d739a1952eb9d91e841132b18a19a910cdd02e4914136db3cc87baaa0fdd84b1e87 + languageName: node + linkType: hard + "@fastify/ajv-compiler@npm:^4.0.5": version: 4.0.5 resolution: "@fastify/ajv-compiler@npm:4.0.5" @@ -12016,7 +12078,7 @@ __metadata: esbuild: "npm:^0.28.0" fs-extra: "npm:^11.3.0" husky: "npm:^8.0.3" - jsdom: "npm:^25.0.1" + jsdom: "npm:^29.1.1" json5: "npm:^2.2.3" lazyrepo: "npm:0.0.0-alpha.27" license-report: "npm:^6.7.1" @@ -15727,6 +15789,15 @@ __metadata: languageName: node linkType: hard +"bidi-js@npm:^1.0.3": + version: 1.0.3 + resolution: "bidi-js@npm:1.0.3" + dependencies: + require-from-string: "npm:^2.0.2" + checksum: 10/c4341c7a98797efe3d186cd99d6f97e9030a4f959794ca200ef2ec0a678483a916335bba6c2c0608a21d04a221288a31c9fd0faa0cd9b3903b93594b42466a6a + languageName: node + linkType: hard + "bignumber.js@npm:^9.0.0": version: 9.1.2 resolution: "bignumber.js@npm:9.1.2" @@ -17110,6 +17181,16 @@ __metadata: languageName: node linkType: hard +"css-tree@npm:^3.0.0, css-tree@npm:^3.2.1": + version: 3.2.1 + resolution: "css-tree@npm:3.2.1" + dependencies: + mdn-data: "npm:2.27.1" + source-map-js: "npm:^1.2.1" + checksum: 10/9945b387bdec756738c34d64b8287f05ca6645f51d1c8abaaa5822ec3e74533604103aaad164b8100afd8495e92120be7c1c6afbe5be89f867acc5b456ddd79c + languageName: node + linkType: hard + "css-tree@npm:~2.2.0": version: 2.2.1 resolution: "css-tree@npm:2.2.1" @@ -17152,16 +17233,6 @@ __metadata: languageName: node linkType: hard -"cssstyle@npm:^4.1.0": - version: 4.6.0 - resolution: "cssstyle@npm:4.6.0" - dependencies: - "@asamuzakjp/css-color": "npm:^3.2.0" - rrweb-cssom: "npm:^0.8.0" - checksum: 10/1cb25c9d66b87adb165f978b75cdeb6f225d7e31ba30a8934666046a0be037e4e7200d359bfa79d4f1a4aef1083ea09633b81bcdb36a2f2ac888e8c73ea3a289 - languageName: node - linkType: hard - "csstype@npm:3.1.3": version: 3.1.3 resolution: "csstype@npm:3.1.3" @@ -17584,13 +17655,13 @@ __metadata: languageName: node linkType: hard -"data-urls@npm:^5.0.0": - version: 5.0.0 - resolution: "data-urls@npm:5.0.0" +"data-urls@npm:^7.0.0": + version: 7.0.0 + resolution: "data-urls@npm:7.0.0" dependencies: - whatwg-mimetype: "npm:^4.0.0" - whatwg-url: "npm:^14.0.0" - checksum: 10/5c40568c31b02641a70204ff233bc4e42d33717485d074244a98661e5f2a1e80e38fe05a5755dfaf2ee549f2ab509d6a3af2a85f4b2ad2c984e5d176695eaf46 + whatwg-mimetype: "npm:^5.0.0" + whatwg-url: "npm:^16.0.0" + checksum: 10/60f88ded4306aea5d6251c4db100ca272fc026014004d68aad4db495397a73bb39d17a6bd29ed9ab348c88a28f6e97266a1759985df4e12dc8c02bb8544c7731 languageName: node linkType: hard @@ -18374,6 +18445,13 @@ __metadata: languageName: node linkType: hard +"entities@npm:^8.0.0": + version: 8.0.0 + resolution: "entities@npm:8.0.0" + checksum: 10/d6e2ba75e444fb101ee2fbb07c839e687306c8a509426b75186619c19196f97c1db9932ca083f823c03e4a20e7407b654aa34de8cbb7770468e20fb2d4573a0e + languageName: node + linkType: hard + "entities@npm:~2.1.0": version: 2.1.0 resolution: "entities@npm:2.1.0" @@ -20883,12 +20961,12 @@ __metadata: languageName: node linkType: hard -"html-encoding-sniffer@npm:^4.0.0": - version: 4.0.0 - resolution: "html-encoding-sniffer@npm:4.0.0" +"html-encoding-sniffer@npm:^6.0.0": + version: 6.0.0 + resolution: "html-encoding-sniffer@npm:6.0.0" dependencies: - whatwg-encoding: "npm:^3.1.1" - checksum: 10/e86efd493293a5671b8239bd099d42128433bb3c7b0fdc7819282ef8e118a21f5dead0ad6f358e024a4e5c84f17ebb7a9b36075220fac0a6222b207248bede6f + "@exodus/bytes": "npm:^1.6.0" + checksum: 10/97392e45d8aff57f180f62a1b12e62201c8451af68424b8bc3196f78e273891f2df285e5be43a3f28c7ba4badf9524ef305db65c4e4935a9e796afc86d9654b8 languageName: node linkType: hard @@ -20981,7 +21059,7 @@ __metadata: languageName: node linkType: hard -"http-proxy-agent@npm:^7.0.0, http-proxy-agent@npm:^7.0.1, http-proxy-agent@npm:^7.0.2": +"http-proxy-agent@npm:^7.0.0, http-proxy-agent@npm:^7.0.1": version: 7.0.2 resolution: "http-proxy-agent@npm:7.0.2" dependencies: @@ -22068,37 +22146,37 @@ __metadata: languageName: node linkType: hard -"jsdom@npm:^25.0.1": - version: 25.0.1 - resolution: "jsdom@npm:25.0.1" +"jsdom@npm:^29.1.1": + version: 29.1.1 + resolution: "jsdom@npm:29.1.1" dependencies: - cssstyle: "npm:^4.1.0" - data-urls: "npm:^5.0.0" - decimal.js: "npm:^10.4.3" - form-data: "npm:^4.0.0" - html-encoding-sniffer: "npm:^4.0.0" - http-proxy-agent: "npm:^7.0.2" - https-proxy-agent: "npm:^7.0.5" + "@asamuzakjp/css-color": "npm:^5.1.11" + "@asamuzakjp/dom-selector": "npm:^7.1.1" + "@bramus/specificity": "npm:^2.4.2" + "@csstools/css-syntax-patches-for-csstree": "npm:^1.1.3" + "@exodus/bytes": "npm:^1.15.0" + css-tree: "npm:^3.2.1" + data-urls: "npm:^7.0.0" + decimal.js: "npm:^10.6.0" + html-encoding-sniffer: "npm:^6.0.0" is-potential-custom-element-name: "npm:^1.0.1" - nwsapi: "npm:^2.2.12" - parse5: "npm:^7.1.2" - rrweb-cssom: "npm:^0.7.1" + lru-cache: "npm:^11.3.5" + parse5: "npm:^8.0.1" saxes: "npm:^6.0.0" symbol-tree: "npm:^3.2.4" - tough-cookie: "npm:^5.0.0" + tough-cookie: "npm:^6.0.1" + undici: "npm:^7.25.0" w3c-xmlserializer: "npm:^5.0.0" - webidl-conversions: "npm:^7.0.0" - whatwg-encoding: "npm:^3.1.1" - whatwg-mimetype: "npm:^4.0.0" - whatwg-url: "npm:^14.0.0" - ws: "npm:^8.18.0" + webidl-conversions: "npm:^8.0.1" + whatwg-mimetype: "npm:^5.0.0" + whatwg-url: "npm:^16.0.1" xml-name-validator: "npm:^5.0.0" peerDependencies: - canvas: ^2.11.2 + canvas: ^3.0.0 peerDependenciesMeta: canvas: optional: true - checksum: 10/e6bf7250ddd2fbcf68da0ea041a0dc63545dc4bf77fa3ff40a46ae45b1dac1ca55b87574ab904d1f8baeeb547c52cec493a22f545d7d413b320011f41150ec49 + checksum: 10/344aed7f91839b6c7d1b40778c5542d6ded7d42d88e1b787e10bf12d4ccd65464a5f23f774eb84350885c75a48efc99f6972adbb94dffe324a1b065d3650843c languageName: node linkType: hard @@ -23030,17 +23108,17 @@ __metadata: languageName: node linkType: hard -"lru-cache@npm:^10.0.0, lru-cache@npm:^10.0.1, lru-cache@npm:^10.2.0, lru-cache@npm:^10.4.3": +"lru-cache@npm:^10.0.0, lru-cache@npm:^10.0.1, lru-cache@npm:^10.2.0": version: 10.4.3 resolution: "lru-cache@npm:10.4.3" checksum: 10/e6e90267360476720fa8e83cc168aa2bf0311f3f2eea20a6ba78b90a885ae72071d9db132f40fda4129c803e7dcec3a6b6a6fbb44ca90b081630b810b5d6a41a languageName: node linkType: hard -"lru-cache@npm:^11.0.0": - version: 11.0.2 - resolution: "lru-cache@npm:11.0.2" - checksum: 10/25fcb66e9d91eaf17227c6abfe526a7bed5903de74f93bfde380eb8a13410c5e8d3f14fe447293f3f322a7493adf6f9f015c6f1df7a235ff24ec30f366e1c058 +"lru-cache@npm:^11.0.0, lru-cache@npm:^11.3.5": + version: 11.5.0 + resolution: "lru-cache@npm:11.5.0" + checksum: 10/c02af3d6e5e5baff9b52ed1a0850dc97e21a2554308c0feab5663873f9b395ea66b2767ead6e347542c08ab89b04aadb92884eddbcd14d975505687530df99e8 languageName: node linkType: hard @@ -23523,6 +23601,13 @@ __metadata: languageName: node linkType: hard +"mdn-data@npm:2.27.1": + version: 2.27.1 + resolution: "mdn-data@npm:2.27.1" + checksum: 10/5046dc83a961b8ea82a5d6d8331d07df6b15faec61519ce2f83e49766702358e7e6af96413be977ff89080534be6762c1d5963b5dd1180c208a47c0a663226b2 + languageName: node + linkType: hard + "mdurl@npm:^1.0.1": version: 1.0.1 resolution: "mdurl@npm:1.0.1" @@ -25043,13 +25128,6 @@ __metadata: languageName: node linkType: hard -"nwsapi@npm:^2.2.12": - version: 2.2.21 - resolution: "nwsapi@npm:2.2.21" - checksum: 10/3d84e7e0691640028fd7b1e93f3368cb1b5958332cecdcb31f335178177a6efdd00a07fb68b99cc476f0ca835bed5bd79b1010a16b97d33ce6c3c3c94bebd05c - languageName: node - linkType: hard - "object-assign@npm:^4, object-assign@npm:^4.0.1, object-assign@npm:^4.1.0, object-assign@npm:^4.1.1": version: 4.1.1 resolution: "object-assign@npm:4.1.1" @@ -25912,6 +25990,15 @@ __metadata: languageName: node linkType: hard +"parse5@npm:^8.0.1": + version: 8.0.1 + resolution: "parse5@npm:8.0.1" + dependencies: + entities: "npm:^8.0.0" + checksum: 10/671dedfe7cbf4714414317bc8c6b2a14c61ef44f8fd90c983b5b1870653af5aa2e3b4e25e38e9538a7120ea2b688c50908830da2bd0930d8fd4bce34aed024eb + languageName: node + linkType: hard + "parseurl@npm:^1.3.3": version: 1.3.3 resolution: "parseurl@npm:1.3.3" @@ -28366,20 +28453,6 @@ __metadata: languageName: node linkType: hard -"rrweb-cssom@npm:^0.7.1": - version: 0.7.1 - resolution: "rrweb-cssom@npm:0.7.1" - checksum: 10/e80cf25c223a823921d7ab57c0ce78f5b7ebceab857b400cce99dd4913420ce679834bc5707e8ada47d062e21ad368108a9534c314dc8d72c20aa4a4fa0ed16a - languageName: node - linkType: hard - -"rrweb-cssom@npm:^0.8.0": - version: 0.8.0 - resolution: "rrweb-cssom@npm:0.8.0" - checksum: 10/07521ee36fb6569c17906afad1ac7ff8f099d49ade9249e190693ac36cdf27f88d9acf0cc66978935d5d0a23fca105643d7e9125b9a9d91ed9db9e02d31d7d80 - languageName: node - linkType: hard - "rsvp@npm:^4.8.5": version: 4.8.5 resolution: "rsvp@npm:4.8.5" @@ -30558,21 +30631,21 @@ __metadata: languageName: unknown linkType: soft -"tldts-core@npm:^6.1.86": - version: 6.1.86 - resolution: "tldts-core@npm:6.1.86" - checksum: 10/cb5dff9cc15661ac773a2099e98c99a5cb3cebc35909c23cc4261ff7992032c7501995ae995de3574dbbf3431e59c47496534d52f5e96abcb231f0e72144c020 +"tldts-core@npm:^7.4.0": + version: 7.4.0 + resolution: "tldts-core@npm:7.4.0" + checksum: 10/07b83e10f69f16170fc8059968c1182118c74b1afc258c66940e6450e3db687d551794bd736bfb9615bb6c163f0971906036363bcd669dd1133ba4b4c86d0bf4 languageName: node linkType: hard -"tldts@npm:^6.1.32": - version: 6.1.86 - resolution: "tldts@npm:6.1.86" +"tldts@npm:^7.0.5": + version: 7.4.0 + resolution: "tldts@npm:7.4.0" dependencies: - tldts-core: "npm:^6.1.86" + tldts-core: "npm:^7.4.0" bin: tldts: bin/cli.js - checksum: 10/f7e66824e44479ccdda55ea556af14ce61c4d27708be403e3f90631defde49f82a580e1ca07187cc7e3b349e257a30c2808a22903f3a0548e136ebb609ccc109 + checksum: 10/dbe809485a0c7bf69fb680fe8e3d8bff5f683e77c4658b3f8b53401ba8393469dffe9a6be375e211aa1e9954027503886ad21021e1ca612eedd238b6c9c713eb languageName: node linkType: hard @@ -30689,12 +30762,12 @@ __metadata: languageName: node linkType: hard -"tough-cookie@npm:^5.0.0": - version: 5.1.2 - resolution: "tough-cookie@npm:5.1.2" +"tough-cookie@npm:^6.0.1": + version: 6.0.1 + resolution: "tough-cookie@npm:6.0.1" dependencies: - tldts: "npm:^6.1.32" - checksum: 10/de430e6e6d34b794137e05b8ac2aa6b74ebbe6cdceb4126f168cf1e76101162a4b2e0e7587c3b70e728bd8654fc39958b2035be7619ee6f08e7257610ba4cd04 + tldts: "npm:^7.0.5" + checksum: 10/915b1167e0630598eb0644e8bc089ddc28a23bf05f3c329a4a0d879c6b9801a2603be65acb06b5d2dd0f589cabb06bb638837f8222dd82a7023655f07269451a languageName: node linkType: hard @@ -30707,12 +30780,12 @@ __metadata: languageName: node linkType: hard -"tr46@npm:^5.1.0": - version: 5.1.1 - resolution: "tr46@npm:5.1.1" +"tr46@npm:^6.0.0": + version: 6.0.0 + resolution: "tr46@npm:6.0.0" dependencies: punycode: "npm:^2.3.1" - checksum: 10/833a0e1044574da5790148fd17866d4ddaea89e022de50279967bcd6b28b4ce0d30d59eb3acf9702b60918975b3bad481400337e3a2e6326cffa5c77b874753d + checksum: 10/e6d402eb2b780a40042f327f77b4ae316da1d2b18a29c16e48c239f5267c6005bbf780f854179cfae62b02dfaa70b0e9aad8f0078ccc4225f5b3b3b131928e8f languageName: node linkType: hard @@ -31128,10 +31201,10 @@ __metadata: languageName: node linkType: hard -"undici@npm:^7.16.0": - version: 7.25.0 - resolution: "undici@npm:7.25.0" - checksum: 10/038d3568c72bb976e3cc389284f7f1cc64cd70d578300e4676a449fbcb624a35fe99ac127b5f3729f18b8246d6c090444ab61b1b67736bb88f52a3e913d76bf8 +"undici@npm:^7.16.0, undici@npm:^7.25.0": + version: 7.26.0 + resolution: "undici@npm:7.26.0" + checksum: 10/c55b8f8e378dce6e645853faf0834d66b9f470c6ee1430c6e2b7971d831de36081f7e2e21ac1fc11297657d7c856241b0bf746b2a24f1277f8d156df9c181ba7 languageName: node linkType: hard @@ -32211,10 +32284,10 @@ __metadata: languageName: node linkType: hard -"webidl-conversions@npm:^7.0.0": - version: 7.0.0 - resolution: "webidl-conversions@npm:7.0.0" - checksum: 10/4c4f65472c010eddbe648c11b977d048dd96956a625f7f8b9d64e1b30c3c1f23ea1acfd654648426ce5c743c2108a5a757c0592f02902cf7367adb7d14e67721 +"webidl-conversions@npm:^8.0.1": + version: 8.0.1 + resolution: "webidl-conversions@npm:8.0.1" + checksum: 10/0f7007311f1fc257a8e406dd236f13b61fb57cf0fddb476aec33457d2d0add2d012d6df0eeb00934399238e3f3b9dad30f59dc6ac83024ae0ebd5a518bf365e8 languageName: node linkType: hard @@ -32286,6 +32359,13 @@ __metadata: languageName: node linkType: hard +"whatwg-mimetype@npm:^5.0.0": + version: 5.0.0 + resolution: "whatwg-mimetype@npm:5.0.0" + checksum: 10/a2d5da445f671ed34010b45283ffb9ba3c68c695b8ec91f7400cfc9149c35eb2bc47bd2c39bbe8e026786b955ace03402ba2e5cfde4955434a3ec3c511a85d0a + languageName: node + linkType: hard + "whatwg-stream-to-async-iter@npm:latest": version: 0.6.2 resolution: "whatwg-stream-to-async-iter@npm:0.6.2" @@ -32295,13 +32375,14 @@ __metadata: languageName: node linkType: hard -"whatwg-url@npm:^14.0.0": - version: 14.2.0 - resolution: "whatwg-url@npm:14.2.0" +"whatwg-url@npm:^16.0.0, whatwg-url@npm:^16.0.1": + version: 16.0.1 + resolution: "whatwg-url@npm:16.0.1" dependencies: - tr46: "npm:^5.1.0" - webidl-conversions: "npm:^7.0.0" - checksum: 10/f0a95b0601c64f417c471536a2d828b4c16fe37c13662483a32f02f183ed0f441616609b0663fb791e524e8cd56d9a86dd7366b1fc5356048ccb09b576495e7c + "@exodus/bytes": "npm:^1.11.0" + tr46: "npm:^6.0.0" + webidl-conversions: "npm:^8.0.1" + checksum: 10/221cc15ef89288dc1fafdb409352c62ab12ba9ff7f0753e925d8799c87b20371f3bc762dc0a8a5b9c23cddc4b1860537fc6c1bcc9d816ace9b3d3c47212cd163 languageName: node linkType: hard From ff23978d50f621cbb3eb93217257b96aedf35d39 Mon Sep 17 00:00:00 2001 From: Kevin Ingersoll Date: Fri, 5 Jun 2026 12:55:24 +0100 Subject: [PATCH 7/9] refactor(editor): decouple FocusManager from select tool state (#9003) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In order to remove an upward dependency from the editor package on the tldraw package's tool tree, this PR changes `FocusManager` to stop checking the select tool's `'select.editing_shape'` state path. As reported in #8888, the editor package referenced a tldraw-package tool state held together only by a string, which would break silently if the select tool were renamed, restructured, or swapped out. Instead, `FocusManager` now uses the editor's own first-class, tool-agnostic editing state via `editor.getEditingShapeId()`. This is the same state that `select.editing_shape` sets on enter and clears on exit, so the focus-ring behavior is equivalent — but it no longer couples the editor to a specific tool, and it correctly suppresses the focus ring whenever any tool is editing a shape. The blur point from #8888 ("`editor.blur({ blurContainer: false })` bypasses the manager") was already resolved separately by #8895, which centralized the blur contract on `FocusManager`. ### Change type - [x] `improvement` ### Test plan 1. Double-click a shape to start editing its text label. 2. Press Tab or an arrow key — the container focus ring should stay suppressed while editing. 3. Tab into the contextual toolbar — the focus ring should still be allowed there. - [x] Unit tests ### Code changes | Section | LOC change | | --------- | ---------- | | Core code | +1 / -2 | | Tests | +4 / -4 | Closes #8888 --- .../lib/editor/managers/FocusManager/FocusManager.test.ts | 8 ++++---- .../src/lib/editor/managers/FocusManager/FocusManager.ts | 3 +-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/editor/src/lib/editor/managers/FocusManager/FocusManager.test.ts b/packages/editor/src/lib/editor/managers/FocusManager/FocusManager.test.ts index dd4cd7a0f466..cd39e4bfba98 100644 --- a/packages/editor/src/lib/editor/managers/FocusManager/FocusManager.test.ts +++ b/packages/editor/src/lib/editor/managers/FocusManager/FocusManager.test.ts @@ -14,7 +14,7 @@ describe('FocusManager', () => { getInstanceState: Mock updateInstanceState: Mock getContainer: Mock - isIn: Mock + getEditingShapeId: Mock getSelectedShapeIds: Mock complete: Mock } @@ -51,7 +51,7 @@ describe('FocusManager', () => { updateInstanceState: vi.fn(), getContainer: vi.fn(() => mockContainer), getContainerDocument: vi.fn(() => document), - isIn: vi.fn(() => false), + getEditingShapeId: vi.fn(() => null), getSelectedShapeIds: vi.fn(() => []), complete: vi.fn(), } as any @@ -243,7 +243,7 @@ describe('FocusManager', () => { }) it('should return early when editor is in editing mode', () => { - editor.isIn.mockReturnValue(true) + editor.getEditingShapeId.mockReturnValue('shape:1') const event = new KeyboardEvent('keydown', { key: 'Tab' }) keydownHandler(event) @@ -407,7 +407,7 @@ describe('FocusManager', () => { const keydownCall = addEventListenerCalls.find((call: any) => call[0] === 'keydown') const keydownHandler = keydownCall![1] - editor.isIn.mockReturnValue(true) // Editing mode + editor.getEditingShapeId.mockReturnValue('shape:1') // Editing mode const event = new KeyboardEvent('keydown', { key: 'Tab' }) keydownHandler(event) diff --git a/packages/editor/src/lib/editor/managers/FocusManager/FocusManager.ts b/packages/editor/src/lib/editor/managers/FocusManager/FocusManager.ts index 8d4730722b8c..2aea997f9e98 100644 --- a/packages/editor/src/lib/editor/managers/FocusManager/FocusManager.ts +++ b/packages/editor/src/lib/editor/managers/FocusManager/FocusManager.ts @@ -63,8 +63,7 @@ export class FocusManager { const activeEl = container.ownerDocument.activeElement // Edit mode should remove the focus ring, however if the active element's // parent is the contextual toolbar, then allow it. - if (this.editor.isIn('select.editing_shape') && !activeEl?.closest('.tlui-contextual-toolbar')) - return + if (this.editor.getEditingShapeId() && !activeEl?.closest('.tlui-contextual-toolbar')) return if (activeEl === container && this.editor.getSelectedShapeIds().length > 0) return if (['Tab', 'ArrowUp', 'ArrowDown'].includes(keyEvent.key)) { container.classList.remove('tl-container__no-focus-ring') From 5974f766948ba8ac554e45a4649fdc3e4c98a338 Mon Sep 17 00:00:00 2001 From: Max Drake Date: Fri, 5 Jun 2026 12:57:08 +0100 Subject: [PATCH 8/9] feat(templates): port agent highlights to overlay util (#8764) In order to render the agent template's context and viewport-bounds highlights through tldraw's first-class overlay system instead of bespoke React/SVG components mounted via \`OnTheCanvas\`, this PR replaces the \`AgentViewportBoundsHighlights\`, \`ContextHighlights\`, \`AreaHighlight\`, and \`PointHighlight\` components with a single \`AgentHighlightOverlayUtil\` registered through the new \`overlayUtils\` prop. The overlay util enumerates active agents, derives area/point overlays from each agent's active request and selected context items, and draws them on both the main canvas and the minimap with the existing dashed/animated styling. This branch is a clean replay of [\`max/port-agent-starter-highlight-to-overlays\`](https://github.com/tldraw/tldraw/compare/main...max/port-agent-starter-highlight-to-overlays) split into reviewable commits. The second commit is supporting work that the overlay port surfaced: \`FocusedFontSize\` now derives the focused font size from the text shape util's display values (via \`getDisplayValues\`) instead of a static multiplier table and a hard-coded 16px base, so the conversion stays in sync with whatever the editor actually renders. The forward conversion now also forwards the text shape's \`font\` prop so the nearest size is chosen per-font. ### Change type - [x] \`improvement\` ### Test plan 1. \`yarn dev-template agent\` and open the demo. 2. Send a prompt so the agent runs; confirm the dashed viewport-bounds highlight and the agent-side context area highlights render on the canvas while generating, including the \"Reviewing\" / \"Agent ...'s view\" labels. 3. Pick context (shape, multiple shapes, area, point) before sending; confirm each highlight kind renders correctly with the selection-stroke colour. 4. Open the minimap and confirm filled rounded-rect highlights pulse while generating and stay solid otherwise. 5. Edit a text shape, change its \`size\`/\`scale\`/\`font\` and confirm focused font size round-trips correctly across a request. - [ ] Unit tests - [ ] End to end tests ### Release notes - Agent template now renders highlights through an \`OverlayUtil\`, including minimap support. ### Code changes | Section | LOC change | | --------- | ------------ | | Templates | +434 / -460 | --- templates/agent/client/App.tsx | 15 +- .../AgentViewportBoundsHighlights.tsx | 57 --- .../components/highlights/AreaHighlight.tsx | 54 --- .../highlights/ContextHighlights.tsx | 223 ---------- .../components/highlights/PointHighlight.tsx | 33 -- templates/agent/client/index.css | 53 --- .../overlays/AgentHighlightOverlayUtil.ts | 383 ++++++++++++++++++ .../agent/shared/format/FocusedFontSize.ts | 77 ++-- .../convertFocusedShapeToTldrawShape.ts | 7 +- .../convertTldrawShapeToFocusedShape.ts | 3 +- 10 files changed, 445 insertions(+), 460 deletions(-) delete mode 100644 templates/agent/client/components/highlights/AgentViewportBoundsHighlights.tsx delete mode 100644 templates/agent/client/components/highlights/AreaHighlight.tsx delete mode 100644 templates/agent/client/components/highlights/ContextHighlights.tsx delete mode 100644 templates/agent/client/components/highlights/PointHighlight.tsx create mode 100644 templates/agent/client/overlays/AgentHighlightOverlayUtil.ts diff --git a/templates/agent/client/App.tsx b/templates/agent/client/App.tsx index d6fb9607aa6f..a595f699c11d 100644 --- a/templates/agent/client/App.tsx +++ b/templates/agent/client/App.tsx @@ -15,8 +15,7 @@ import { import { ChatPanel } from './components/ChatPanel' import { ChatPanelFallback } from './components/ChatPanelFallback' import { CustomHelperButtons } from './components/CustomHelperButtons' -import { AgentViewportBoundsHighlights } from './components/highlights/AgentViewportBoundsHighlights' -import { AllContextHighlights } from './components/highlights/ContextHighlights' +import { AgentHighlightOverlayUtil } from './overlays/AgentHighlightOverlayUtil' import { TargetAreaTool } from './tools/TargetAreaTool' import { TargetShapeTool } from './tools/TargetShapeTool' @@ -25,6 +24,7 @@ DefaultSizeStyle.setDefaultValue('s') // Custom tools for picking context items const tools = [TargetShapeTool, TargetAreaTool] +const overlayUtils = [AgentHighlightOverlayUtil] const overrides: TLUiOverrides = { tools: (editor, tools) => { return { @@ -58,8 +58,7 @@ function App() { setApp(null) }, []) - // Custom components to visualize what the agent is doing - // These use TldrawAgentAppContextProvider to access the app/agent + // Custom components that need the agent app's React context const components: TLComponents = useMemo(() => { return { HelperButtons: () => @@ -68,13 +67,6 @@ function App() { ), - OnTheCanvas: () => - app ? ( - - - - - ) : null, } }, [app]) @@ -85,6 +77,7 @@ function App() { diff --git a/templates/agent/client/components/highlights/AgentViewportBoundsHighlights.tsx b/templates/agent/client/components/highlights/AgentViewportBoundsHighlights.tsx deleted file mode 100644 index 1494d3c096c8..000000000000 --- a/templates/agent/client/components/highlights/AgentViewportBoundsHighlights.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Box, useValue } from 'tldraw' -import { TldrawAgent } from '../../agent/TldrawAgent' -import { useAgents } from '../../agent/TldrawAgentAppProvider' -import { AreaHighlight } from './AreaHighlight' - -/** - * Renders viewport bounds highlights for all agents. - */ -export function AgentViewportBoundsHighlights() { - const agents = useAgents() - - return ( - <> - {agents.map((agent) => ( - - ))} - - ) -} - -/** - * Renders a highlight showing an agent's current viewport bounds. - */ -export function AgentViewportBoundsHighlight({ agent }: { agent: TldrawAgent }) { - const currentRequest = useValue('activeRequest', () => agent.requests.getActiveRequest(), [agent]) - const agentViewportBounds = currentRequest?.bounds - - // If the agent's viewport is equivalent to a pending context area, don't show the highlight - // (because it would overlap and be redundant) - const isEquivalentToPendingContextArea = useValue( - 'isEquivalentToPendingContextArea', - () => { - if (!agentViewportBounds) return false - const contextItems = currentRequest.contextItems ?? [] - return contextItems.some( - (item) => - item.type === 'area' && - item.source === 'agent' && - Box.Equals(item.bounds, agentViewportBounds) - ) - }, - [agentViewportBounds] - ) - - if (!agentViewportBounds) return null - if (isEquivalentToPendingContextArea) return null - - return ( - - ) -} diff --git a/templates/agent/client/components/highlights/AreaHighlight.tsx b/templates/agent/client/components/highlights/AreaHighlight.tsx deleted file mode 100644 index f857a7d43844..000000000000 --- a/templates/agent/client/components/highlights/AreaHighlight.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { Box, BoxModel, SVGContainer, useValue } from 'tldraw' - -export interface AreaHighlightProps { - pageBounds: BoxModel - generating: boolean - color: string - label?: string -} - -export function AreaHighlight({ pageBounds, color, generating, label }: AreaHighlightProps) { - // Use page coordinates directly - the Overlays component transforms with the camera - const bounds = useValue('bounds', () => Box.From(pageBounds).expandBy(4), [pageBounds]) - - if (!bounds) return null - const minX = bounds.minX - const minY = bounds.minY - const maxX = bounds.maxX - const maxY = bounds.maxY - - return ( - <> - - {bounds.sides.map((side, j) => { - return ( - - ) - })} - - {label && ( -
- {label} -
- )} - - ) -} diff --git a/templates/agent/client/components/highlights/ContextHighlights.tsx b/templates/agent/client/components/highlights/ContextHighlights.tsx deleted file mode 100644 index e208472637df..000000000000 --- a/templates/agent/client/components/highlights/ContextHighlights.tsx +++ /dev/null @@ -1,223 +0,0 @@ -import { useMemo } from 'react' -import { TLShapeId, useEditor, useValue } from 'tldraw' -import { TldrawAgent } from '../../agent/TldrawAgent' -import { useAgents } from '../../agent/TldrawAgentAppProvider' -import { AreaHighlight, AreaHighlightProps } from './AreaHighlight' -import { PointHighlight, PointHighlightProps } from './PointHighlight' - -/** - * Renders context highlights for all agents. - */ -export function AllContextHighlights() { - const agents = useAgents() - - return ( - <> - {agents.map((agent) => ( - - ))} - - ) -} - -/** - * Renders context highlights for a single agent. - */ -export function ContextHighlights({ agent }: { agent: TldrawAgent }) { - const editor = useEditor() - const selectedContextItems = useValue( - 'contextItems', - () => (agent.requests.isGenerating() ? [] : agent.context.getItems()), - [agent] - ) - const activeRequest = useValue('activeRequest', () => agent.requests.getActiveRequest(), [agent]) - const activeContextItems = activeRequest?.contextItems ?? [] - - const selectedAreas: AreaHighlightProps[] = useValue( - 'selectedAreas', - () => { - const selectedAreaItems = selectedContextItems.filter((item) => item.type === 'area') - return selectedAreaItems.map((item) => { - return { - pageBounds: item.bounds, - generating: false, - color: 'var(--tl-color-selected)', - } - }) - }, - [selectedContextItems] - ) - - const activeAreas: AreaHighlightProps[] = useValue( - 'activeAreas', - () => { - const activeAreaItems = activeContextItems.filter((item) => item.type === 'area') - return activeAreaItems.map((item) => { - return { - pageBounds: item.bounds, - generating: true, - color: 'var(--tl-color-selected)', - label: item.source === 'agent' ? 'Reviewing' : undefined, - } - }) - }, - [activeContextItems] - ) - - const selectedShapes: AreaHighlightProps[] = useValue( - 'selectedShapes', - () => { - const selectedShapeItems = selectedContextItems.filter((item) => item.type === 'shapes') - return selectedShapeItems - .map((item) => { - const bounds = editor.getShapesPageBounds( - item.shapes.map((shape) => `shape:${shape.shapeId}` as TLShapeId) - ) - if (!bounds) return null - return { - pageBounds: bounds, - generating: false, - color: 'var(--tl-color-selected)', - } - }) - .filter((highlight) => highlight !== null) - }, - [selectedContextItems, editor] - ) - - const activeShapes: AreaHighlightProps[] = useValue( - 'activeShapes', - () => { - const activeShapeItems = activeContextItems.filter((item) => item.type === 'shapes') - return activeShapeItems - .map((item) => { - const bounds = editor.getShapesPageBounds( - item.shapes.map((shape) => `shape:${shape.shapeId}` as TLShapeId) - ) - if (!bounds) return null - return { - pageBounds: bounds, - generating: true, - color: 'var(--tl-color-selected)', - } - }) - .filter((highlight) => highlight !== null) - }, - [activeContextItems, editor] - ) - - const selectedShapesAreas: AreaHighlightProps[] = useValue( - 'selectedShapesAreas', - () => { - const selectedShapeItems = selectedContextItems.filter((item) => item.type === 'shape') - return selectedShapeItems - .map((item) => { - const bounds = editor.getShapePageBounds(`shape:${item.shape.shapeId}` as TLShapeId) - if (!bounds) return null - return { - pageBounds: bounds, - generating: false, - color: 'var(--tl-color-selected)', - } - }) - .filter((highlight) => highlight !== null) - }, - [selectedContextItems, editor] - ) - - const activeShapeAreas: AreaHighlightProps[] = useValue( - 'activeShapeAreas', - () => { - const activeShapeItems = activeContextItems.filter((item) => item.type === 'shape') - return activeShapeItems - .map((item) => { - const bounds = editor.getShapePageBounds(`shape:${item.shape.shapeId}` as TLShapeId) - if (!bounds) return null - return { - pageBounds: bounds, - generating: true, - color: 'var(--tl-color-selected)', - } - }) - .filter((highlight) => highlight !== null) - }, - [activeContextItems, editor] - ) - - const selectedPoints: PointHighlightProps[] = useValue( - 'selectedPoints', - () => { - const selectedPointItems = selectedContextItems.filter((item) => item.type === 'point') - return selectedPointItems.map((item) => { - return { - pagePoint: item.point, - generating: false, - color: 'var(--tl-color-selected)', - } - }) - }, - [selectedContextItems] - ) - - const activePoints: PointHighlightProps[] = useValue( - 'activePoints', - () => { - const activePointItems = activeContextItems.filter((item) => item.type === 'point') - return activePointItems.map((item) => { - return { - pagePoint: item.point, - generating: true, - color: 'var(--tl-color-selected)', - } - }) - }, - [activeContextItems] - ) - - const allAreaHighlights = useMemo( - () => [ - ...selectedAreas, - ...selectedShapes, - ...selectedShapesAreas, - ...activeAreas, - ...activeShapes, - ...activeShapeAreas, - ], - [ - selectedAreas, - selectedShapes, - selectedShapesAreas, - activeAreas, - activeShapes, - activeShapeAreas, - ] - ) - - const allPointsHighlights = useMemo( - () => [...selectedPoints, ...activePoints], - [selectedPoints, activePoints] - ) - - return ( - <> - {allAreaHighlights.map((highlight, i) => ( - - ))} - - {allPointsHighlights.map((highlight, i) => ( - - ))} - - ) -} diff --git a/templates/agent/client/components/highlights/PointHighlight.tsx b/templates/agent/client/components/highlights/PointHighlight.tsx deleted file mode 100644 index da6c72452e48..000000000000 --- a/templates/agent/client/components/highlights/PointHighlight.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { SVGContainer, useEditor, useValue, VecModel } from 'tldraw' - -export interface PointHighlightProps { - pagePoint: VecModel - color: string - generating: boolean -} - -export function PointHighlight({ pagePoint, color, generating }: PointHighlightProps) { - const editor = useEditor() - // Scale radius by 1/zoom to maintain constant visual size - const r = useValue('radius', () => 3 / editor.getZoomLevel(), [editor]) - - return ( - - - - ) -} diff --git a/templates/agent/client/index.css b/templates/agent/client/index.css index 9baa6b1236ab..0e458eba3876 100644 --- a/templates/agent/client/index.css +++ b/templates/agent/client/index.css @@ -668,59 +668,6 @@ button.prompt-tag span { background-color: hsl(214, 84%, 40%); } -/* Context Highlights */ -/* Uses --tl-scale (1/zoom) to keep visual size constant at different zoom levels */ - -.context-highlight { - pointer-events: none; -} - -.context-highlight line { - stroke-dasharray: calc(4px * var(--tl-scale)); - stroke-linecap: round; - stroke-width: calc(1px * var(--tl-scale)); -} - -.context-highlight-generating line { - animation: dash 100s linear infinite; -} - -.context-highlight circle { - stroke-width: calc(1px * var(--tl-scale)); - pointer-events: none; - stroke-linecap: round; - stroke-linejoin: round; -} - -.context-highlight-generating circle { - stroke-dasharray: calc(3px * var(--tl-scale)); - stroke-width: calc(2px * var(--tl-scale)); - fill: none; - animation: dash 100s linear infinite; -} - -.context-highlight-label { - position: absolute; - color: var(--tl-color-text-shadow); - padding: 4px; - font-size: 12px; - background-color: var(--tl-color-tooltip); - border-radius: 0px 0px 6px 0px; - font-weight: 500; - white-space: nowrap; - pointer-events: none; - font-family: 'Inter', sans-serif; - /* Scale the label to maintain constant visual size */ - transform: scale(var(--tl-scale)); - transform-origin: top left; -} - -@keyframes dash { - to { - stroke-dashoffset: -2000; - } -} - /* Todo list */ .todo-list { diff --git a/templates/agent/client/overlays/AgentHighlightOverlayUtil.ts b/templates/agent/client/overlays/AgentHighlightOverlayUtil.ts new file mode 100644 index 000000000000..525b1d9577c5 --- /dev/null +++ b/templates/agent/client/overlays/AgentHighlightOverlayUtil.ts @@ -0,0 +1,383 @@ +import { + atom, + Box, + BoxModel, + Editor, + OverlayUtil, + PI2, + TLOverlay, + TLShapeId, + VecModel, +} from 'tldraw' +import { ContextItem } from '../../shared/types/ContextItem' +import { AgentAppAgentsManager } from '../agent/managers/AgentAppAgentsManager' + +type AgentHighlightColor = 'selected' | 'tooltip' +const ANIMATION_PERIOD = 100_000 +const DASH_SPEED = 20 +const MINIMAP_FILL_ALPHA = 0.12 +const MINIMAP_PULSE_PERIOD = 2400 + +interface AgentAreaHighlightOverlay extends TLOverlay { + type: 'agent-highlight' + props: { + kind: 'area' + bounds: BoxModel + color: AgentHighlightColor + generating: boolean + label?: string + } +} + +interface AgentPointHighlightOverlay extends TLOverlay { + type: 'agent-highlight' + props: { + kind: 'point' + point: VecModel + color: AgentHighlightColor + generating: boolean + } +} + +type AgentHighlightOverlay = AgentAreaHighlightOverlay | AgentPointHighlightOverlay + +export class AgentHighlightOverlayUtil extends OverlayUtil { + static override type = 'agent-highlight' as const + override options = { zIndex: 700 } + + private readonly $animationElapsed = atom('agent highlight animation elapsed', 0) + private hasAnimatedOverlays = false + + constructor(editor: Editor) { + super(editor) + editor.on('tick', this.handleTick) + } + + override dispose() { + this.editor.off('tick', this.handleTick) + } + + override isActive(): boolean { + return AgentAppAgentsManager.getAgents(this.editor).length > 0 + } + + override getOverlays(): AgentHighlightOverlay[] { + const overlays: AgentHighlightOverlay[] = [] + const agents = AgentAppAgentsManager.getAgents(this.editor) + + for (const agent of agents) { + const activeRequest = agent.requests.getActiveRequest() + const activeContextItems = activeRequest?.contextItems ?? [] + + if ( + activeRequest?.bounds && + !this.isEquivalentToPendingContextArea(activeRequest.bounds, activeContextItems) + ) { + overlays.push({ + id: `agent-highlight:${agent.id}:viewport`, + type: 'agent-highlight', + props: { + kind: 'area', + bounds: toBoxModel(activeRequest.bounds), + color: 'tooltip', + generating: true, + label: `Agent ${agent.id.slice(0, 6)}'s view`, + }, + }) + } + + const selectedContextItems = agent.requests.isGenerating() ? [] : agent.context.getItems() + this.addContextItemOverlays(overlays, agent.id, 'selected', selectedContextItems, false) + this.addContextItemOverlays(overlays, agent.id, 'active', activeContextItems, true) + } + + return overlays + } + + override render(ctx: CanvasRenderingContext2D, overlays: AgentHighlightOverlay[]): void { + // Track whether any overlay is animating so the tick handler knows when to + // advance the animation. This is a side effect, so it belongs in render + // (an effect) rather than getOverlays (a reactive computation). + this.hasAnimatedOverlays = overlays.some((overlay) => overlay.props.generating) + + const zoom = this.editor.getZoomLevel() + const colors = this.getColors() + const dashOffset = getDashOffset(this.$animationElapsed.get(), zoom) + + for (const overlay of overlays) { + const color = colors[overlay.props.color] + + if (overlay.props.kind === 'area') { + this.renderArea(ctx, overlay.props.bounds, { + color, + generating: overlay.props.generating, + dashOffset, + label: overlay.props.label, + labelText: colors.labelText, + zoom, + }) + } else { + this.renderPoint(ctx, overlay.props.point, { + color, + generating: overlay.props.generating, + dashOffset, + zoom, + }) + } + } + } + + override renderMinimap( + ctx: CanvasRenderingContext2D, + overlays: AgentHighlightOverlay[], + zoom: number + ): void { + const colors = this.getColors() + const animationElapsed = this.$animationElapsed.get() + + for (const overlay of overlays) { + const color = colors[overlay.props.color] + + if (overlay.props.kind === 'area') { + const bounds = Box.From(overlay.props.bounds).expandBy(4) + const radius = Math.min(bounds.w, bounds.h, 3 / zoom) + ctx.globalAlpha = overlay.props.generating + ? getMinimapPulseAlpha(animationElapsed) + : MINIMAP_FILL_ALPHA + ctx.fillStyle = color + ctx.beginPath() + ctx.roundRect(bounds.x, bounds.y, bounds.w, bounds.h, radius) + ctx.fill() + ctx.globalAlpha = 0.85 + ctx.lineWidth = 1 / zoom + ctx.strokeStyle = color + ctx.stroke() + ctx.globalAlpha = 1 + } else { + const radius = 3 / zoom + ctx.beginPath() + ctx.arc(overlay.props.point.x, overlay.props.point.y, radius, 0, PI2) + ctx.fillStyle = color + ctx.fill() + } + } + } + + private addContextItemOverlays( + overlays: AgentHighlightOverlay[], + agentId: string, + group: 'selected' | 'active', + contextItems: ContextItem[], + generating: boolean + ): void { + const areaHighlights: AgentAreaHighlightOverlay[] = [] + const pointHighlights: AgentPointHighlightOverlay[] = [] + + for (let i = 0; i < contextItems.length; i++) { + const item = contextItems[i] + const id = `agent-highlight:${agentId}:${group}:${i}` + + switch (item.type) { + case 'area': { + areaHighlights.push({ + id, + type: 'agent-highlight', + props: { + kind: 'area', + bounds: toBoxModel(item.bounds), + color: 'selected', + generating, + label: generating && item.source === 'agent' ? 'Reviewing' : undefined, + }, + }) + break + } + case 'shapes': { + const bounds = this.editor.getShapesPageBounds( + item.shapes.map((shape) => `shape:${shape.shapeId}` as TLShapeId) + ) + if (!bounds) break + areaHighlights.push({ + id, + type: 'agent-highlight', + props: { + kind: 'area', + bounds: toBoxModel(bounds), + color: 'selected', + generating, + }, + }) + break + } + case 'shape': { + const bounds = this.editor.getShapePageBounds(`shape:${item.shape.shapeId}` as TLShapeId) + if (!bounds) break + areaHighlights.push({ + id, + type: 'agent-highlight', + props: { + kind: 'area', + bounds: toBoxModel(bounds), + color: 'selected', + generating, + }, + }) + break + } + case 'point': { + pointHighlights.push({ + id, + type: 'agent-highlight', + props: { + kind: 'point', + point: item.point, + color: 'selected', + generating, + }, + }) + break + } + } + } + + overlays.push(...areaHighlights, ...pointHighlights) + } + + private isEquivalentToPendingContextArea( + agentViewportBounds: BoxModel, + contextItems: ContextItem[] + ): boolean { + return contextItems.some( + (item) => + item.type === 'area' && + item.source === 'agent' && + Box.Equals(item.bounds, agentViewportBounds) + ) + } + + private renderArea( + ctx: CanvasRenderingContext2D, + pageBounds: BoxModel, + { + color, + generating, + dashOffset, + label, + labelText, + zoom, + }: { + color: string + generating: boolean + dashOffset: number + label?: string + labelText: string + zoom: number + } + ): void { + const bounds = Box.From(pageBounds).expandBy(4) + ctx.save() + ctx.strokeStyle = color + ctx.lineWidth = 1 / zoom + ctx.lineCap = 'round' + if (generating) { + ctx.setLineDash([4 / zoom, 4 / zoom]) + ctx.lineDashOffset = dashOffset + } + ctx.strokeRect(bounds.x, bounds.y, bounds.w, bounds.h) + ctx.restore() + + if (label) { + this.renderLabel(ctx, bounds, label, color, labelText, zoom) + } + } + + private renderPoint( + ctx: CanvasRenderingContext2D, + pagePoint: VecModel, + { + color, + generating, + dashOffset, + zoom, + }: { color: string; generating: boolean; dashOffset: number; zoom: number } + ): void { + const radius = 3 / zoom + ctx.save() + ctx.beginPath() + ctx.arc(pagePoint.x, pagePoint.y, radius, 0, PI2) + ctx.strokeStyle = color + ctx.fillStyle = color + ctx.lineCap = 'round' + ctx.lineJoin = 'round' + if (generating) { + ctx.setLineDash([3 / zoom, 3 / zoom]) + ctx.lineDashOffset = dashOffset + ctx.lineWidth = 2 / zoom + ctx.stroke() + } else { + ctx.lineWidth = 1 / zoom + ctx.fill() + ctx.stroke() + } + ctx.restore() + } + + private renderLabel( + ctx: CanvasRenderingContext2D, + bounds: Box, + label: string, + color: string, + labelText: string, + zoom: number + ): void { + const scale = 1 / zoom + const padding = 4 + const fontSize = 12 + const fontFamily = this.editor.getCurrentTheme().fonts.sans.fontFamily + + ctx.save() + ctx.translate(bounds.x, bounds.y) + ctx.scale(scale, scale) + ctx.font = `500 ${fontSize}px ${fontFamily}` + ctx.textBaseline = 'top' + + const width = Math.ceil(ctx.measureText(label).width) + padding * 2 + const height = fontSize + padding * 2 + + ctx.fillStyle = color + ctx.beginPath() + ctx.roundRect(0, 0, width, height, 4) + ctx.fill() + + ctx.fillStyle = labelText + ctx.fillText(label, padding, padding) + ctx.restore() + } + + private getColors() { + const themeColors = this.editor.getCurrentTheme().colors[this.editor.getColorMode()] + return { + selected: themeColors.selectionStroke, + tooltip: themeColors.text, + labelText: themeColors.background, + } + } + + private handleTick = (elapsed: number) => { + if (!this.hasAnimatedOverlays) return + this.$animationElapsed.update((phase) => (phase + elapsed) % ANIMATION_PERIOD) + } +} + +function toBoxModel(box: BoxModel): BoxModel { + return { x: box.x, y: box.y, w: box.w, h: box.h } +} + +function getMinimapPulseAlpha(phase: number): number { + const progress = (phase % MINIMAP_PULSE_PERIOD) / MINIMAP_PULSE_PERIOD + return MINIMAP_FILL_ALPHA + 0.06 * (0.5 + Math.sin(progress * PI2 - Math.PI / 2) * 0.5) +} + +function getDashOffset(elapsed: number, zoom: number): number { + return -((elapsed / 1000) * DASH_SPEED) / zoom +} diff --git a/templates/agent/shared/format/FocusedFontSize.ts b/templates/agent/shared/format/FocusedFontSize.ts index 1dd8e97df1d8..2a29fcfbde5a 100644 --- a/templates/agent/shared/format/FocusedFontSize.ts +++ b/templates/agent/shared/format/FocusedFontSize.ts @@ -1,38 +1,43 @@ -import { TLDefaultSizeStyle } from 'tldraw' +import { + createShapeId, + Editor, + getDisplayValues, + IndexKey, + TLDefaultSizeStyle, + TLTextShape, + TextShapeUtil, + TextShapeUtilDisplayValues, +} from 'tldraw' import { z } from 'zod' -const FONT_SIZES: Record = { - s: 1.125, - m: 1.5, - l: 2.25, - xl: 2.75, -} +const TEXT_SIZE_STYLES = ['s', 'm', 'l', 'xl'] as const satisfies readonly TLDefaultSizeStyle[] export const FocusedFontSize = z.number() -const DEFAULT_BASE_FONT_SIZE = 16 - /** * Calculates the closest predefined font size and scale combination to achieve a target font size + * @param editor - The tldraw editor instance * @param targetFontSize - The desired font size in pixels - * @param baseFontSize - The base font size from the theme (defaults to 16) + * @param textProps - The text shape props to use when resolving display values * @returns An object containing the closest predefined font size key and the scale factor */ export function convertFocusedFontSizeToTldrawFontSizeAndScale( + editor: Editor, targetFontSize: number, - baseFontSize = DEFAULT_BASE_FONT_SIZE + textProps?: Partial ) { - const fontSizeEntries = Object.entries(FONT_SIZES) + const fontSizeEntries = TEXT_SIZE_STYLES.map( + (size) => [size, getTextShapeDisplayFontSize(editor, { ...textProps, size })] as const + ) let closestSize = fontSizeEntries[0] - let closestPixelSize = closestSize[1] * baseFontSize + let closestPixelSize = closestSize[1] let minDifference = Math.abs(targetFontSize - closestPixelSize) - for (const [size, multiplier] of fontSizeEntries) { - const pixelSize = multiplier * baseFontSize + for (const [size, pixelSize] of fontSizeEntries) { const difference = Math.abs(targetFontSize - pixelSize) if (difference < minDifference) { minDifference = difference - closestSize = [size, multiplier] + closestSize = [size, pixelSize] closestPixelSize = pixelSize } } @@ -45,15 +50,37 @@ export function convertFocusedFontSizeToTldrawFontSizeAndScale( /** * Converts a tldraw font size and scale to a focused font size - * @param textSize - The tldraw font size - * @param scale - The tldraw scale - * @param baseFontSize - The base font size from the theme (defaults to 16) + * @param editor - The tldraw editor instance + * @param shape - The text shape to convert * @returns The focused font size */ -export function convertTldrawFontSizeAndScaleToFocusedFontSize( - textSize: TLDefaultSizeStyle, - scale: number, - baseFontSize = DEFAULT_BASE_FONT_SIZE -) { - return Math.round(FONT_SIZES[textSize] * baseFontSize * scale) +export function convertTldrawFontSizeAndScaleToFocusedFontSize(editor: Editor, shape: TLTextShape) { + const util = editor.getShapeUtil('text') + const displayValues = getDisplayValues(util, shape) + return Math.round(displayValues.fontSize * shape.props.scale) +} + +function getTextShapeDisplayFontSize( + editor: Editor, + textProps?: Partial +): number { + const util = editor.getShapeUtil('text') + const shape: TLTextShape = { + id: createShapeId('agent-font-size'), + typeName: 'shape', + type: 'text', + x: 0, + y: 0, + rotation: 0, + index: 'a1' as IndexKey, + parentId: editor.getCurrentPageId(), + isLocked: false, + opacity: 1, + props: { + ...util.getDefaultProps(), + ...textProps, + }, + meta: {}, + } + return getDisplayValues(util, shape).fontSize } diff --git a/templates/agent/shared/format/convertFocusedShapeToTldrawShape.ts b/templates/agent/shared/format/convertFocusedShapeToTldrawShape.ts index 02cf5a4a8608..9a05b2d3c909 100644 --- a/templates/agent/shared/format/convertFocusedShapeToTldrawShape.ts +++ b/templates/agent/shared/format/convertFocusedShapeToTldrawShape.ts @@ -149,10 +149,14 @@ function convertTextShapeToTldrawShape( // Determine the base font size and scale - focusedShape takes priority let textSize: TLDefaultSizeStyle = 's' let scale = 1 + const font = defaultTextShape.props?.font ?? 'draw' if (focusedShape.fontSize) { const { textSize: calculatedTextSize, scale: calculatedScale } = - convertFocusedFontSizeToTldrawFontSizeAndScale(focusedShape.fontSize) + convertFocusedFontSizeToTldrawFontSizeAndScale(editor, focusedShape.fontSize, { + ...defaultTextShape.props, + font, + }) textSize = calculatedTextSize scale = calculatedScale } else if (defaultTextShape.props?.size) { @@ -167,7 +171,6 @@ function convertTextShapeToTldrawShape( focusedShape.maxWidth !== undefined && focusedShape.maxWidth !== null ? false : (defaultTextShape.props?.autoSize ?? true) - const font = defaultTextShape.props?.font ?? 'draw' let richText if (focusedShape.text !== undefined) { diff --git a/templates/agent/shared/format/convertTldrawShapeToFocusedShape.ts b/templates/agent/shared/format/convertTldrawShapeToFocusedShape.ts index a2b94f64004a..6c92f91ee4e7 100644 --- a/templates/agent/shared/format/convertTldrawShapeToFocusedShape.ts +++ b/templates/agent/shared/format/convertTldrawShapeToFocusedShape.ts @@ -112,7 +112,6 @@ function convertTextShapeToFocused(editor: Editor, shape: TLTextShape): FocusedT const util = editor.getShapeUtil(shape) const text = util.getText(shape) ?? '' const bounds = getSimpleBounds(editor, shape) - const textSize = shape.props.size const position = new Vec() let anchor: FocusedTextAnchor = 'top-left' @@ -141,7 +140,7 @@ function convertTextShapeToFocused(editor: Editor, shape: TLTextShape): FocusedT _type: 'text', anchor, color: shape.props.color, - fontSize: convertTldrawFontSizeAndScaleToFocusedFontSize(textSize, shape.props.scale), + fontSize: convertTldrawFontSizeAndScaleToFocusedFontSize(editor, shape), maxWidth: shape.props.autoSize ? null : shape.props.w, note: (shape.meta.note as string) ?? '', shapeId: convertTldrawIdToSimpleId(shape.id), From 5884580752191e3d07fbb00a5aa313ae6ad5ef27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mitja=20Bezen=C5=A1ek?= Date: Fri, 5 Jun 2026 15:57:24 +0200 Subject: [PATCH 9/9] fix(editor): keep focal point fixed when clamping zoom to min/max (#8957) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thinks this works better than preserving the center, wdyt @steveruizok? In order to stop the viewport from shifting when a zoom gesture overshoots the zoom limits, this PR makes `getConstrainedCamera` preserve the caller's focal point (e.g. the cursor) when it clamps the zoom, instead of snapping back to the viewport center. Closes #8942. When you wheel/pinch zoom out past the minimum (or in past the maximum), the gesture computes a camera that keeps the point under the cursor fixed at the *requested* zoom. The old code then clamped the zoom and re-derived `x`/`y` to keep the viewport *center* fixed, discarding the cursor anchor — so the canvas visibly jumped. Now the clamped camera keeps the same focal point fixed. When that focal point happens to be the viewport center (e.g. the zoom-out button), the math reduces to the previous center-preserving behaviour, so nothing else changes. `zoomIn`/`zoomOut` are unaffected because they snap exactly onto zoom steps and never overshoot. ### Before https://github.com/user-attachments/assets/0cc13c03-2ea5-4642-bcf4-2c5f62e57e4d ### After https://github.com/user-attachments/assets/b800a301-b101-4e98-8303-3e5801b7bbeb ### Change type - [x] `bugfix` ### Test plan 1. Open the editor at any zoom level with the pointer away from the screen center. 2. Wheel- or pinch-zoom out until the zoom clamps at the minimum. 3. The point under the cursor should stay put as the zoom hits the limit; further zoom-out gestures should not move the canvas. 4. Repeat zooming in past the maximum. - [x] Unit tests ### Release notes - Fix the viewport shifting slightly when zooming past the minimum or maximum zoom level. ### Code changes | Section | LOC change | | --------- | ---------- | | Core code | +20 / -14 | | Tests | +97 / -1 | --- packages/editor/src/lib/editor/Editor.ts | 34 ++++--- .../src/test/commands/setCamera.test.ts | 98 ++++++++++++++++++- 2 files changed, 117 insertions(+), 15 deletions(-) diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index 8fc09f924209..e6afd9bdbb8a 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -3336,6 +3336,15 @@ export class Editor extends EventEmitter { let { x, y, z = currentCamera.z } = point + // `requested` kept the caller's focal point (e.g. the cursor) fixed at + // zoom `rz`. When `rz` gets clamped, keep that same focal point fixed at + // the clamped zoom `z` rather than snapping to the viewport center. + const preserveFocalPoint = (current: number, requested: number, rz: number, z: number) => { + const cz = currentCamera.z + if (rz === cz) return current + return current + ((requested - current) * (1 / z - 1 / cz)) / (1 / rz - 1 / cz) + } + // If force is true, then we'll set the camera to the point regardless of // the camera options, so that we can handle gestures that permit elasticity // or decay, or animations that occur while the camera is locked. @@ -3378,17 +3387,14 @@ export class Editor extends EventEmitter { } if (z < minZ || z > maxZ) { - // We're trying to zoom out past the minimum zoom level, - // or in past the maximum zoom level, so stop the camera - // but keep the current center - const { x: cx, y: cy, z: cz } = currentCamera - const cxA = -cx + vsb.w / cz / 2 - const cyA = -cy + vsb.h / cz / 2 + // We're trying to zoom out past the minimum zoom level, or in + // past the maximum zoom level, so clamp the zoom while keeping + // the caller's focal point fixed. Axis constraints below still + // apply on top of this. + const rz = z z = clamp(z, minZ, maxZ) - const cxB = -cx + vsb.w / z / 2 - const cyB = -cy + vsb.h / z / 2 - x = cx + cxB - cxA - y = cy + cyB - cyA + x = preserveFocalPoint(currentCamera.x, x, rz, z) + y = preserveFocalPoint(currentCamera.y, y, rz, z) } // Calculate available space @@ -3477,12 +3483,12 @@ export class Editor extends EventEmitter { } } } else { - // constrain the zoom, preserving the center + // constrain the zoom, keeping the caller's focal point fixed if (z > zoomMax || z < zoomMin) { - const { x: cx, y: cy, z: cz } = currentCamera + const rz = z z = clamp(z, zoomMin, zoomMax) - x = cx + (-cx + vsb.w / z / 2) - (-cx + vsb.w / cz / 2) - y = cy + (-cy + vsb.h / z / 2) - (-cy + vsb.h / cz / 2) + x = preserveFocalPoint(currentCamera.x, x, rz, z) + y = preserveFocalPoint(currentCamera.y, y, rz, z) } } } diff --git a/packages/tldraw/src/test/commands/setCamera.test.ts b/packages/tldraw/src/test/commands/setCamera.test.ts index 07c43d47c80b..281765835532 100644 --- a/packages/tldraw/src/test/commands/setCamera.test.ts +++ b/packages/tldraw/src/test/commands/setCamera.test.ts @@ -1,4 +1,4 @@ -import { Box, DEFAULT_CAMERA_OPTIONS, Vec, createShapeId } from '@tldraw/editor' +import { Box, DEFAULT_CAMERA_OPTIONS, Vec, createShapeId, last } from '@tldraw/editor' import { vi } from 'vitest' import { TestEditor } from '../TestEditor' @@ -141,6 +141,102 @@ describe('With default options', () => { }) }) +describe('Zoom clamping preserves the focal point', () => { + beforeEach(() => { + editor.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS }) + }) + + // An off-center screen point, so a center-preserving clamp would shift it. + const cursor = { x: 200, y: 150 } + + it('keeps the point under the cursor fixed when wheel zooming out past the min', () => { + editor.setCamera({ x: 0, y: 0, z: 0.06 }) // just above the 0.05 min + editor.pointerMove(cursor.x, cursor.y) + const before = editor.screenToPage(cursor) + + // Each event halves the zoom, overshooting the min, then pins at it. + for (let i = 0; i < 4; i++) { + editor + .dispatch({ + ...wheelEvent, + point: new Vec(cursor.x, cursor.y), + delta: new Vec(0, 0, -0.5), + ctrlKey: true, + }) + .forceTick() + } + + expect(editor.getZoomLevel()).toBe(DEFAULT_CAMERA_OPTIONS.zoomSteps[0]) + const after = editor.screenToPage(cursor) + expect(after.x).toBeCloseTo(before.x, 4) + expect(after.y).toBeCloseTo(before.y, 4) + }) + + it('keeps the point under the cursor fixed when wheel zooming in past the max', () => { + const max = last(DEFAULT_CAMERA_OPTIONS.zoomSteps)! + editor.setCamera({ x: 0, y: 0, z: max * 0.9 }) // just below the max + editor.pointerMove(cursor.x, cursor.y) + const before = editor.screenToPage(cursor) + + for (let i = 0; i < 4; i++) { + editor + .dispatch({ + ...wheelEvent, + point: new Vec(cursor.x, cursor.y), + delta: new Vec(0, 0, 0.5), + ctrlKey: true, + }) + .forceTick() + } + + expect(editor.getZoomLevel()).toBe(max) + const after = editor.screenToPage(cursor) + expect(after.x).toBeCloseTo(before.x, 4) + expect(after.y).toBeCloseTo(before.y, 4) + }) + + it('does not translate the camera once zoom is pinned at the min', () => { + const min = DEFAULT_CAMERA_OPTIONS.zoomSteps[0] + editor.setCamera({ x: 0, y: 0, z: min }) + editor.pointerMove(cursor.x, cursor.y) + const camera = { ...editor.getCamera() } + + for (let i = 0; i < 3; i++) { + editor + .dispatch({ + ...wheelEvent, + point: new Vec(cursor.x, cursor.y), + delta: new Vec(0, 0, -0.5), + ctrlKey: true, + }) + .forceTick() + } + + expect(editor.getCamera()).toMatchObject({ x: camera.x, y: camera.y, z: min }) + }) + + it('keeps the focal point fixed when setCamera requests an out-of-range zoom', () => { + // Mimic a cursor-anchored zoom request that overshoots the min: choose + // x/y so screen point (200, 150) stays fixed at the requested zoom. + const px = cursor.x + const py = cursor.y + editor.setCamera({ x: 0, y: 0, z: 0.06 }) + const { x: cx, y: cy, z: cz } = editor.getCamera() + const before = editor.screenToPage(cursor) + + const requestedZoom = 0.02 // below the 0.05 min + editor.setCamera( + new Vec(cx + px / requestedZoom - px / cz, cy + py / requestedZoom - py / cz, requestedZoom), + { immediate: true } + ) + + expect(editor.getZoomLevel()).toBe(DEFAULT_CAMERA_OPTIONS.zoomSteps[0]) + const after = editor.screenToPage(cursor) + expect(after.x).toBeCloseTo(before.x, 4) + expect(after.y).toBeCloseTo(before.y, 4) + }) +}) + it('Sets the camera options', () => { const optionsA = { ...DEFAULT_CAMERA_OPTIONS, panSpeed: 2 } editor.setCameraOptions(optionsA)