Skip to content

Commit efdec30

Browse files
authored
Drag out of toolbar (again) (tldraw#6563)
Restores tldraw#4793 after it was reverted in tldraw#6551, plus some bugfixes. ### Change type - [x] `other` ### Release notes - Adds the ability to drag shapes from the toolbar onto the canvas. ### API changes - Adds `onDragFromToolbarToCreateShape`, `OnDragFromToolbarToCreateShapesOpts`, and `onDragStart` on `TldrawUiMenuItem` to facilitate dragging shapes out of a toolbar
1 parent a962044 commit efdec30

13 files changed

Lines changed: 567 additions & 96 deletions

File tree

apps/examples/e2e/tests/fixtures/menus/Toolbar.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,12 @@ export class Toolbar {
1717
this.tools = {
1818
select: this.page.getByTestId('tools.select'),
1919
draw: this.page.getByTestId('tools.draw'),
20+
text: this.page.getByTestId('tools.text'),
2021
arrow: this.page.getByTestId('tools.arrow'),
2122
cloud: this.page.getByTestId('tools.cloud'),
2223
eraser: this.page.getByTestId('tools.eraser'),
2324
rectangle: this.page.getByTestId('tools.rectangle'),
2425
hand: this.page.getByTestId('tools.hand'),
25-
text: this.page.getByTestId('tools.text'),
2626
}
2727
this.popOverTools = {
2828
popoverCloud: this.page.getByTestId('tools.more.cloud'),

apps/examples/e2e/tests/test-toolbar.spec.ts

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { expect } from '@playwright/test'
2-
import { setup } from '../shared-e2e'
2+
import { sleep } from 'tldraw'
3+
import { getAllShapeTypes, setup } from '../shared-e2e'
34
import test from './fixtures/fixtures'
45

56
test.describe('when selecting a tool from the toolbar', () => {
@@ -36,3 +37,106 @@ test.describe('when selecting a tool from the toolbar', () => {
3637
})
3738
})
3839
})
40+
41+
test.describe('when dragging a tool from the toolbar', () => {
42+
test.beforeEach(setup)
43+
44+
test('dragging from main toolbar creates and positions shapes', async ({
45+
page,
46+
toolbar,
47+
isMobile,
48+
}) => {
49+
if (isMobile) return
50+
51+
const { rectangle, arrow, text } = toolbar.tools
52+
53+
await test.step('dragging rectangle tool creates a rectangle', async () => {
54+
const startPoint = { x: 100, y: 100 }
55+
const endPoint = { x: 200, y: 200 }
56+
57+
// Start dragging from the rectangle tool
58+
await rectangle.hover()
59+
await page.mouse.down()
60+
await page.mouse.move(startPoint.x, startPoint.y)
61+
await page.mouse.move(endPoint.x, endPoint.y)
62+
await page.mouse.up()
63+
64+
// Verify a rectangle was created
65+
const shapes = await getAllShapeTypes(page)
66+
expect(shapes).toHaveLength(1)
67+
expect(shapes).toContain('geo')
68+
})
69+
70+
await sleep(100)
71+
72+
await test.step('dragging arrow tool creates an arrow', async () => {
73+
const startPoint = { x: 300, y: 100 }
74+
const endPoint = { x: 400, y: 200 }
75+
76+
// Start dragging from the arrow tool
77+
await arrow.hover()
78+
await page.mouse.down()
79+
await page.mouse.move(startPoint.x, startPoint.y)
80+
await page.mouse.move(endPoint.x, endPoint.y)
81+
await page.mouse.up()
82+
83+
// Verify an arrow was created
84+
const shapes = await getAllShapeTypes(page)
85+
expect(shapes).toHaveLength(2)
86+
expect(shapes).toContain('geo')
87+
expect(shapes).toContain('arrow')
88+
})
89+
90+
await sleep(100)
91+
92+
await test.step('dragging text tool creates editable text', async () => {
93+
const startPoint = { x: 500, y: 100 }
94+
const endPoint = { x: 600, y: 200 }
95+
96+
// Start dragging from the text tool
97+
await text.hover()
98+
await page.mouse.down()
99+
await page.mouse.move(startPoint.x, startPoint.y)
100+
await page.mouse.move(endPoint.x, endPoint.y)
101+
await page.mouse.up()
102+
103+
// Verify a text shape was created
104+
const shapes = await getAllShapeTypes(page)
105+
expect(shapes).toHaveLength(3)
106+
expect(shapes).toContain('geo')
107+
expect(shapes).toContain('arrow')
108+
expect(shapes).toContain('text')
109+
110+
// Verify text is in edit mode
111+
const hasTextInputFocused = await page.evaluate(() => {
112+
return document.activeElement?.classList.contains('tiptap')
113+
})
114+
expect(hasTextInputFocused).toBe(true)
115+
})
116+
})
117+
118+
test('dragging from overflow toolbar creates shapes', async ({ page, toolbar }) => {
119+
// Open the overflow menu
120+
await toolbar.moreToolsButton.click()
121+
await expect(toolbar.moreToolsPopover).toBeVisible()
122+
123+
const { popoverCloud } = toolbar.popOverTools
124+
125+
await test.step('dragging cloud tool from overflow creates a cloud', async () => {
126+
const startPoint = { x: 100, y: 100 }
127+
const endPoint = { x: 200, y: 200 }
128+
129+
// Start dragging from the cloud tool in overflow
130+
await popoverCloud.hover()
131+
await page.mouse.down()
132+
await page.mouse.move(startPoint.x, startPoint.y)
133+
await page.mouse.move(endPoint.x, endPoint.y)
134+
await page.mouse.up()
135+
136+
// Verify a cloud shape was created
137+
const shapes = await getAllShapeTypes(page)
138+
expect(shapes).toHaveLength(1)
139+
expect(shapes).toContain('geo')
140+
})
141+
})
142+
})

packages/editor/src/lib/TldrawEditor.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { MigrationSequence, Store } from '@tldraw/store'
22
import { TLShape, TLStore, TLStoreSnapshot } from '@tldraw/tlschema'
3-
import { Required, annotateError } from '@tldraw/utils'
3+
import { annotateError, Required } from '@tldraw/utils'
44
import React, {
5-
ReactNode,
65
memo,
6+
ReactNode,
77
useCallback,
88
useEffect,
99
useLayoutEffect,
@@ -15,13 +15,13 @@ import React, {
1515

1616
import classNames from 'classnames'
1717
import { version } from '../version'
18-
import { OptionalErrorBoundary } from './components/ErrorBoundary'
1918
import { DefaultErrorFallback } from './components/default-components/DefaultErrorFallback'
20-
import { TLEditorSnapshot } from './config/TLEditorSnapshot'
19+
import { OptionalErrorBoundary } from './components/ErrorBoundary'
2120
import { TLStoreBaseOptions } from './config/createTLStore'
22-
import { TLUser, createTLUser } from './config/createTLUser'
21+
import { createTLUser, TLUser } from './config/createTLUser'
2322
import { TLAnyBindingUtilConstructor } from './config/defaultBindings'
2423
import { TLAnyShapeUtilConstructor } from './config/defaultShapes'
24+
import { TLEditorSnapshot } from './config/TLEditorSnapshot'
2525
import { Editor } from './editor/Editor'
2626
import { TLStateNodeConstructor } from './editor/tools/StateNode'
2727
import { TLCameraOptions } from './editor/types/misc-types'

packages/editor/src/lib/components/MenuClickCapture.tsx

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,6 @@ export function MenuClickCapture() {
5050
// Do nothing unless we're pointing
5151
if (!rPointerState.current.isDown) return
5252

53-
// If we're already dragging, pass on the event as it is
54-
if (rPointerState.current.isDragging) {
55-
canvasEvents.onPointerMove?.(e)
56-
return
57-
}
58-
5953
if (
6054
// We're pointing, but are we dragging?
6155
Vec.Dist2(rPointerState.current.start, new Vec(e.clientX, e.clientY)) >
@@ -75,8 +69,6 @@ export function MenuClickCapture() {
7569
clientY: y,
7670
button: 0,
7771
})
78-
// call the pointer move with the current pointer position
79-
canvasEvents.onPointerMove?.(e)
8072
}
8173
},
8274
[canvasEvents, editor]

packages/editor/src/lib/editor/Editor.ts

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7857,25 +7857,32 @@ export class Editor extends EventEmitter<TLEventMap> {
78577857
) {
78587858
let parentId: TLParentId = this.getFocusedGroupId()
78597859

7860-
for (let i = currentPageShapesSorted.length - 1; i >= 0; i--) {
7861-
const parent = currentPageShapesSorted[i]
7862-
const util = this.getShapeUtil(parent)
7863-
if (
7864-
util.canReceiveNewChildrenOfType(parent, partial.type) &&
7865-
!this.isShapeHidden(parent) &&
7866-
this.isPointInShape(
7867-
parent,
7868-
// If no parent is provided, then we can treat the
7869-
// shape's provided x/y as being in the page's space.
7870-
{ x: partial.x ?? 0, y: partial.y ?? 0 },
7871-
{
7872-
margin: 0,
7873-
hitInside: true,
7874-
}
7875-
)
7876-
) {
7877-
parentId = parent.id
7878-
break
7860+
const isPositioned = partial.x !== undefined && partial.y !== undefined
7861+
7862+
// If the shape has been explicitly positioned, we'll try to find a parent at
7863+
// that position. If not, we'll assume the user isn't deliberately placing the
7864+
// shape and the positioning will be handled later by another system.
7865+
if (isPositioned) {
7866+
for (let i = currentPageShapesSorted.length - 1; i >= 0; i--) {
7867+
const parent = currentPageShapesSorted[i]
7868+
const util = this.getShapeUtil(parent)
7869+
if (
7870+
util.canReceiveNewChildrenOfType(parent, partial.type) &&
7871+
!this.isShapeHidden(parent) &&
7872+
this.isPointInShape(
7873+
parent,
7874+
// If no parent is provided, then we can treat the
7875+
// shape's provided x/y as being in the page's space.
7876+
{ x: partial.x ?? 0, y: partial.y ?? 0 },
7877+
{
7878+
margin: 0,
7879+
hitInside: true,
7880+
}
7881+
)
7882+
) {
7883+
parentId = parent.id
7884+
break
7885+
}
78797886
}
78807887
}
78817888

packages/editor/src/lib/hooks/useCanvasEvents.ts

Lines changed: 39 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useValue } from '@tldraw/state-react'
2-
import React, { useMemo } from 'react'
2+
import React, { useEffect, useMemo } from 'react'
33
import { RIGHT_MOUSE_BUTTON } from '../constants'
44
import {
55
preventDefault,
@@ -16,9 +16,6 @@ export function useCanvasEvents() {
1616

1717
const events = useMemo(
1818
function canvasEvents() {
19-
// Track the last screen point
20-
let lastX: number, lastY: number
21-
2219
function onPointerDown(e: React.PointerEvent) {
2320
if ((e as any).isKilled) return
2421

@@ -44,35 +41,9 @@ export function useCanvasEvents() {
4441
})
4542
}
4643

47-
function onPointerMove(e: React.PointerEvent) {
48-
if ((e as any).isKilled) return
49-
50-
if (e.clientX === lastX && e.clientY === lastY) return
51-
lastX = e.clientX
52-
lastY = e.clientY
53-
54-
// For tools that benefit from a higher fidelity of events,
55-
// we dispatch the coalesced events.
56-
// N.B. Sometimes getCoalescedEvents isn't present on iOS, ugh.
57-
const events =
58-
currentTool.useCoalescedEvents && e.nativeEvent.getCoalescedEvents
59-
? e.nativeEvent.getCoalescedEvents()
60-
: [e]
61-
for (const singleEvent of events) {
62-
editor.dispatch({
63-
type: 'pointer',
64-
target: 'canvas',
65-
name: 'pointer_move',
66-
...getPointerInfo(singleEvent),
67-
})
68-
}
69-
}
70-
7144
function onPointerUp(e: React.PointerEvent) {
7245
if ((e as any).isKilled) return
7346
if (e.button !== 0 && e.button !== 1 && e.button !== 2 && e.button !== 5) return
74-
lastX = e.clientX
75-
lastY = e.clientY
7647

7748
releasePointerCapture(e.currentTarget, e)
7849

@@ -158,7 +129,6 @@ export function useCanvasEvents() {
158129

159130
return {
160131
onPointerDown,
161-
onPointerMove,
162132
onPointerUp,
163133
onPointerEnter,
164134
onPointerLeave,
@@ -169,8 +139,45 @@ export function useCanvasEvents() {
169139
onClick,
170140
}
171141
},
172-
[editor, currentTool]
142+
[editor]
173143
)
174144

145+
// onPointerMove is special: where we're only interested in the other events when they're
146+
// happening _on_ the canvas (as opposed to outside of it, or on UI floating over it), we want
147+
// the pointer position to be up to date regardless of whether it's over the tldraw canvas or
148+
// not. So instead of returning a listener to be attached to the canvas, we directly attach a
149+
// listener to the whole document instead.
150+
useEffect(() => {
151+
let lastX: number, lastY: number
152+
153+
function onPointerMove(e: PointerEvent) {
154+
if ((e as any).isKilled) return
155+
;(e as any).isKilled = true
156+
157+
if (e.clientX === lastX && e.clientY === lastY) return
158+
lastX = e.clientX
159+
lastY = e.clientY
160+
161+
// For tools that benefit from a higher fidelity of events,
162+
// we dispatch the coalesced events.
163+
// N.B. Sometimes getCoalescedEvents isn't present on iOS, ugh.
164+
const events =
165+
currentTool.useCoalescedEvents && e.getCoalescedEvents ? e.getCoalescedEvents() : [e]
166+
for (const singleEvent of events) {
167+
editor.dispatch({
168+
type: 'pointer',
169+
target: 'canvas',
170+
name: 'pointer_move',
171+
...getPointerInfo(singleEvent),
172+
})
173+
}
174+
}
175+
176+
document.body.addEventListener('pointermove', onPointerMove)
177+
return () => {
178+
document.body.removeEventListener('pointermove', onPointerMove)
179+
}
180+
}, [editor, currentTool])
181+
175182
return events
176183
}

packages/tldraw/api-report.api.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2153,6 +2153,15 @@ export function NoteToolbarItem(): JSX_2.Element;
21532153
// @public (undocumented)
21542154
export function OfflineIndicator(): JSX_2.Element;
21552155

2156+
// @public
2157+
export function onDragFromToolbarToCreateShape(editor: Editor, info: TLPointerEventInfo, opts: OnDragFromToolbarToCreateShapesOpts): void;
2158+
2159+
// @public
2160+
export interface OnDragFromToolbarToCreateShapesOpts {
2161+
createShape(id: TLShapeId): void;
2162+
onDragEnd?(id: TLShapeId): void;
2163+
}
2164+
21562165
// @public (undocumented)
21572166
export function OpacitySlider(): JSX_2.Element | null;
21582167

@@ -3058,7 +3067,7 @@ export function TldrawUiMenuContextProvider({ type, sourceId, children, }: TLUiM
30583067
export function TldrawUiMenuGroup({ id, label, className, children }: TLUiMenuGroupProps): boolean | JSX_2.Element | Iterable<ReactNode> | null | number | string | undefined;
30593068

30603069
// @public (undocumented)
3061-
export function TldrawUiMenuItem<TranslationKey extends string = string, IconType extends string = string>({ disabled, spinner, readonlyOk, id, kbd, label, icon, iconLeft, onSelect, noClose, isSelected, }: TLUiMenuItemProps<TranslationKey, IconType>): JSX_2.Element | null;
3070+
export function TldrawUiMenuItem<TranslationKey extends string = string, IconType extends string = string>({ disabled, spinner, readonlyOk, id, kbd, label, icon, iconLeft, onSelect, noClose, isSelected, onDragStart, }: TLUiMenuItemProps<TranslationKey, IconType>): JSX_2.Element | null;
30623071

30633072
// @public (undocumented)
30643073
export function TldrawUiMenuSubmenu<Translation extends string = string>({ id, disabled, label, size, children, }: TLUiMenuSubmenuProps<Translation>): boolean | JSX_2.Element | Iterable<ReactNode> | null | number | string | undefined;
@@ -3684,6 +3693,10 @@ export interface TLUiEventMap {
36843693
// (undocumented)
36853694
'download-original': null;
36863695
// (undocumented)
3696+
'drag-tool': {
3697+
id: string;
3698+
};
3699+
// (undocumented)
36873700
'duplicate-page': null;
36883701
// (undocumented)
36893702
'duplicate-shapes': null;
@@ -4068,6 +4081,7 @@ export interface TLUiMenuItemProps<TranslationKey extends string = string, IconT
40684081
[key: string]: TranslationKey;
40694082
} | TranslationKey;
40704083
noClose?: boolean;
4084+
onDragStart?(source: TLUiEventSource, info: TLPointerEventInfo): void;
40714085
onSelect(source: TLUiEventSource): Promise<void> | void;
40724086
readonlyOk?: boolean;
40734087
spinner?: boolean;
@@ -4323,6 +4337,8 @@ export interface TLUiToolItem<TranslationKey extends string = string, IconType e
43234337
[key: string]: any;
43244338
};
43254339
// (undocumented)
4340+
onDragStart?(source: TLUiEventSource, info: TLPointerEventInfo): void;
4341+
// (undocumented)
43264342
onSelect(source: TLUiEventSource): void;
43274343
// (undocumented)
43284344
readonlyOk?: boolean;

0 commit comments

Comments
 (0)