tldraw 4.3.0-canary.c7096a59bf3b → 4.3.0-canary.cf5673a789a1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist-cjs/index.d.ts +3 -0
  2. package/dist-cjs/index.js +2 -1
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/ui/components/primitives/TldrawUiSlider.js +1 -1
  5. package/dist-cjs/lib/ui/components/primitives/TldrawUiSlider.js.map +2 -2
  6. package/dist-cjs/lib/ui/components/primitives/TldrawUiTooltip.js +144 -77
  7. package/dist-cjs/lib/ui/components/primitives/TldrawUiTooltip.js.map +2 -2
  8. package/dist-cjs/lib/ui/components/primitives/menus/TldrawUiMenuItem.js +1 -1
  9. package/dist-cjs/lib/ui/components/primitives/menus/TldrawUiMenuItem.js.map +2 -2
  10. package/dist-cjs/lib/ui/version.js +3 -3
  11. package/dist-cjs/lib/ui/version.js.map +1 -1
  12. package/dist-esm/index.d.mts +3 -0
  13. package/dist-esm/index.mjs +3 -1
  14. package/dist-esm/index.mjs.map +2 -2
  15. package/dist-esm/lib/ui/components/primitives/TldrawUiSlider.mjs +2 -2
  16. package/dist-esm/lib/ui/components/primitives/TldrawUiSlider.mjs.map +2 -2
  17. package/dist-esm/lib/ui/components/primitives/TldrawUiTooltip.mjs +144 -78
  18. package/dist-esm/lib/ui/components/primitives/TldrawUiTooltip.mjs.map +2 -2
  19. package/dist-esm/lib/ui/components/primitives/menus/TldrawUiMenuItem.mjs +2 -2
  20. package/dist-esm/lib/ui/components/primitives/menus/TldrawUiMenuItem.mjs.map +2 -2
  21. package/dist-esm/lib/ui/version.mjs +3 -3
  22. package/dist-esm/lib/ui/version.mjs.map +1 -1
  23. package/package.json +3 -3
  24. package/src/index.ts +1 -0
  25. package/src/lib/ui/components/primitives/TldrawUiSlider.tsx +2 -2
  26. package/src/lib/ui/components/primitives/TldrawUiTooltip.tsx +188 -95
  27. package/src/lib/ui/components/primitives/menus/TldrawUiMenuItem.tsx +2 -2
  28. package/src/lib/ui/version.ts +3 -3
  29. package/src/test/commands/putContent.test.ts +79 -1
@@ -6,7 +6,6 @@ import React, {
6
6
  ReactNode,
7
7
  useContext,
8
8
  useEffect,
9
- useLayoutEffect,
10
9
  useRef,
11
10
  useState,
12
11
  } from 'react'
@@ -25,7 +24,7 @@ export interface TldrawUiTooltipProps {
25
24
  delayDuration?: number
26
25
  }
27
26
 
28
- interface CurrentTooltip {
27
+ interface TooltipData {
29
28
  id: string
30
29
  content: ReactNode
31
30
  side: 'top' | 'right' | 'bottom' | 'left'
@@ -35,11 +34,25 @@ interface CurrentTooltip {
35
34
  delayDuration: number
36
35
  }
37
36
 
38
- // Singleton tooltip manager
37
+ // State machine states
38
+ type TooltipState =
39
+ | { name: 'idle' }
40
+ | { name: 'pointer_down' }
41
+ | { name: 'showing'; tooltip: TooltipData }
42
+ | { name: 'waiting_to_hide'; tooltip: TooltipData; timeoutId: number }
43
+
44
+ // State machine events
45
+ type TooltipEvent =
46
+ | { type: 'pointer_down' }
47
+ | { type: 'pointer_up' }
48
+ | { type: 'show'; tooltip: TooltipData }
49
+ | { type: 'hide'; tooltipId: string; editor: Editor | null; instant: boolean }
50
+ | { type: 'hide_all' }
51
+
52
+ // Singleton tooltip manager using explicit state machine
39
53
  class TooltipManager {
40
54
  private static instance: TooltipManager | null = null
41
- private currentTooltip = atom<CurrentTooltip | null>('current tooltip', null)
42
- private destroyTimeoutId: number | null = null
55
+ private state = atom<TooltipState>('tooltip state', { name: 'idle' })
43
56
 
44
57
  static getInstance(): TooltipManager {
45
58
  if (!TooltipManager.instance) {
@@ -48,69 +61,108 @@ class TooltipManager {
48
61
  return TooltipManager.instance
49
62
  }
50
63
 
51
- showTooltip(
52
- tooltipId: string,
53
- content: string | React.ReactNode,
54
- targetElement: HTMLElement,
55
- side: 'top' | 'right' | 'bottom' | 'left',
56
- sideOffset: number,
57
- showOnMobile: boolean,
58
- delayDuration: number
59
- ) {
60
- // Clear any existing destroy timeout
61
- if (this.destroyTimeoutId) {
62
- clearTimeout(this.destroyTimeoutId)
63
- this.destroyTimeoutId = null
64
- }
65
-
66
- // Update current tooltip
67
- this.currentTooltip.set({
68
- id: tooltipId,
69
- content,
70
- side,
71
- sideOffset,
72
- showOnMobile,
73
- targetElement,
74
- delayDuration,
75
- })
64
+ hideAllTooltips() {
65
+ this.handleEvent({ type: 'hide_all' })
76
66
  }
77
67
 
78
- updateCurrentTooltip(tooltipId: string, update: (tooltip: CurrentTooltip) => CurrentTooltip) {
79
- this.currentTooltip.update((tooltip) => {
80
- if (tooltip?.id === tooltipId) {
81
- return update(tooltip)
68
+ handleEvent(event: TooltipEvent) {
69
+ const currentState = this.state.get()
70
+
71
+ switch (event.type) {
72
+ case 'pointer_down': {
73
+ // Transition to pointer_down from any state
74
+ if (currentState.name === 'waiting_to_hide') {
75
+ clearTimeout(currentState.timeoutId)
76
+ }
77
+ this.state.set({ name: 'pointer_down' })
78
+ break
82
79
  }
83
- return tooltip
84
- })
85
- }
86
80
 
87
- hideTooltip(editor: Editor | null, tooltipId: string, instant: boolean = false) {
88
- const hide = () => {
89
- // Only hide if this is the current tooltip
90
- if (this.currentTooltip.get()?.id === tooltipId) {
91
- this.currentTooltip.set(null)
92
- this.destroyTimeoutId = null
81
+ case 'pointer_up': {
82
+ // Only transition from pointer_down to idle
83
+ if (currentState.name === 'pointer_down') {
84
+ this.state.set({ name: 'idle' })
85
+ }
86
+ break
93
87
  }
94
- }
95
88
 
96
- if (editor && !instant) {
97
- // Start destroy timeout (1 second)
98
- this.destroyTimeoutId = editor.timers.setTimeout(hide, 300)
99
- } else {
100
- hide()
89
+ case 'show': {
90
+ // Don't show tooltips while pointer is down
91
+ if (currentState.name === 'pointer_down') {
92
+ return
93
+ }
94
+
95
+ // Clear any existing timeout if transitioning from waiting_to_hide
96
+ if (currentState.name === 'waiting_to_hide') {
97
+ clearTimeout(currentState.timeoutId)
98
+ }
99
+
100
+ // Transition to showing state
101
+ this.state.set({ name: 'showing', tooltip: event.tooltip })
102
+ break
103
+ }
104
+
105
+ case 'hide': {
106
+ const { tooltipId, editor, instant } = event
107
+
108
+ // Only hide if the tooltip matches
109
+ if (currentState.name === 'showing' && currentState.tooltip.id === tooltipId) {
110
+ if (editor && !instant) {
111
+ // Transition to waiting_to_hide state
112
+ const timeoutId = editor.timers.setTimeout(() => {
113
+ const state = this.state.get()
114
+ if (state.name === 'waiting_to_hide' && state.tooltip.id === tooltipId) {
115
+ this.state.set({ name: 'idle' })
116
+ }
117
+ }, 300)
118
+ this.state.set({
119
+ name: 'waiting_to_hide',
120
+ tooltip: currentState.tooltip,
121
+ timeoutId,
122
+ })
123
+ } else {
124
+ this.state.set({ name: 'idle' })
125
+ }
126
+ } else if (
127
+ currentState.name === 'waiting_to_hide' &&
128
+ currentState.tooltip.id === tooltipId
129
+ ) {
130
+ // Already waiting to hide, make it instant if requested
131
+ if (instant) {
132
+ clearTimeout(currentState.timeoutId)
133
+ this.state.set({ name: 'idle' })
134
+ }
135
+ }
136
+ break
137
+ }
138
+
139
+ case 'hide_all': {
140
+ if (currentState.name === 'waiting_to_hide') {
141
+ clearTimeout(currentState.timeoutId)
142
+ }
143
+ // Preserve pointer_down state if that's the current state
144
+ if (currentState.name === 'pointer_down') {
145
+ return
146
+ }
147
+ this.state.set({ name: 'idle' })
148
+ break
149
+ }
101
150
  }
102
151
  }
103
152
 
104
- hideAllTooltips() {
105
- this.currentTooltip.set(null)
106
- this.destroyTimeoutId = null
107
- }
153
+ getCurrentTooltipData(): TooltipData | null {
154
+ const currentState = this.state.get()
155
+ let tooltip: TooltipData | null = null
108
156
 
109
- getCurrentTooltipData() {
110
- const currentTooltip = this.currentTooltip.get()
111
- if (!currentTooltip) return null
112
- if (!this.supportsHover() && !currentTooltip.showOnMobile) return null
113
- return currentTooltip
157
+ if (currentState.name === 'showing') {
158
+ tooltip = currentState.tooltip
159
+ } else if (currentState.name === 'waiting_to_hide') {
160
+ tooltip = currentState.tooltip
161
+ }
162
+
163
+ if (!tooltip) return null
164
+ if (!this.supportsHover() && !tooltip.showOnMobile) return null
165
+ return tooltip
114
166
  }
115
167
 
116
168
  private supportsHoverAtom: Atom<boolean> | null = null
@@ -127,7 +179,12 @@ class TooltipManager {
127
179
  }
128
180
  }
129
181
 
130
- export const tooltipManager = TooltipManager.getInstance()
182
+ const tooltipManager = TooltipManager.getInstance()
183
+
184
+ /** @public */
185
+ export function hideAllTooltips() {
186
+ tooltipManager.hideAllTooltips()
187
+ }
131
188
 
132
189
  // Context for the tooltip singleton
133
190
  const TooltipSingletonContext = createContext<boolean>(false)
@@ -167,14 +224,19 @@ function TooltipSingleton() {
167
224
  // Hide tooltip when camera is moving (panning/zooming)
168
225
  useEffect(() => {
169
226
  if (cameraState === 'moving' && isOpen && currentTooltip) {
170
- tooltipManager.hideTooltip(editor, currentTooltip.id, true)
227
+ tooltipManager.handleEvent({
228
+ type: 'hide',
229
+ tooltipId: currentTooltip.id,
230
+ editor,
231
+ instant: true,
232
+ })
171
233
  }
172
234
  }, [cameraState, isOpen, currentTooltip, editor])
173
235
 
174
236
  useEffect(() => {
175
237
  function handleKeyDown(event: KeyboardEvent) {
176
238
  if (event.key === 'Escape' && currentTooltip && isOpen) {
177
- tooltipManager.hideTooltip(editor, currentTooltip.id)
239
+ hideAllTooltips()
178
240
  event.stopPropagation()
179
241
  }
180
242
  }
@@ -183,7 +245,29 @@ function TooltipSingleton() {
183
245
  return () => {
184
246
  document.removeEventListener('keydown', handleKeyDown, { capture: true })
185
247
  }
186
- }, [editor, currentTooltip, isOpen])
248
+ }, [currentTooltip, isOpen])
249
+
250
+ // Hide tooltip and prevent new ones from opening while pointer is down
251
+ useEffect(() => {
252
+ function handlePointerDown() {
253
+ tooltipManager.handleEvent({ type: 'pointer_down' })
254
+ }
255
+
256
+ function handlePointerUp() {
257
+ tooltipManager.handleEvent({ type: 'pointer_up' })
258
+ }
259
+
260
+ document.addEventListener('pointerdown', handlePointerDown, { capture: true })
261
+ document.addEventListener('pointerup', handlePointerUp, { capture: true })
262
+ document.addEventListener('pointercancel', handlePointerUp, { capture: true })
263
+ return () => {
264
+ document.removeEventListener('pointerdown', handlePointerDown, { capture: true })
265
+ document.removeEventListener('pointerup', handlePointerUp, { capture: true })
266
+ document.removeEventListener('pointercancel', handlePointerUp, { capture: true })
267
+ // Reset pointer state on unmount to prevent stuck state
268
+ tooltipManager.handleEvent({ type: 'pointer_up' })
269
+ }
270
+ }, [])
187
271
 
188
272
  // Update open state and trigger position
189
273
  useEffect(() => {
@@ -280,23 +364,16 @@ export const TldrawUiTooltip = forwardRef<HTMLButtonElement, TldrawUiTooltipProp
280
364
  const currentTooltipId = tooltipId.current
281
365
  return () => {
282
366
  if (hasProvider) {
283
- tooltipManager.hideTooltip(editor, currentTooltipId, true)
367
+ tooltipManager.handleEvent({
368
+ type: 'hide',
369
+ tooltipId: currentTooltipId,
370
+ editor,
371
+ instant: true,
372
+ })
284
373
  }
285
374
  }
286
375
  }, [editor, hasProvider])
287
376
 
288
- useLayoutEffect(() => {
289
- if (hasProvider && tooltipManager.getCurrentTooltipData()?.id === tooltipId.current) {
290
- tooltipManager.updateCurrentTooltip(tooltipId.current, (tooltip) => ({
291
- ...tooltip,
292
- content,
293
- side: sideToUse,
294
- sideOffset,
295
- showOnMobile,
296
- }))
297
- }
298
- }, [content, sideToUse, sideOffset, showOnMobile, hasProvider])
299
-
300
377
  // Don't show tooltip if disabled, no content, or enhanced accessibility mode is disabled
301
378
  if (disabled || !content) {
302
379
  return <>{children}</>
@@ -340,38 +417,54 @@ export const TldrawUiTooltip = forwardRef<HTMLButtonElement, TldrawUiTooltipProp
340
417
 
341
418
  const handleMouseEnter = (event: React.MouseEvent<HTMLElement>) => {
342
419
  child.props.onMouseEnter?.(event)
343
- tooltipManager.showTooltip(
344
- tooltipId.current,
345
- content,
346
- event.currentTarget as HTMLElement,
347
- sideToUse,
348
- sideOffset,
349
- showOnMobile,
350
- delayDurationToUse
351
- )
420
+ tooltipManager.handleEvent({
421
+ type: 'show',
422
+ tooltip: {
423
+ id: tooltipId.current,
424
+ content,
425
+ targetElement: event.currentTarget as HTMLElement,
426
+ side: sideToUse,
427
+ sideOffset,
428
+ showOnMobile,
429
+ delayDuration: delayDurationToUse,
430
+ },
431
+ })
352
432
  }
353
433
 
354
434
  const handleMouseLeave = (event: React.MouseEvent<HTMLElement>) => {
355
435
  child.props.onMouseLeave?.(event)
356
- tooltipManager.hideTooltip(editor, tooltipId.current)
436
+ tooltipManager.handleEvent({
437
+ type: 'hide',
438
+ tooltipId: tooltipId.current,
439
+ editor,
440
+ instant: false,
441
+ })
357
442
  }
358
443
 
359
444
  const handleFocus = (event: React.FocusEvent<HTMLElement>) => {
360
445
  child.props.onFocus?.(event)
361
- tooltipManager.showTooltip(
362
- tooltipId.current,
363
- content,
364
- event.currentTarget as HTMLElement,
365
- sideToUse,
366
- sideOffset,
367
- showOnMobile,
368
- delayDurationToUse
369
- )
446
+ tooltipManager.handleEvent({
447
+ type: 'show',
448
+ tooltip: {
449
+ id: tooltipId.current,
450
+ content,
451
+ targetElement: event.currentTarget as HTMLElement,
452
+ side: sideToUse,
453
+ sideOffset,
454
+ showOnMobile,
455
+ delayDuration: delayDurationToUse,
456
+ },
457
+ })
370
458
  }
371
459
 
372
460
  const handleBlur = (event: React.FocusEvent<HTMLElement>) => {
373
461
  child.props.onBlur?.(event)
374
- tooltipManager.hideTooltip(editor, tooltipId.current)
462
+ tooltipManager.handleEvent({
463
+ type: 'hide',
464
+ tooltipId: tooltipId.current,
465
+ editor,
466
+ instant: false,
467
+ })
375
468
  }
376
469
 
377
470
  const childrenWithHandlers = React.cloneElement(children as React.ReactElement, {
@@ -24,7 +24,7 @@ import { TldrawUiDropdownMenuItem } from '../TldrawUiDropdownMenu'
24
24
  import { TLUiIconJsx } from '../TldrawUiIcon'
25
25
  import { TldrawUiKbd } from '../TldrawUiKbd'
26
26
  import { TldrawUiToolbarButton } from '../TldrawUiToolbar'
27
- import { tooltipManager } from '../TldrawUiTooltip'
27
+ import { hideAllTooltips } from '../TldrawUiTooltip'
28
28
  import { useTldrawUiMenuContext } from './TldrawUiMenuContext'
29
29
 
30
30
  /** @public */
@@ -350,7 +350,7 @@ function useDraggableEvents(
350
350
  point: screenSpaceStart,
351
351
  })
352
352
 
353
- tooltipManager.hideAllTooltips()
353
+ hideAllTooltips()
354
354
  editor.getContainer().focus()
355
355
  })
356
356
  }
@@ -1,9 +1,9 @@
1
1
  // This file is automatically generated by internal/scripts/refresh-assets.ts.
2
2
  // Do not edit manually. Or do, I'm a comment, not a cop.
3
3
 
4
- export const version = '4.3.0-canary.c7096a59bf3b'
4
+ export const version = '4.3.0-canary.cf5673a789a1'
5
5
  export const publishDates = {
6
6
  major: '2025-09-18T14:39:22.803Z',
7
- minor: '2025-12-01T14:44:15.569Z',
8
- patch: '2025-12-01T14:44:15.569Z',
7
+ minor: '2025-12-05T09:37:06.859Z',
8
+ patch: '2025-12-05T09:37:06.859Z',
9
9
  }
@@ -1,4 +1,4 @@
1
- import { TLContent, structuredClone } from '@tldraw/editor'
1
+ import { TLContent, createShapeId, structuredClone } from '@tldraw/editor'
2
2
  import { TestEditor } from '../TestEditor'
3
3
 
4
4
  let editor: TestEditor
@@ -38,3 +38,81 @@ describe('Migrations', () => {
38
38
  expect(() => editor.putContentOntoCurrentPage(withInvalidShapeModel)).toThrow()
39
39
  })
40
40
  })
41
+
42
+ describe('Paste parent selection with explicit point', () => {
43
+ it('falls back to the page when the cursor is outside the original parent', () => {
44
+ const frameId = createShapeId('frame')
45
+ const childId = createShapeId('child')
46
+
47
+ editor.createShapes([
48
+ {
49
+ id: frameId,
50
+ type: 'frame',
51
+ x: 0,
52
+ y: 0,
53
+ props: { w: 200, h: 200 },
54
+ },
55
+ {
56
+ id: childId,
57
+ type: 'geo',
58
+ parentId: frameId,
59
+ x: 40,
60
+ y: 40,
61
+ props: { w: 60, h: 60 },
62
+ },
63
+ ])
64
+
65
+ editor.select(childId)
66
+ editor.copy()
67
+
68
+ editor.putContentOntoCurrentPage(editor.clipboard!, {
69
+ point: { x: 500, y: 500 },
70
+ select: true,
71
+ })
72
+
73
+ const [pastedId] = editor.getSelectedShapeIds()
74
+ expect(editor.getShape(pastedId)?.parentId).toBe(editor.getCurrentPageId())
75
+ })
76
+
77
+ it('uses the parent under the cursor when it can accept the pasted shapes', () => {
78
+ const frameAId = createShapeId('frameA')
79
+ const frameBId = createShapeId('frameB')
80
+ const childId = createShapeId('child')
81
+
82
+ editor.createShapes([
83
+ {
84
+ id: frameAId,
85
+ type: 'frame',
86
+ x: 0,
87
+ y: 0,
88
+ props: { w: 200, h: 200 },
89
+ },
90
+ {
91
+ id: frameBId,
92
+ type: 'frame',
93
+ x: 400,
94
+ y: 0,
95
+ props: { w: 200, h: 200 },
96
+ },
97
+ {
98
+ id: childId,
99
+ type: 'geo',
100
+ parentId: frameAId,
101
+ x: 40,
102
+ y: 40,
103
+ props: { w: 60, h: 60 },
104
+ },
105
+ ])
106
+
107
+ editor.select(childId)
108
+ editor.copy()
109
+
110
+ editor.putContentOntoCurrentPage(editor.clipboard!, {
111
+ point: { x: 450, y: 50 },
112
+ select: true,
113
+ })
114
+
115
+ const [pastedId] = editor.getSelectedShapeIds()
116
+ expect(editor.getShape(pastedId)?.parentId).toBe(frameBId)
117
+ })
118
+ })