Skip to content

Commit 0e0fb8b

Browse files
steveruizokMitjaBezensekSomeHatsmimecuvalo
authored
Permit drag out of toolbar (tldraw#4793)
This PR adds the ability to drag tools out of the toolbar and onto the canvas. ![Kapture 2024-10-26 at 11 11 44](https://github.com/user-attachments/assets/fc1bd904-4174-4a65-87d4-43eddf510ed4) ## Interaction notes A user should be able to interact with the toolbar items in a few ways: 1. Click on the item to select the tool / activate its `onSelect` (on touch start or pointer up). 2. Click and drag to create the shape, begin translating, and display the tool as active; then drop to create the shape and in the case of text and notes begin editing it 3. Click and drag and press Escape to cancel ## Implementation This is very rough and involves a lot of duplication. It's not entirely clear to me what the right API here is, so **I'd love to get some input** on what we could do to make things easier. Some open questions: 1. Should we abstract the "create on drag start" behavior, or provide better information (ie the current page position) as the parameters to that callback? What about behaviors like "select and start editing after drag end"? 2. Is it the right idea to move the pointer move event to the container, so that it occurs in front of the UI? ## Testing Since this technically occurs at the UI layer, we'd have to write e2e tests to verify that things are working the way we expect them to. ### Change type - [x] `improvement` ### Test plan 1. Drag tools from the toolbar onto the canvas. 1. Drag tools from the toolbar overflow menu onto the canvas. ### 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 --------- Co-authored-by: Mitja Bezenšek <mitja.bezensek@gmail.com> Co-authored-by: alex <alex@dytry.ch> Co-authored-by: Mime Čuvalo <mimecuvalo@gmail.com>
1 parent 0a84def commit 0e0fb8b

11 files changed

Lines changed: 501 additions & 61 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/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
@@ -2147,6 +2147,15 @@ export function NoteToolbarItem(): JSX_2.Element;
21472147
// @public (undocumented)
21482148
export function OfflineIndicator(): JSX_2.Element;
21492149

2150+
// @public
2151+
export function onDragFromToolbarToCreateShape(editor: Editor, info: TLPointerEventInfo, opts: OnDragFromToolbarToCreateShapesOpts): void;
2152+
2153+
// @public
2154+
export interface OnDragFromToolbarToCreateShapesOpts {
2155+
createShape(id: TLShapeId): void;
2156+
onDragEnd?(id: TLShapeId): void;
2157+
}
2158+
21502159
// @public (undocumented)
21512160
export function OpacitySlider(): JSX_2.Element | null;
21522161

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

30513060
// @public (undocumented)
3052-
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;
3061+
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;
30533062

30543063
// @public (undocumented)
30553064
export function TldrawUiMenuSubmenu<Translation extends string = string>({ id, disabled, label, size, children, }: TLUiMenuSubmenuProps<Translation>): boolean | JSX_2.Element | Iterable<ReactNode> | null | number | string | undefined;
@@ -3646,6 +3655,10 @@ export interface TLUiEventMap {
36463655
// (undocumented)
36473656
'download-original': null;
36483657
// (undocumented)
3658+
'drag-tool': {
3659+
id: string;
3660+
};
3661+
// (undocumented)
36493662
'duplicate-page': null;
36503663
// (undocumented)
36513664
'duplicate-shapes': null;
@@ -4020,6 +4033,7 @@ export interface TLUiMenuItemProps<TranslationKey extends string = string, IconT
40204033
[key: string]: TranslationKey;
40214034
} | TranslationKey;
40224035
noClose?: boolean;
4036+
onDragStart?(source: TLUiEventSource, info: TLPointerEventInfo): void;
40234037
onSelect(source: TLUiEventSource): Promise<void> | void;
40244038
readonlyOk?: boolean;
40254039
spinner?: boolean;
@@ -4267,6 +4281,8 @@ export interface TLUiToolItem<TranslationKey extends string = string, IconType e
42674281
[key: string]: any;
42684282
};
42694283
// (undocumented)
4284+
onDragStart?(source: TLUiEventSource, info: TLPointerEventInfo): void;
4285+
// (undocumented)
42704286
onSelect(source: TLUiEventSource): void;
42714287
// (undocumented)
42724288
readonlyOk?: boolean;

packages/tldraw/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,7 +590,9 @@ export { useMenuIsOpen } from './lib/ui/hooks/useMenuIsOpen'
590590
export { useReadonly } from './lib/ui/hooks/useReadonly'
591591
export { useRelevantStyles } from './lib/ui/hooks/useRelevantStyles'
592592
export {
593+
onDragFromToolbarToCreateShape,
593594
useTools,
595+
type OnDragFromToolbarToCreateShapesOpts,
594596
type TLUiToolItem,
595597
type TLUiToolsContextType,
596598
type TLUiToolsProviderProps,

packages/tldraw/src/lib/tools/SelectTool/childStates/Translating.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ export type TranslatingInfo = TLPointerEventInfo & {
2828
isCreating?: boolean
2929
creatingMarkId?: string
3030
onCreate?(): void
31-
didStartInPit?: boolean
3231
onInteractionEnd?: string
3332
}
3433

0 commit comments

Comments
 (0)