Skip to content

Commit a02e41a

Browse files
max-drakecursoragentmimecuvalo
authored
fix(editor): respect canReceiveNewChildrenOfType when dragging shapes (tldraw#8777)
In order to make container acceptance rules apply consistently during pointer drag (not only on paste or create), this PR filters drag targets and drag callback arguments using `canReceiveNewChildrenOfType` when shape utils override it, and fixes the geometric reparent helper to consider child types when picking candidate parents. ### Change type - [x] `bugfix` ### Test plan 1. Override `canReceiveNewChildrenOfType` on a frame-like or container shape to reject a type (e.g. `geo`). 2. Drag a shape of that type over the container; it should not reparent until release, and should stay on the page when dropped. 3. Drag an accepted type; behavior should match prior frame drag-in behavior. 4. Run `yarn test run src/test/frames.test.ts` in `packages/tldraw`. - [x] Unit tests - [ ] End to end tests ### Release notes - Fix drag-and-drop so shapes that override `canReceiveNewChildrenOfType` reject disallowed child types during drag, not only on paste or create. ### Code changes | Area | Description | Additions | Deletions | | --- | --- | ---: | ---: | | `packages/editor` | `getDraggingOverShape` skips targets that reject all dragged types | 9 | 0 | | `packages/editor` | `getDroppedShapesToNewParents` parent prefilter by child types | 11 | 5 | | `packages/tldraw` | `DragAndDropManager` filters shapes passed to drag callbacks | 62 | 23 | | `packages/tldraw` | Frame regression test for rejecting util | 34 | 0 | | **Total** | | **116** | **28** | Made with [Cursor](https://cursor.com) ### API changes - adds `canRemoveChildrenOfType` for frame-like shapes --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Mime Čuvalo <mimecuvalo@gmail.com>
1 parent febe9b9 commit a02e41a

10 files changed

Lines changed: 583 additions & 67 deletions

File tree

apps/docs/content/releases/next.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,7 @@ class AudioAssetUtil extends AssetUtil<TLAudioAsset> {
298298

299299
Custom shapes can now opt into frame-like behavior: clipping children, acting as a parent on paste and drag-in, blocking erasure from inside, and supporting full-brush selection. Previously, frame behavior was hardcoded to the built-in `frame` type; the editor and tools now route frame checks through `editor.getShapeUtil(shape).isFrameLike(shape)`.
300300

301-
The easiest way to build one is to extend the new `BaseFrameLikeShapeUtil` abstract class, which provides sensible defaults for `isFrameLike`, `providesBackgroundForChildren`, `canReceiveNewChildrenOfType`, `getClipPath`, `onDragShapesIn`, and `onDragShapesOut`:
301+
The easiest way to build one is to extend the new `BaseFrameLikeShapeUtil` abstract class, which provides sensible defaults for `isFrameLike`, `providesBackgroundForChildren`, `canReceiveNewChildrenOfType`, `canRemoveChildrenOfType`, `getClipPath`, `onDragShapesIn`, and `onDragShapesOut`:
302302

303303
```tsx
304304
import { BaseFrameLikeShapeUtil, SVGContainer } from '@tldraw/editor'

apps/docs/content/sdk-features/drag-and-drop.mdx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ Only shapes that implement drag callbacks are considered as drop targets. If you
169169

170170
## Controlling which shapes can be dropped
171171

172-
Use the [ShapeUtil#canReceiveNewChildrenOfType](?) method to control which shape types your container accepts:
172+
Use the [ShapeUtil#canReceiveNewChildrenOfType](?) method to control which shape types your container accepts. The editor uses it to decide whether `onDragShapesIn` and `onDropShapesOver` fire for a dragged shape:
173173

174174
```typescript
175175
override canReceiveNewChildrenOfType(shape: MyContainerShape, type: TLShape['type']) {
@@ -178,7 +178,20 @@ override canReceiveNewChildrenOfType(shape: MyContainerShape, type: TLShape['typ
178178
}
179179
```
180180

181-
You must check this yourself in your drag callbacks. The editor does not call it automatically.
181+
The default is `false`, so any shape that should accept dropped children must override this method. `onDragShapesOver` is not gated by this method, which lets you provide visual feedback even when the target won't accept the drop.
182+
183+
## Controlling which shapes can be dragged out
184+
185+
Use the [ShapeUtil#canRemoveChildrenOfType](?) method to control which child shape types can be dragged out of your container. The editor uses it to decide whether `onDragShapesOut` fires for a child shape, and to decide whether the editor should automatically reparent a child that has moved outside its parent's geometry:
186+
187+
```typescript
188+
override canRemoveChildrenOfType(shape: MyContainerShape, type: TLShape['type']) {
189+
// Pin certain children in place; allow others to be removed
190+
return type !== 'pinned-item'
191+
}
192+
```
193+
194+
The default is `true`, so children can be dragged out of any container unless this method is overridden.
182195

183196
## Example: slot container
184197

apps/examples/src/examples/shapes/tools/drag-and-drop/DragAndDropExample.tsx

Lines changed: 57 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import {
2+
BaseFrameLikeShapeUtil,
23
Circle2d,
34
Geometry2d,
5+
Group2d,
46
HTMLContainer,
57
Rectangle2d,
68
ShapeUtil,
7-
TLDragShapesOutInfo,
9+
TLBaseBoxShape,
810
TLShape,
911
Tldraw,
12+
Vec,
1013
} from 'tldraw'
1114
import 'tldraw/tldraw.css'
1215

@@ -16,13 +19,15 @@ const MY_COUNTER_SHAPE_TYPE = 'my-counter-shape'
1619
// [1]
1720
declare module 'tldraw' {
1821
export interface TLGlobalShapePropsMap {
19-
[MY_GRID_SHAPE_TYPE]: Record<string, never>
22+
[MY_GRID_SHAPE_TYPE]: { w: number; h: number }
2023
[MY_COUNTER_SHAPE_TYPE]: Record<string, never>
2124
}
2225
}
2326

2427
// [2]
25-
type MyGridShape = TLShape<typeof MY_GRID_SHAPE_TYPE>
28+
type MyGridShape = TLBaseBoxShape & {
29+
type: typeof MY_GRID_SHAPE_TYPE
30+
}
2631
type MyCounterShape = TLShape<typeof MY_COUNTER_SHAPE_TYPE>
2732

2833
// [3]
@@ -65,59 +70,66 @@ class MyCounterShapeUtil extends ShapeUtil<MyCounterShape> {
6570
}
6671

6772
// [4]
68-
class MyGridShapeUtil extends ShapeUtil<MyGridShape> {
73+
class MyGridShapeUtil extends BaseFrameLikeShapeUtil<MyGridShape> {
6974
static override type = MY_GRID_SHAPE_TYPE
7075

7176
getDefaultProps(): MyGridShape['props'] {
72-
return {}
77+
return {
78+
w: SLOT_SIZE * 5,
79+
h: SLOT_SIZE * 2,
80+
}
7381
}
7482

75-
getGeometry(): Geometry2d {
76-
return new Rectangle2d({
77-
width: SLOT_SIZE * 5,
78-
height: SLOT_SIZE * 2,
79-
isFilled: true,
83+
override getGeometry(shape: MyGridShape): Geometry2d {
84+
return new Group2d({
85+
children: [
86+
new Rectangle2d({
87+
width: shape.props.w,
88+
height: shape.props.h,
89+
isFilled: true,
90+
}),
91+
],
8092
})
8193
}
8294

83-
override canResize(shape: MyGridShape) {
95+
override canResize(_shape: MyGridShape) {
8496
return false
8597
}
86-
override hideResizeHandles(shape: MyGridShape) {
98+
99+
override hideResizeHandles(_shape: MyGridShape) {
87100
return true
88101
}
89102

90103
// [5]
91-
override onDragShapesIn(shape: MyGridShape, draggingShapes: TLShape[]): void {
92-
const { editor } = this
93-
const reparentingShapes = draggingShapes.filter(
94-
(s) => s.parentId !== shape.id && s.type === 'my-counter-shape'
95-
)
96-
if (reparentingShapes.length === 0) return
97-
editor.reparentShapes(reparentingShapes, shape.id)
104+
override canReceiveNewChildrenOfType(_shape: MyGridShape, type: TLShape['type']) {
105+
return type === MY_COUNTER_SHAPE_TYPE
98106
}
99107

100108
// [6]
101-
override onDragShapesOut(
102-
shape: MyGridShape,
103-
draggingShapes: TLShape[],
104-
info: TLDragShapesOutInfo
105-
): void {
106-
const { editor } = this
107-
const reparentingShapes = draggingShapes.filter((s) => s.parentId !== shape.id)
108-
if (!info.nextDraggingOverShapeId) {
109-
editor.reparentShapes(reparentingShapes, editor.getCurrentPageId())
110-
}
109+
override canRemoveChildrenOfType(_shape: MyGridShape, type: TLShape['type']) {
110+
return type !== MY_COUNTER_SHAPE_TYPE
111111
}
112112

113-
component() {
113+
// [7]
114+
override getClipPath(_shape: MyGridShape): Vec[] {
115+
return [
116+
new Vec(0, 0),
117+
new Vec(SLOT_SIZE * 5, 0),
118+
new Vec(SLOT_SIZE * 5, SLOT_SIZE * 2),
119+
new Vec(0, SLOT_SIZE * 2),
120+
]
121+
}
122+
123+
component(shape: MyGridShape) {
114124
return (
115125
<HTMLContainer
116126
style={{
117127
backgroundColor: '#efefef',
118128
borderRight: '1px solid #ccc',
119129
borderBottom: '1px solid #ccc',
120130
backgroundSize: `${SLOT_SIZE}px ${SLOT_SIZE}px`,
131+
width: shape.props.w,
132+
height: shape.props.h,
121133
backgroundImage: `
122134
linear-gradient(to right, #ccc 1px, transparent 1px),
123135
linear-gradient(to bottom, #ccc 1px, transparent 1px)
@@ -127,9 +139,9 @@ class MyGridShapeUtil extends ShapeUtil<MyGridShape> {
127139
)
128140
}
129141

130-
getIndicatorPath() {
142+
override getIndicatorPath(shape: MyGridShape) {
131143
const path = new Path2D()
132-
path.rect(0, 0, SLOT_SIZE * 5, SLOT_SIZE * 2)
144+
path.rect(0, 0, shape.props.w, shape.props.h)
133145
return path
134146
}
135147
}
@@ -170,10 +182,19 @@ Create a ShapeUtil for the grid shape. This creates a rectangular grid that can
170182
Rectangle2d geometry and render it with CSS grid lines using background gradients.
171183
172184
[5]
173-
Override onDragShapesIn to handle when shapes are dragged into the grid. We filter for counter shapes that
174-
aren't already children of this grid, then reparent them to become children. This makes them move with the grid.
185+
Override canReceiveNewChildrenOfType to gate which shape types can be dragged into the grid. The editor only
186+
fires onDragShapesIn for shapes that pass this check. The default is false, so any container that wants to
187+
receive dragged shapes must override this. Here we accept counter shapes only.
175188
176189
[6]
177-
Override onDragShapesOut to handle when shapes are dragged out of the grid. If they're not being dragged to
178-
another shape, we reparent them back to the page level, making them independent again.
190+
Override canRemoveChildrenOfType to gate which child shape types are allowed to be dragged out. The editor
191+
only fires onDragShapesOut for shapes that pass this check, and it also won't auto-reparent ("kick out") a
192+
child of a blocked type when it's moved outside the parent's geometry. The default is true. Here, we don't
193+
allow any child counter shapes to be dragged out.
194+
195+
[7]
196+
Override getClipPath so children are visually clipped to the grid's bounds while they're parented to it.
197+
This is independent of the drag-and-drop callbacks above; it just makes it visually obvious that a counter
198+
"belongs" to the grid while it's a child. Try dragging a counter outside the grid — because counters are
199+
pinned, the counter stays a child of the grid and the clip path keeps it inside.
179200
*/

packages/editor/api-report.api.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,8 @@ export abstract class BaseFrameLikeShapeUtil<Shape extends TLBaseBoxShape> exten
196196
// (undocumented)
197197
canReceiveNewChildrenOfType(shape: Shape, _type: TLShape['type']): boolean;
198198
// (undocumented)
199+
canRemoveChildrenOfType(shape: Shape, _type: TLShape['type']): boolean;
200+
// (undocumented)
199201
getClipPath(shape: Shape): undefined | Vec[];
200202
// (undocumented)
201203
isFrameLike(_shape: Shape): boolean;
@@ -2996,6 +2998,7 @@ export abstract class ShapeUtil<Shape extends TLShape = TLShape> {
29962998
canEditInReadonly(shape: Shape): boolean;
29972999
canEditWhileLocked(shape: Shape): boolean;
29983000
canReceiveNewChildrenOfType(shape: Shape, type: TLShape['type']): boolean;
3001+
canRemoveChildrenOfType(shape: Shape, type: TLShape['type']): boolean;
29993002
canResize(shape: Shape): boolean;
30003003
canResizeChildren(shape: Shape): boolean;
30013004
canScroll(shape: Shape): boolean;

packages/editor/src/lib/editor/shapes/BaseFrameLikeShapeUtil.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { TLDragShapesInInfo, TLDragShapesOutInfo } from './ShapeUtil'
1515
* - `isFrameLike()` returns `true`
1616
* - `providesBackgroundForChildren()` returns `true`
1717
* - `canReceiveNewChildrenOfType()` returns `true` unless the container is locked
18+
* - `canRemoveChildrenOfType()` returns `true` unless the container is locked
1819
* - `getClipPath()` returns the shape geometry's vertices
1920
* - `onDragShapesIn()` reparents shapes into the frame (with index restoration)
2021
* - `onDragShapesOut()` reparents shapes back to the page
@@ -60,6 +61,10 @@ export abstract class BaseFrameLikeShapeUtil<
6061
return !shape.isLocked
6162
}
6263

64+
override canRemoveChildrenOfType(shape: Shape, _type: TLShape['type']): boolean {
65+
return !shape.isLocked
66+
}
67+
6368
override getClipPath(shape: Shape): Vec[] | undefined {
6469
return this.editor.getShapeGeometry(shape.id).vertices
6570
}

packages/editor/src/lib/editor/shapes/ShapeUtil.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -546,7 +546,9 @@ export abstract class ShapeUtil<Shape extends TLShape = TLShape> {
546546
getHandles?(shape: Shape): TLHandle[]
547547

548548
/**
549-
* Get whether the shape can receive children of a given type.
549+
* Get whether the shape can receive children of a given type. Used by the drag and drop system
550+
* to decide whether {@link ShapeUtil.onDragShapesIn} should fire when a shape of the given type
551+
* is dragged over this one.
550552
*
551553
* @param shape - The shape.
552554
* @param type - The shape type.
@@ -556,6 +558,22 @@ export abstract class ShapeUtil<Shape extends TLShape = TLShape> {
556558
return false
557559
}
558560

561+
/**
562+
* Get whether children of a given type can be removed from this shape. Used by the drag and
563+
* drop system to decide whether {@link ShapeUtil.onDragShapesOut} should fire when a child of
564+
* the given type is dragged out of this shape, and by `kickoutOccludedShapes` to decide
565+
* whether to auto-reparent a child of the given type when it has moved outside this shape's
566+
* geometry. Returning `false` therefore "pins" matching children — they stay parented to this
567+
* shape even when dragged or moved outside it. Defaults to `true`.
568+
*
569+
* @param shape - The shape.
570+
* @param type - The shape type.
571+
* @public
572+
*/
573+
canRemoveChildrenOfType(shape: Shape, type: TLShape['type']) {
574+
return true
575+
}
576+
559577
/**
560578
* Get the shape as an SVG object.
561579
*

packages/editor/src/lib/utils/reparenting.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,17 @@ export function kickoutOccludedShapes(
4242
} else {
4343
const overlappingChildren = getOverlappingShapes(editor, parent.id, childIds)
4444
if (overlappingChildren.length < childIds.length) {
45-
parentsToLostChildren.set(
46-
parent,
47-
childIds.filter((id) => !overlappingChildren.includes(id))
48-
)
45+
const parentUtil = editor.getShapeUtil(parent)
46+
const lostChildIds = childIds.filter((id) => {
47+
if (overlappingChildren.includes(id)) return false
48+
const child = editor.getShape(id)
49+
if (!child) return false
50+
// Respect the parent's removal gate: if it pins this child type, don't kick it out.
51+
return parentUtil.canRemoveChildrenOfType(parent, child.type)
52+
})
53+
if (lostChildIds.length > 0) {
54+
parentsToLostChildren.set(parent, lostChildIds)
55+
}
4956
}
5057
}
5158
}
@@ -238,11 +245,17 @@ export function getDroppedShapesToNewParents(
238245
const potentialParentShapes = editor
239246
.getCurrentPageShapesSorted()
240247
// filter out any shapes that aren't frames or that are included among the provided shapes
241-
.filter(
242-
(s) =>
243-
editor.getShapeUtil(s).canReceiveNewChildrenOfType?.(s, s.type) &&
244-
!remainingShapesToReparent.has(s)
245-
)
248+
.filter((parentShape) => {
249+
if (remainingShapesToReparent.has(parentShape)) return false
250+
251+
const parentUtil = editor.getShapeUtil(parentShape)
252+
for (const childShape of remainingShapesToReparent) {
253+
if (parentUtil.canReceiveNewChildrenOfType(parentShape, childShape.type)) {
254+
return true
255+
}
256+
}
257+
return false
258+
})
246259

247260
parentCheck: for (let i = potentialParentShapes.length - 1; i >= 0; i--) {
248261
const parentShape = potentialParentShapes[i]

0 commit comments

Comments
 (0)