Skip to content

Commit 3810d2a

Browse files
authored
Add frame-like capability to shape utils (tldraw#8331)
## Summary - adds a `ShapeUtil.isFrameLike()` capability so custom shapes can opt into frame-like behavior (paste parenting, snapping, selection, erasing, export) - adds a `BaseFrameLikeShapeUtil` abstract class — the easiest way to create a custom frame-like shape, with sensible defaults for `isFrameLike`, `providesBackgroundForChildren`, `canReceiveNewChildrenOfType`, `getClipPath`, `onDragShapesIn`, and `onDragShapesOut` - routes frame-specific behaviors in the editor and tools through `editor.getShapeUtil(shape).isFrameLike(shape)` instead of hardcoding the `frame` shape type - refactors `FrameShapeUtil` to extend `BaseFrameLikeShapeUtil`, removing ~80 lines of boilerplate while preserving current frame behavior - adds a portal shapes example demonstrating custom frame-like shapes that teleport children between each other split off from tldraw#8030 Closes tldraw#7309 Closes tldraw#7518 https://github.com/user-attachments/assets/6bd0791e-c25b-48ec-bad9-37492e5c32fe ## Example Creating a custom frame-like shape by extending `BaseFrameLikeShapeUtil`: ```ts class MyContainerUtil extends BaseFrameLikeShapeUtil<MyContainerShape> { static override type = 'my-container' as const static override props = myContainerShapeProps override getDefaultProps() { return { w: 300, h: 200 } } override component(shape: MyContainerShape) { return <SVGContainer>...</SVGContainer> } override indicator(shape: MyContainerShape) { return <rect width={shape.props.w} height={shape.props.h} /> } } ``` ## Test plan - [x] pre-commit checks pass locally - [ ] smoke test paste / duplicate behavior for frame-like shapes - [ ] smoke test snapping, selection, and erasing interactions - [ ] verify the portal shapes example reparents and teleports correctly ### Change type - [x] `feature` ### Release notes - Add `BaseFrameLikeShapeUtil` abstract class to make it easier to build custom frame-like shapes. - Add `ShapeUtil.isFrameLike()` so custom shapes can opt into frame-like behavior (clipping children, full-brush selection, blocking erasure from inside, etc.). ### API changes - Added `BaseFrameLikeShapeUtil` abstract class exported from `@tldraw/editor`, extending `BaseBoxShapeUtil` with defaults for frame-like behavior. - Added `ShapeUtil.isFrameLike(shape)` method (returns `false` by default) for custom shapes to opt into frame behaviors. - `FrameShapeUtil` now extends `BaseFrameLikeShapeUtil` instead of `BaseBoxShapeUtil`. ### Code changes | Section | LOC change | | --------------- | ---------- | | Core code | +169 / -97 | | Tests | +14 / -2 | | Automated files | +19 / -13 | | Documentation | +452 / -0 |
1 parent 4486ad9 commit 3810d2a

25 files changed

Lines changed: 670 additions & 134 deletions

File tree

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import {
2+
BaseFrameLikeShapeUtil,
3+
Ellipse2d,
4+
Geometry2d,
5+
Group2d,
6+
RecordProps,
7+
SVGContainer,
8+
T,
9+
TLBaseBoxShape,
10+
TLDropShapesOverInfo,
11+
TLResizeInfo,
12+
TLShape,
13+
resizeBox,
14+
toDomPrecision,
15+
toRichText,
16+
} from 'tldraw'
17+
18+
const PORTAL_SHAPE_TYPE = 'portal'
19+
20+
declare module 'tldraw' {
21+
export interface TLGlobalShapePropsMap {
22+
[PORTAL_SHAPE_TYPE]: { w: number; h: number; color: string }
23+
}
24+
}
25+
26+
export type PortalShape = TLBaseBoxShape & {
27+
type: typeof PORTAL_SHAPE_TYPE
28+
props: { color: 'blue' | 'orange' }
29+
}
30+
31+
const COLORS = {
32+
blue: {
33+
fill: 'rgba(60, 130, 246, 0.08)',
34+
stroke: '#3b82f6',
35+
glow: 'rgba(59, 130, 246, 0.4)',
36+
},
37+
orange: {
38+
fill: 'rgba(249, 115, 22, 0.08)',
39+
stroke: '#f97316',
40+
glow: 'rgba(249, 115, 22, 0.4)',
41+
},
42+
} as const
43+
44+
let teleportCount = 0
45+
46+
export class PortalShapeUtil extends BaseFrameLikeShapeUtil<PortalShape> {
47+
static override type = PORTAL_SHAPE_TYPE
48+
static override props: RecordProps<PortalShape> = {
49+
w: T.number,
50+
h: T.number,
51+
color: T.literalEnum('blue', 'orange'),
52+
}
53+
54+
override getDefaultProps(): PortalShape['props'] {
55+
return { w: 250, h: 300, color: 'blue' }
56+
}
57+
58+
override canResize() {
59+
return true
60+
}
61+
62+
override getGeometry(shape: PortalShape): Geometry2d {
63+
return new Group2d({
64+
children: [
65+
new Ellipse2d({
66+
width: shape.props.w,
67+
height: shape.props.h,
68+
isFilled: true,
69+
}),
70+
],
71+
})
72+
}
73+
74+
override onResize(shape: PortalShape, info: TLResizeInfo<PortalShape>) {
75+
return resizeBox(shape, info)
76+
}
77+
78+
private findLinkedPortal(shape: PortalShape): PortalShape | undefined {
79+
const otherColor = shape.props.color === 'blue' ? 'orange' : 'blue'
80+
const allShapes = this.editor.getCurrentPageShapes() as TLShape[]
81+
return allShapes.find(
82+
(s): s is PortalShape =>
83+
s.type === PORTAL_SHAPE_TYPE && (s as PortalShape).props.color === otherColor
84+
)
85+
}
86+
87+
// On drop, teleport: move shapes from this portal to the linked one.
88+
override onDropShapesOver(shape: PortalShape, shapes: TLShape[], _info: TLDropShapesOverInfo) {
89+
const linked = this.findLinkedPortal(shape)
90+
if (!linked) return
91+
92+
const { editor } = this
93+
94+
// First, ensure all dropped shapes are children of this portal so we
95+
// have consistent local coordinates. Shapes dragged in during the drag
96+
// are already parented here; shapes dropped directly may not be.
97+
const needsReparent = shapes.filter((s) => s.parentId !== shape.id)
98+
if (needsReparent.length > 0) {
99+
editor.reparentShapes(needsReparent, shape.id)
100+
}
101+
102+
// Re-read shapes after reparenting so local coords are up to date.
103+
const freshShapes = shapes.map((s) => editor.getShape(s.id)!).filter(Boolean)
104+
105+
// Stash each shape's local position inside this portal, then
106+
// reparent to the linked portal and restore the same local offset.
107+
const localPositions = new Map<TLShape['id'], { x: number; y: number }>()
108+
for (const s of freshShapes) {
109+
localPositions.set(s.id, { x: s.x, y: s.y })
110+
}
111+
112+
editor.reparentShapes(
113+
freshShapes.map((s) => s.id),
114+
linked.id
115+
)
116+
117+
for (const s of freshShapes) {
118+
const pos = localPositions.get(s.id)!
119+
const current = editor.getShape(s.id)!
120+
editor.updateShape({ ...current, x: pos.x, y: pos.y })
121+
}
122+
123+
teleportCount++
124+
if (teleportCount === 3) {
125+
editor.createShape({
126+
type: 'text',
127+
x: linked.x + linked.props.w + 40,
128+
y: linked.y + linked.props.h / 2 - 20,
129+
props: { size: 'xl', richText: toRichText('🍰') },
130+
})
131+
}
132+
}
133+
134+
override component(shape: PortalShape) {
135+
const theme = COLORS[shape.props.color]
136+
const cx = shape.props.w / 2
137+
const cy = shape.props.h / 2
138+
const rx = shape.props.w / 2
139+
const ry = shape.props.h / 2
140+
141+
return (
142+
<SVGContainer>
143+
<ellipse
144+
cx={toDomPrecision(cx)}
145+
cy={toDomPrecision(cy)}
146+
rx={toDomPrecision(rx)}
147+
ry={toDomPrecision(ry)}
148+
fill={theme.fill}
149+
stroke={theme.stroke}
150+
strokeWidth={3}
151+
style={{ filter: `drop-shadow(0 0 12px ${theme.glow})` }}
152+
/>
153+
</SVGContainer>
154+
)
155+
}
156+
157+
override indicator(shape: PortalShape) {
158+
return (
159+
<ellipse
160+
cx={toDomPrecision(shape.props.w / 2)}
161+
cy={toDomPrecision(shape.props.h / 2)}
162+
rx={toDomPrecision(shape.props.w / 2)}
163+
ry={toDomPrecision(shape.props.h / 2)}
164+
/>
165+
)
166+
}
167+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import {
2+
createShapeId,
3+
TLContent,
4+
TLShapePartial,
5+
TLRichText,
6+
TLTextShapeProps,
7+
Tldraw,
8+
} from 'tldraw'
9+
import 'tldraw/tldraw.css'
10+
import companionCube from './companion-cube.json'
11+
import { PortalShapeUtil } from './PortalShapeUtil'
12+
13+
// [1]
14+
const shapeUtils = [PortalShapeUtil]
15+
16+
const tagline: TLRichText = {
17+
type: 'doc',
18+
content: [
19+
{
20+
type: 'paragraph',
21+
content: [
22+
{ type: 'text', text: "Now you're thinking with " },
23+
{ type: 'text', marks: [{ type: 'strike' }], text: 'portals' },
24+
{ type: 'text', text: ' frames' },
25+
],
26+
},
27+
],
28+
}
29+
30+
export default function PortalShapesExample() {
31+
return (
32+
<div className="tldraw__editor">
33+
<Tldraw
34+
shapeUtils={shapeUtils}
35+
onMount={(editor) => {
36+
// [2]
37+
const blueId = createShapeId('blue-portal')
38+
const orangeId = createShapeId('orange-portal')
39+
const portalShapes = [
40+
{
41+
id: blueId,
42+
type: 'portal',
43+
x: 100,
44+
y: 150,
45+
props: { w: 200, h: 300, color: 'blue' },
46+
},
47+
{
48+
id: orangeId,
49+
type: 'portal',
50+
x: 500,
51+
y: 150,
52+
props: { w: 200, h: 300, color: 'orange' },
53+
},
54+
] as const
55+
56+
editor.createShapes(portalShapes as unknown as TLShapePartial[])
57+
58+
// [3]
59+
editor.createShape({
60+
type: 'text',
61+
x: 100,
62+
y: 500,
63+
props: {
64+
size: 'l',
65+
richText: tagline,
66+
} satisfies Partial<TLTextShapeProps>,
67+
})
68+
69+
// [4]
70+
editor.putContentOntoCurrentPage(companionCube as unknown as TLContent, {
71+
point: { x: 300, y: 20 },
72+
})
73+
editor.selectNone()
74+
75+
editor.zoomToFit({ animation: { duration: 0 } })
76+
editor.zoomOut(undefined, { animation: { duration: 0 } })
77+
}}
78+
/>
79+
</div>
80+
)
81+
}
82+
83+
/*
84+
[1]
85+
Register the custom PortalShapeUtil. This array is defined outside of the
86+
component so it stays referentially stable across renders.
87+
88+
[2]
89+
Create a pair of linked portals — one blue, one orange. They find each
90+
other by color, so only one of each should exist on a page.
91+
92+
[3]
93+
Add the Portal tagline as a text shape.
94+
95+
[4]
96+
Load the companion cube from a snapshot and place it on the canvas
97+
for the user to drag into the portals.
98+
*/
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
title: Portal shapes
3+
component: ./PortalShapesExample.tsx
4+
category: shapes/tools
5+
priority: 6
6+
keywords: [frame, portal, BaseFrameLikeShapeUtil, reparent, drag]
7+
---
8+
9+
Custom frame-like shapes that teleport children between each other, inspired by Portal.
10+
11+
---
12+
13+
This example shows how to extend `BaseFrameLikeShapeUtil` to create custom shapes that behave like frames — they accept children via drag-and-drop and clip their contents. Two portal shapes (blue and orange) are linked: dragging a shape into one teleports it into the other.

0 commit comments

Comments
 (0)