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/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) ? ( { // 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/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/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 @@
+ 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/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/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/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/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/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/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') 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/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/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/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/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/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}` 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/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/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) 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') 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), 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