tldraw 3.16.0-canary.ed8bd30c0f28 → 3.16.0-canary.f60032f16651

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 (36) hide show
  1. package/dist-cjs/index.d.ts +32 -1
  2. package/dist-cjs/index.js +2 -1
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/shapes/arrow/arrowTargetState.js +1 -1
  5. package/dist-cjs/lib/shapes/arrow/arrowTargetState.js.map +2 -2
  6. package/dist-cjs/lib/tools/SelectTool/childStates/Translating.js.map +2 -2
  7. package/dist-cjs/lib/ui/components/primitives/menus/TldrawUiMenuItem.js +149 -1
  8. package/dist-cjs/lib/ui/components/primitives/menus/TldrawUiMenuItem.js.map +2 -2
  9. package/dist-cjs/lib/ui/context/events.js.map +2 -2
  10. package/dist-cjs/lib/ui/hooks/useTools.js +76 -9
  11. package/dist-cjs/lib/ui/hooks/useTools.js.map +2 -2
  12. package/dist-cjs/lib/ui/version.js +3 -3
  13. package/dist-cjs/lib/ui/version.js.map +1 -1
  14. package/dist-esm/index.d.mts +32 -1
  15. package/dist-esm/index.mjs +3 -1
  16. package/dist-esm/index.mjs.map +2 -2
  17. package/dist-esm/lib/shapes/arrow/arrowTargetState.mjs +1 -1
  18. package/dist-esm/lib/shapes/arrow/arrowTargetState.mjs.map +2 -2
  19. package/dist-esm/lib/tools/SelectTool/childStates/Translating.mjs.map +2 -2
  20. package/dist-esm/lib/ui/components/primitives/menus/TldrawUiMenuItem.mjs +157 -3
  21. package/dist-esm/lib/ui/components/primitives/menus/TldrawUiMenuItem.mjs.map +2 -2
  22. package/dist-esm/lib/ui/context/events.mjs.map +2 -2
  23. package/dist-esm/lib/ui/hooks/useTools.mjs +83 -10
  24. package/dist-esm/lib/ui/hooks/useTools.mjs.map +2 -2
  25. package/dist-esm/lib/ui/version.mjs +3 -3
  26. package/dist-esm/lib/ui/version.mjs.map +1 -1
  27. package/package.json +3 -3
  28. package/src/index.ts +2 -0
  29. package/src/lib/shapes/arrow/arrowTargetState.ts +2 -1
  30. package/src/lib/tools/SelectTool/childStates/Translating.ts +0 -1
  31. package/src/lib/ui/components/primitives/menus/TldrawUiMenuItem.tsx +213 -2
  32. package/src/lib/ui/context/events.tsx +1 -0
  33. package/src/lib/ui/hooks/useTools.tsx +118 -10
  34. package/src/lib/ui/version.ts +3 -3
  35. package/src/test/arrows-megabus.test.tsx +12 -6
  36. package/src/test/inner-outer-margin.test.ts +315 -0
@@ -1,9 +1,17 @@
1
- import { exhaustiveSwitchError, preventDefault } from '@tldraw/editor'
1
+ import {
2
+ exhaustiveSwitchError,
3
+ getPointerInfo,
4
+ preventDefault,
5
+ TLPointerEventInfo,
6
+ useEditor,
7
+ Vec,
8
+ } from '@tldraw/editor'
2
9
  import { ContextMenu as _ContextMenu } from 'radix-ui'
3
- import { useState } from 'react'
10
+ import { useMemo, useState } from 'react'
4
11
  import { unwrapLabel } from '../../../context/actions'
5
12
  import { TLUiEventSource } from '../../../context/events'
6
13
  import { useReadonly } from '../../../hooks/useReadonly'
14
+ import { TLUiToolItem } from '../../../hooks/useTools'
7
15
  import { TLUiTranslationKey } from '../../../hooks/useTranslation/TLUiTranslationKey'
8
16
  import { useTranslation } from '../../../hooks/useTranslation/useTranslation'
9
17
  import { kbdStr } from '../../../kbd-utils'
@@ -63,6 +71,10 @@ export interface TLUiMenuItemProps<
63
71
  * Whether the item is selected.
64
72
  */
65
73
  isSelected?: boolean
74
+ /**
75
+ * The function to call when the item is dragged. If this is provided, the item will be draggable.
76
+ */
77
+ onDragStart?(source: TLUiEventSource, info: TLPointerEventInfo): void
66
78
  }
67
79
 
68
80
  /** @public @react */
@@ -81,6 +93,7 @@ export function TldrawUiMenuItem<
81
93
  onSelect,
82
94
  noClose,
83
95
  isSelected,
96
+ onDragStart,
84
97
  }: TLUiMenuItemProps<TranslationKey, IconType>) {
85
98
  const { type: menuType, sourceId } = useTldrawUiMenuContext()
86
99
 
@@ -207,6 +220,20 @@ export function TldrawUiMenuItem<
207
220
  )
208
221
  }
209
222
  case 'toolbar': {
223
+ if (onDragStart) {
224
+ return (
225
+ <DraggableToolbarButton
226
+ id={id}
227
+ icon={icon}
228
+ onSelect={onSelect}
229
+ onDragStart={onDragStart}
230
+ labelToUse={labelToUse}
231
+ titleStr={titleStr}
232
+ disabled={disabled}
233
+ isSelected={isSelected}
234
+ />
235
+ )
236
+ }
210
237
  return (
211
238
  <TldrawUiToolbarButton
212
239
  aria-label={labelStr}
@@ -227,6 +254,21 @@ export function TldrawUiMenuItem<
227
254
  )
228
255
  }
229
256
  case 'toolbar-overflow': {
257
+ if (onDragStart) {
258
+ return (
259
+ <DraggableToolbarButton
260
+ id={id}
261
+ icon={icon}
262
+ onSelect={onSelect}
263
+ onDragStart={onDragStart}
264
+ labelToUse={labelToUse}
265
+ titleStr={titleStr}
266
+ disabled={disabled}
267
+ isSelected={isSelected}
268
+ overflow
269
+ />
270
+ )
271
+ }
230
272
  return (
231
273
  <TldrawUiToolbarButton
232
274
  aria-label={labelStr}
@@ -249,3 +291,172 @@ export function TldrawUiMenuItem<
249
291
  }
250
292
  }
251
293
  }
294
+
295
+ function useDraggableEvents(
296
+ onDragStart: TLUiToolItem['onDragStart'],
297
+ onSelect: TLUiToolItem['onSelect']
298
+ ) {
299
+ const editor = useEditor()
300
+ const events = useMemo(() => {
301
+ let state = { name: 'idle' } as
302
+ | {
303
+ name: 'idle'
304
+ }
305
+ | {
306
+ name: 'pointing'
307
+ start: Vec
308
+ }
309
+ | {
310
+ name: 'dragging'
311
+ start: Vec
312
+ }
313
+ | {
314
+ name: 'dragged'
315
+ }
316
+
317
+ function handlePointerDown(e: React.PointerEvent<HTMLButtonElement>) {
318
+ state = {
319
+ name: 'pointing',
320
+ start: editor.inputs.currentPagePoint.clone(),
321
+ }
322
+
323
+ e.currentTarget.setPointerCapture(e.pointerId)
324
+ }
325
+
326
+ function handlePointerMove(e: React.PointerEvent<HTMLButtonElement>) {
327
+ if ((e as any).isSpecialRedispatchedEvent) return
328
+
329
+ if (state.name === 'pointing') {
330
+ const distance = Vec.Dist2(state.start, editor.inputs.currentPagePoint)
331
+ if (
332
+ distance >
333
+ (editor.getInstanceState().isCoarsePointer
334
+ ? editor.options.coarseDragDistanceSquared
335
+ : editor.options.dragDistanceSquared)
336
+ ) {
337
+ const start = state.start
338
+ state = {
339
+ name: 'dragging',
340
+ start,
341
+ }
342
+
343
+ editor.run(() => {
344
+ // Set origin point
345
+ editor.dispatch({
346
+ type: 'pointer',
347
+ target: 'canvas',
348
+ name: 'pointer_down',
349
+ ...getPointerInfo(e),
350
+ point: start,
351
+ })
352
+
353
+ // Pointer down potentially selects shapes, so we need to deselect them.
354
+ editor.selectNone()
355
+
356
+ // start drag
357
+ onDragStart?.('toolbar', {
358
+ type: 'pointer',
359
+ target: 'canvas',
360
+ name: 'pointer_move',
361
+ ...getPointerInfo(e),
362
+ })
363
+ })
364
+ }
365
+ }
366
+ }
367
+
368
+ function handlePointerUp(e: React.PointerEvent<HTMLButtonElement>) {
369
+ if ((e as any).isSpecialRedispatchedEvent) return
370
+
371
+ e.currentTarget.releasePointerCapture(e.pointerId)
372
+
373
+ editor.dispatch({
374
+ type: 'pointer',
375
+ target: 'canvas',
376
+ name: 'pointer_up',
377
+ ...getPointerInfo(e),
378
+ })
379
+ }
380
+
381
+ function handleClick() {
382
+ if (state.name === 'dragging' || state.name === 'dragged') {
383
+ state = { name: 'idle' }
384
+ return true
385
+ }
386
+
387
+ state = { name: 'idle' }
388
+ onSelect?.('toolbar')
389
+ }
390
+
391
+ return {
392
+ onPointerDown: handlePointerDown,
393
+ onPointerMove: handlePointerMove,
394
+ onPointerUp: handlePointerUp,
395
+ onClick: handleClick,
396
+ }
397
+ }, [onDragStart, editor, onSelect])
398
+
399
+ return events
400
+ }
401
+
402
+ function DraggableToolbarButton({
403
+ id,
404
+ labelToUse,
405
+ titleStr,
406
+ disabled,
407
+ isSelected,
408
+ icon,
409
+ onSelect,
410
+ onDragStart,
411
+ overflow,
412
+ }: {
413
+ id: string
414
+ disabled: boolean
415
+ labelToUse?: string
416
+ titleStr?: string
417
+ isSelected?: boolean
418
+ icon: TLUiMenuItemProps['icon']
419
+ onSelect: TLUiMenuItemProps['onSelect']
420
+ onDragStart: TLUiMenuItemProps['onDragStart']
421
+ overflow?: boolean
422
+ }) {
423
+ const events = useDraggableEvents(onDragStart, onSelect)
424
+
425
+ if (overflow) {
426
+ return (
427
+ <TldrawUiToolbarButton
428
+ aria-label={labelToUse}
429
+ aria-pressed={isSelected ? 'true' : 'false'}
430
+ isActive={isSelected}
431
+ className="tlui-button-grid__button"
432
+ data-testid={`tools.more.${id}`}
433
+ data-value={id}
434
+ disabled={disabled}
435
+ title={titleStr}
436
+ type="icon"
437
+ {...events}
438
+ >
439
+ <TldrawUiButtonIcon icon={icon!} />
440
+ </TldrawUiToolbarButton>
441
+ )
442
+ }
443
+
444
+ return (
445
+ <TldrawUiToolbarButton
446
+ aria-label={labelToUse}
447
+ aria-pressed={isSelected ? 'true' : 'false'}
448
+ data-testid={`tools.${id}`}
449
+ data-value={id}
450
+ disabled={disabled}
451
+ onTouchStart={(e) => {
452
+ preventDefault(e)
453
+ onSelect('toolbar')
454
+ }}
455
+ title={titleStr}
456
+ type="tool"
457
+ {...events}
458
+ >
459
+ <TldrawUiButtonIcon icon={icon!} />
460
+ </TldrawUiToolbarButton>
461
+ )
462
+ }
@@ -126,6 +126,7 @@ export interface TLUiEventMap {
126
126
  'open-context-menu': null
127
127
  'adjust-shape-styles': null
128
128
  'copy-link': null
129
+ 'drag-tool': { id: string }
129
130
  'image-replace': null
130
131
  'video-replace': null
131
132
  'open-kbd-shortcuts': null
@@ -1,4 +1,13 @@
1
- import { Editor, GeoShapeGeoStyle, useMaybeEditor } from '@tldraw/editor'
1
+ import {
2
+ assertExists,
3
+ createShapeId,
4
+ Editor,
5
+ GeoShapeGeoStyle,
6
+ TLPointerEventInfo,
7
+ TLShapeId,
8
+ toRichText,
9
+ useMaybeEditor,
10
+ } from '@tldraw/editor'
2
11
  import * as React from 'react'
3
12
  import { EmbedDialog } from '../components/EmbedDialog'
4
13
  import { TLUiIconJsx } from '../components/primitives/TldrawUiIcon'
@@ -19,6 +28,7 @@ export interface TLUiToolItem<
19
28
  shortcutsLabel?: TranslationKey
20
29
  icon: IconType | TLUiIconJsx
21
30
  onSelect(source: TLUiEventSource): void
31
+ onDragStart?(source: TLUiEventSource, info: TLPointerEventInfo): void
22
32
  /**
23
33
  * The keyboard shortcut for this tool. This is a string that can be a single key,
24
34
  * or a combination of keys.
@@ -126,21 +136,27 @@ export function ToolsProvider({ overrides, children }: TLUiToolsProviderProps) {
126
136
  onToolSelect(source, this)
127
137
  },
128
138
  },
129
- ...[...GeoShapeGeoStyle.values].map((id) => ({
130
- id,
131
- label: `tool.${id}` as TLUiTranslationKey,
139
+ ...[...GeoShapeGeoStyle.values].map((geo) => ({
140
+ id: geo,
141
+ label: `tool.${geo}` as TLUiTranslationKey,
132
142
  meta: {
133
- geo: id,
143
+ geo,
134
144
  },
135
- kbd: id === 'rectangle' ? 'r' : id === 'ellipse' ? 'o' : undefined,
136
- icon: ('geo-' + id) as TLUiIconType,
145
+ kbd: geo === 'rectangle' ? 'r' : geo === 'ellipse' ? 'o' : undefined,
146
+ icon: ('geo-' + geo) as TLUiIconType,
137
147
  onSelect(source: TLUiEventSource) {
138
148
  editor.run(() => {
139
- editor.setStyleForNextShapes(GeoShapeGeoStyle, id)
149
+ editor.setStyleForNextShapes(GeoShapeGeoStyle, geo)
140
150
  editor.setCurrentTool('geo')
141
- onToolSelect(source, this, `geo-${id}`)
151
+ onToolSelect(source, this, `geo-${geo}`)
142
152
  })
143
153
  },
154
+ onDragStart(source: TLUiEventSource, info: TLPointerEventInfo) {
155
+ onDragFromToolbarToCreateShape(editor, info, {
156
+ createShape: (id) => editor.createShape({ id, type: 'geo', props: { geo } }),
157
+ })
158
+ trackEvent('drag-tool', { source, id: 'geo' })
159
+ },
144
160
  })),
145
161
  {
146
162
  id: 'arrow',
@@ -151,6 +167,17 @@ export function ToolsProvider({ overrides, children }: TLUiToolsProviderProps) {
151
167
  editor.setCurrentTool('arrow')
152
168
  onToolSelect(source, this)
153
169
  },
170
+ onDragStart(source: TLUiEventSource, info: TLPointerEventInfo) {
171
+ onDragFromToolbarToCreateShape(editor, info, {
172
+ createShape: (id) =>
173
+ editor.createShape({
174
+ id,
175
+ type: 'arrow',
176
+ props: { start: { x: 0, y: 0 }, end: { x: 200, y: 0 } },
177
+ }),
178
+ })
179
+ trackEvent('drag-tool', { source, id: 'arrow' })
180
+ },
154
181
  },
155
182
  {
156
183
  id: 'line',
@@ -171,6 +198,12 @@ export function ToolsProvider({ overrides, children }: TLUiToolsProviderProps) {
171
198
  editor.setCurrentTool('frame')
172
199
  onToolSelect(source, this)
173
200
  },
201
+ onDragStart(source, info) {
202
+ onDragFromToolbarToCreateShape(editor, info, {
203
+ createShape: (id) => editor.createShape({ id, type: 'frame' }),
204
+ })
205
+ trackEvent('drag-tool', { source, id: 'frame' })
206
+ },
174
207
  },
175
208
  {
176
209
  id: 'text',
@@ -181,6 +214,17 @@ export function ToolsProvider({ overrides, children }: TLUiToolsProviderProps) {
181
214
  editor.setCurrentTool('text')
182
215
  onToolSelect(source, this)
183
216
  },
217
+ onDragStart(source, info) {
218
+ onDragFromToolbarToCreateShape(editor, info, {
219
+ createShape: (id) =>
220
+ editor.createShape({ id, type: 'text', props: { richText: toRichText('Text') } }),
221
+ onDragEnd: (id) => {
222
+ editor.emit('select-all-text', { shapeId: id })
223
+ editor.setEditingShape(id)
224
+ },
225
+ })
226
+ trackEvent('drag-tool', { source, id: 'text' })
227
+ },
184
228
  },
185
229
  {
186
230
  id: 'asset',
@@ -201,6 +245,16 @@ export function ToolsProvider({ overrides, children }: TLUiToolsProviderProps) {
201
245
  editor.setCurrentTool('note')
202
246
  onToolSelect(source, this)
203
247
  },
248
+ onDragStart(source, info) {
249
+ onDragFromToolbarToCreateShape(editor, info, {
250
+ createShape: (id) => editor.createShape({ id, type: 'note' }),
251
+ onDragEnd: (id) => {
252
+ editor.emit('select-all-text', { shapeId: id })
253
+ editor.setEditingShape(id)
254
+ },
255
+ })
256
+ trackEvent('drag-tool', { source, id: 'note' })
257
+ },
204
258
  },
205
259
  {
206
260
  id: 'laser',
@@ -244,7 +298,7 @@ export function ToolsProvider({ overrides, children }: TLUiToolsProviderProps) {
244
298
  }
245
299
 
246
300
  return tools
247
- }, [overrides, editor, helpers, onToolSelect])
301
+ }, [overrides, editor, helpers, onToolSelect, trackEvent])
248
302
 
249
303
  return <ToolsContext.Provider value={tools}>{children}</ToolsContext.Provider>
250
304
  }
@@ -259,3 +313,57 @@ export function useTools() {
259
313
 
260
314
  return ctx
261
315
  }
316
+
317
+ /**
318
+ * Options for {@link onDragFromToolbarToCreateShape}.
319
+ * @public
320
+ */
321
+ export interface OnDragFromToolbarToCreateShapesOpts {
322
+ /**
323
+ * Create the shape being dragged. You don't need to worry about positioning it, as it'll be
324
+ * immediately updated with the correct position.
325
+ */
326
+ createShape(id: TLShapeId): void
327
+ /**
328
+ * Called once the drag interaction has finished.
329
+ */
330
+ onDragEnd?(id: TLShapeId): void
331
+ }
332
+
333
+ /**
334
+ * A helper method to use in {@link TLUiToolItem#onDragStart} to create a shape by dragging it from
335
+ * the toolbar.
336
+ * @public
337
+ */
338
+ export function onDragFromToolbarToCreateShape(
339
+ editor: Editor,
340
+ info: TLPointerEventInfo,
341
+ opts: OnDragFromToolbarToCreateShapesOpts
342
+ ) {
343
+ const { x, y } = editor.inputs.currentPagePoint
344
+
345
+ const stoppingPoint = editor.markHistoryStoppingPoint('drag shape tool')
346
+ editor.setCurrentTool('select.translating')
347
+
348
+ const id = createShapeId()
349
+ opts.createShape(id)
350
+ const shape = assertExists(editor.getShape(id), 'Shape not found')
351
+
352
+ const { w, h } = editor.getShapePageBounds(id)!
353
+ editor.updateShape({ id, type: shape.type, x: x - w / 2, y: y - h / 2 })
354
+ editor.select(id)
355
+
356
+ editor.setCurrentTool('select.translating', {
357
+ ...info,
358
+ target: 'shape',
359
+ shape: editor.getShape(id),
360
+ isCreating: true,
361
+ creatingMarkId: stoppingPoint,
362
+ onCreate() {
363
+ editor.setCurrentTool('select.idle')
364
+ editor.select(id)
365
+ opts.onDragEnd?.(id)
366
+ },
367
+ })
368
+ editor.getCurrentTool().setCurrentToolIdMask(shape.type)
369
+ }
@@ -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 = '3.16.0-canary.ed8bd30c0f28'
4
+ export const version = '3.16.0-canary.f60032f16651'
5
5
  export const publishDates = {
6
6
  major: '2024-09-13T14:36:29.063Z',
7
- minor: '2025-07-30T15:24:08.744Z',
8
- patch: '2025-07-30T15:24:08.744Z',
7
+ minor: '2025-08-06T09:18:23.766Z',
8
+ patch: '2025-08-06T09:18:23.766Z',
9
9
  }
@@ -288,11 +288,13 @@ describe('When shapes are overlapping', () => {
288
288
  editor.pointerDown(0, 50) // over nothing
289
289
  editor.pointerMove(125, 50) // over box1 only
290
290
  expect(bindings().end).toMatchObject({ toId: ids.box1 })
291
- editor.pointerMove(175, 50) // box2 is higher but box1 is filled?
291
+ editor.pointerMove(175, 50) // box2 is higher but box1 is filled, but we're on the edge ofd box 2
292
+ expect(bindings().end).toMatchObject({ toId: ids.box2 })
293
+ editor.pointerMove(175, 70) // box2 is higher but box1 is filled, and we're inside of box2
292
294
  expect(bindings().end).toMatchObject({ toId: ids.box1 })
293
- editor.pointerMove(225, 50) // box3 is higher
295
+ editor.pointerMove(225, 70) // box3 is higher
294
296
  expect(bindings().end).toMatchObject({ toId: ids.box3 })
295
- editor.pointerMove(275, 50) // box4 is higher but box 3 is filled
297
+ editor.pointerMove(275, 70) // box4 is higher but box 3 is filled
296
298
  expect(bindings().end).toMatchObject({ toId: ids.box3 })
297
299
  })
298
300
 
@@ -304,14 +306,18 @@ describe('When shapes are overlapping', () => {
304
306
  ])
305
307
  editor.setCurrentTool('arrow')
306
308
  editor.pointerDown(0, 50)
307
- editor.pointerMove(175, 50) // box1 is smaller even though it's behind box2
309
+ editor.pointerMove(175, 50) // box1 is smaller even though it's behind box2, but we're on the edge of box 2
310
+ expect(bindings().end).toMatchObject({ toId: ids.box2 })
311
+ editor.pointerMove(175, 70) // box1 is smaller even though it's behind box2
308
312
  expect(bindings().end).toMatchObject({ toId: ids.box1 })
309
- editor.pointerMove(150, 90) // box3 is smaller and at the front
313
+ editor.pointerMove(150, 90) // box3 is smaller and at the front but we're on the edge of box 2
314
+ expect(bindings().end).toMatchObject({ toId: ids.box2 })
315
+ editor.pointerMove(160, 90) // box3 is smaller and at the front and we're in box1 and box 3 and box 2
310
316
  expect(bindings().end).toMatchObject({ toId: ids.box3 })
311
317
  editor.sendToBack([ids.box3])
312
318
  editor.pointerMove(149, 90) // box3 is smaller, even when at the back
313
319
  expect(bindings().end).toMatchObject({ toId: ids.box3 })
314
- editor.pointerMove(175, 50)
320
+ editor.pointerMove(175, 60) // inside of box1 and box 2, but box 1 is smaller
315
321
  expect(bindings().end).toMatchObject({ toId: ids.box1 })
316
322
  })
317
323
  })