Skip to content

Commit e125b3a

Browse files
authored
fix(tldraw): don't steal selection when pasting mid-interaction (tldraw#8976)
## Overview In order to keep an in-progress interaction intact when content is pasted mid-gesture, this PR stops `defaultHandleExternalTldrawContent` from selecting the pasted shapes while the user is dragging a handle, translating, resizing, or rotating. This is an **alternative** to tldraw#8968 for the same underlying problem (tldraw#8305). Where tldraw#8968 keeps the binding-hint overlay alive by gating the overlay's `isActive`/`getOverlays` on the interaction state, this PR instead avoids the root cause for the paste case: the pasted content no longer steals selection from the shape being manipulated. Opened as a draft so the two approaches can be compared side by side. When the user pastes tldraw content while mid-interaction — for example dragging an arrow's endpoint handle — the default handler used to `select` the newly pasted shapes. That stole selection from the shape under manipulation and interrupted the gesture (the arrow's in-progress binding hint, for instance, disappeared until the drag ended). With this change the selection is left untouched while mid-interaction, so the gesture continues uninterrupted. When not mid-interaction, paste still selects the pasted content as before. Because we no longer reselect mid-interaction, the selection bounds before and after paste are identical, which would make the paste "puff" overlap check always pass. The puff is meant to signal that the newly-selected pasted content landed on the old selection, so it's now skipped while mid-interaction. ## Scope note The known undo-grouping quirk tracked in tldraw#8969 (paste landing in the same undo entry as the drag) is **intentionally left out of scope** here, to keep this change focused and comparable to tldraw#8968. Thanks to @SomeHats for suggesting this approach in tldraw#8968 (comment). ## Change type - [x] `bugfix` ## Test plan 1. Create a shape and an arrow bound to it. 2. Start dragging the arrow's free endpoint handle. 3. While still dragging, paste some tldraw content. 4. The arrow stays selected and its binding hint remains visible; the pasted content is added without stealing selection. 5. Paste while idle — the pasted content is selected as normal. - [x] Unit tests added in `SelectTool.test.ts` covering dragging a handle, translating, resizing, the no-puff case, and the idle (still-selects) case. ## Release notes - Pasting content while dragging, translating, resizing, or rotating no longer steals selection from the shape being manipulated. ## Code changes | Area | Files | +/- | | --- | --- | --- | | Core | `defaultExternalContentHandlers.ts` | +19/-1 | | Tests | `SelectTool.test.ts` | +134/-0 | Relates to tldraw#8305. Alternative to tldraw#8968.
1 parent c49c3e8 commit e125b3a

2 files changed

Lines changed: 155 additions & 1 deletion

File tree

packages/tldraw/src/lib/defaultExternalContentHandlers.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -624,12 +624,31 @@ export async function defaultHandleExternalTldrawContent(
624624
}
625625
}
626626

627+
// While the user is mid-interaction (dragging a handle, translating,
628+
// resizing, or rotating), selecting the pasted content would steal the
629+
// selection from the shape being manipulated and interrupt the
630+
// interaction — e.g. an arrow's in-progress binding hint disappears
631+
// until the drag ends. Leave the selection alone in those cases.
632+
const isMidInteraction = editor.isInAny(
633+
'select.dragging_handle',
634+
'select.translating',
635+
'select.resizing',
636+
'select.rotating'
637+
)
638+
627639
editor.putContentOntoCurrentPage(content, {
628640
point: point,
629-
select: true,
641+
select: !isMidInteraction,
630642
})
631643
const selectedBoundsAfter = editor.getSelectionPageBounds()
632644
if (
645+
// When mid-interaction we don't select the pasted content, so the
646+
// selection is unchanged and the before/after bounds are identical —
647+
// the overlap check would always pass. The selection flash below
648+
// signals that the newly-selected pasted content landed on the old
649+
// selection, which is meaningless when we didn't change the
650+
// selection, so skip it.
651+
!isMidInteraction &&
633652
selectionBoundsBefore &&
634653
selectedBoundsAfter &&
635654
selectionBoundsBefore?.collides(selectedBoundsAfter)

packages/tldraw/src/test/SelectTool.test.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { IndexKey, ShapeUtil, TLFrameShape, createShapeId, toRichText } from '@tldraw/editor'
22
import { vi } from 'vitest'
3+
import { defaultHandleExternalTldrawContent } from '../lib/defaultExternalContentHandlers'
34
import { defaultOverlayUtils } from '../lib/defaultOverlayUtils'
45
import { TestEditor } from './TestEditor'
56

@@ -261,6 +262,140 @@ describe('DraggingHandle', () => {
261262
})
262263
})
263264

265+
describe('Pasting during an interaction', () => {
266+
// Regression for #8305: pasting tldraw content mid-interaction should not
267+
// steal the selection from the shape being manipulated. The paste handler
268+
// leaves the selection alone while dragging/translating/resizing/rotating,
269+
// so the interacted shape stays selected (and an arrow's binding hint stays
270+
// visible) without needing the tool to reselect on completion.
271+
272+
it('does not steal selection while dragging an arrow handle, keeping the binding hint visible', async () => {
273+
const overlayEditor = new TestEditor({ overlayUtils: defaultOverlayUtils })
274+
const arrowId = createShapeId('hintArrow')
275+
const targetId = createShapeId('hintTarget')
276+
const clipboardId = createShapeId('hintClipboard')
277+
overlayEditor.createShapes([
278+
{ id: targetId, type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } },
279+
{ id: clipboardId, type: 'geo', x: 400, y: 400, props: { w: 50, h: 50 } },
280+
{
281+
id: arrowId,
282+
type: 'arrow',
283+
x: 150,
284+
y: 150,
285+
props: { start: { x: 0, y: 0 }, end: { x: 120, y: 0 } },
286+
},
287+
])
288+
// Bind the arrow's start to the target so a binding survives an end-handle drag.
289+
overlayEditor.createBindings([
290+
{
291+
fromId: arrowId,
292+
toId: targetId,
293+
type: 'arrow',
294+
props: {
295+
terminal: 'start',
296+
normalizedAnchor: { x: 0.5, y: 0.5 },
297+
isExact: false,
298+
isPrecise: false,
299+
},
300+
},
301+
])
302+
303+
// Snapshot a shape to paste through the real external-content path.
304+
const content = overlayEditor.getContentFromCurrentPage([clipboardId])!
305+
306+
const hasBindingHint = () =>
307+
overlayEditor.overlays.getCurrentOverlays().some((o) => o.type === 'arrow_binding_hint')
308+
309+
const arrow = overlayEditor.getShape(arrowId)!
310+
const endHandle = overlayEditor.getShapeHandles(arrow)!.find((h) => h.id === 'end')!
311+
312+
overlayEditor.select(arrowId)
313+
overlayEditor.pointerDown(arrow.x + endHandle.x, arrow.y + endHandle.y, {
314+
target: 'handle',
315+
shape: arrow,
316+
handle: endHandle,
317+
})
318+
overlayEditor.pointerMove(arrow.x + endHandle.x + 20, arrow.y + endHandle.y)
319+
overlayEditor.expectToBeIn('select.dragging_handle')
320+
expect(hasBindingHint()).toBe(true)
321+
322+
// Paste a shape mid-drag.
323+
const countBefore = overlayEditor.getCurrentPageShapes().length
324+
await defaultHandleExternalTldrawContent(overlayEditor, { content })
325+
expect(overlayEditor.getCurrentPageShapes().length).toBeGreaterThan(countBefore)
326+
327+
// Selection is left alone, so the arrow stays selected and the hint stays up.
328+
expect(overlayEditor.getOnlySelectedShapeId()).toBe(arrowId)
329+
expect(hasBindingHint()).toBe(true)
330+
331+
overlayEditor.pointerUp()
332+
expect(overlayEditor.getSelectedShapeIds()).toEqual([arrowId])
333+
})
334+
335+
it('does not steal selection while translating a shape', async () => {
336+
const content = editor.getContentFromCurrentPage([ids.box1])!
337+
338+
editor.select(ids.box1)
339+
editor.pointerDown(150, 150, { target: 'shape', shape: editor.getShape(ids.box1)! })
340+
editor.pointerMove(250, 250)
341+
editor.expectToBeIn('select.translating')
342+
343+
const countBefore = editor.getCurrentPageShapes().length
344+
await defaultHandleExternalTldrawContent(editor, { content })
345+
expect(editor.getCurrentPageShapes().length).toBeGreaterThan(countBefore)
346+
expect(editor.getOnlySelectedShapeId()).toBe(ids.box1)
347+
348+
editor.pointerUp()
349+
expect(editor.getSelectedShapeIds()).toEqual([ids.box1])
350+
})
351+
352+
it('does not steal selection while resizing a shape', async () => {
353+
const content = editor.getContentFromCurrentPage([ids.box1])!
354+
355+
editor.select(ids.box1)
356+
editor.pointerDown(200, 200, { target: 'selection', handle: 'bottom_right' })
357+
editor.pointerMove(250, 250)
358+
editor.expectToBeIn('select.resizing')
359+
360+
const countBefore = editor.getCurrentPageShapes().length
361+
await defaultHandleExternalTldrawContent(editor, { content })
362+
expect(editor.getCurrentPageShapes().length).toBeGreaterThan(countBefore)
363+
expect(editor.getOnlySelectedShapeId()).toBe(ids.box1)
364+
365+
editor.pointerUp()
366+
expect(editor.getSelectedShapeIds()).toEqual([ids.box1])
367+
})
368+
369+
it('does not flash the selection (isChangingStyle) while mid-interaction', async () => {
370+
const content = editor.getContentFromCurrentPage([ids.box1])!
371+
372+
editor.select(ids.box1)
373+
editor.pointerDown(150, 150, { target: 'shape', shape: editor.getShape(ids.box1)! })
374+
editor.pointerMove(250, 250)
375+
editor.expectToBeIn('select.translating')
376+
377+
// The paste flash's overlap check compares the selection bounds before
378+
// and after paste. Mid-interaction we don't reselect, so those bounds
379+
// are identical and the check would always pass — isChangingStyle must
380+
// stay false.
381+
await defaultHandleExternalTldrawContent(editor, { content })
382+
expect(editor.getInstanceState().isChangingStyle).toBe(false)
383+
})
384+
385+
it('still selects pasted content when not mid-interaction', async () => {
386+
const content = editor.getContentFromCurrentPage([ids.box1])!
387+
388+
editor.selectNone()
389+
editor.expectToBeIn('select.idle')
390+
391+
await defaultHandleExternalTldrawContent(editor, { content })
392+
393+
const selected = editor.getOnlySelectedShapeId()
394+
expect(selected).not.toBeNull()
395+
expect(selected).not.toBe(ids.box1)
396+
})
397+
})
398+
264399
describe('PointingLabel', () => {
265400
it('Enters from pointing_arrow_label and exits to idle', () => {
266401
editor.createShapes([

0 commit comments

Comments
 (0)