Skip to content

Commit 41b0393

Browse files
feat(examples): add native flex layout shape example (tldraw#8715)
Partially inspired by customer request, partially to showcase new framelike behavior, this example adds a drag-and-droppable container that uses native css flexbox as the layout engine. https://github.com/user-attachments/assets/508d8f8e-bb33-45f1-b9e9-87c1d18d5b63 ### Change type - [ ] `bugfix` - [ ] `improvement` - [x] `feature` - [ ] `api` - [ ] `other` ### Test plan 1. Open the flex layout example. 2. Select the frame background and use the contextual toolbar to toggle horizontal/vertical layout. 3. Drag the outside green rectangle into the layout and verify the hint/drop line, pointer-up reparenting, and growth. 4. Drag a child out and verify it deparents and the layout shrinks during mouse move before pointer-up. - [ ] Unit tests - [ ] End to end tests ### Release notes - Added a flex layout custom shape example. <sub>To show artifacts inline, <a href="https://cursor.com/dashboard/cloud-agents#team-pull-requests">enable</a> in settings.</sub> <!-- CURSOR_AGENT_PR_BODY_END --> <div><a href="https://cursor.com/agents/bc-d6af83b5-a09f-4f7a-abc7-0a597d9d6e13"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-d6af83b5-a09f-4f7a-abc7-0a597d9d6e13"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent e125b3a commit 41b0393

4 files changed

Lines changed: 1160 additions & 0 deletions

File tree

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import { Editor, TLShape, TLShapeId } from 'tldraw'
2+
3+
export const FLEX_CONTAINER_DEFAULT_LABEL = 'Flex layout'
4+
5+
export const FLEX_CONTAINER_PADDING = 24
6+
export const FLEX_CONTAINER_GAP = 16
7+
export const FLEX_CONTAINER_EMPTY_WIDTH = 160
8+
export const FLEX_CONTAINER_EMPTY_HEIGHT = 120
9+
10+
export type FlexAlign = 'start' | 'center' | 'end'
11+
export type FlexJustify = 'start' | 'center' | 'end' | 'space-between'
12+
13+
export interface FlexContainerLayoutProps {
14+
direction: 'horizontal' | 'vertical'
15+
align: FlexAlign
16+
justify: FlexJustify
17+
}
18+
19+
export interface FlexContainerBoxShape {
20+
id: TLShapeId
21+
props: FlexContainerLayoutProps & { w: number; h: number }
22+
}
23+
24+
export interface FrameLabelBoxShape {
25+
id: TLShapeId
26+
props: { w: number; h: number }
27+
}
28+
29+
function toCssFlex(value: FlexAlign | FlexJustify) {
30+
if (value === 'start') return 'flex-start'
31+
if (value === 'end') return 'flex-end'
32+
return value
33+
}
34+
35+
export function getFlexContainerStyles(shape: FlexContainerBoxShape): React.CSSProperties {
36+
const isHorizontal = shape.props.direction === 'horizontal'
37+
return {
38+
flexDirection: isHorizontal ? 'row' : 'column',
39+
justifyContent: toCssFlex(shape.props.justify),
40+
alignItems: toCssFlex(shape.props.align),
41+
}
42+
}
43+
44+
export function getMainAxisPositions(
45+
sizes: number[],
46+
innerSize: number,
47+
justify: FlexJustify,
48+
gap: number
49+
): number[] {
50+
const count = sizes.length
51+
if (count === 0) return []
52+
53+
const contentSize = sizes.reduce((sum, size) => sum + size, 0) + gap * Math.max(0, count - 1)
54+
const freeSpace = innerSize - contentSize
55+
56+
if (justify === 'start' || (justify === 'space-between' && count === 1)) {
57+
let position = 0
58+
return sizes.map((size) => {
59+
const axisPosition = position
60+
position += size + gap
61+
return axisPosition
62+
})
63+
}
64+
65+
if (justify === 'end') {
66+
let position = freeSpace
67+
return sizes.map((size) => {
68+
const axisPosition = position
69+
position += size + gap
70+
return axisPosition
71+
})
72+
}
73+
74+
if (justify === 'center') {
75+
let position = freeSpace / 2
76+
return sizes.map((size) => {
77+
const axisPosition = position
78+
position += size + gap
79+
return axisPosition
80+
})
81+
}
82+
83+
const spacing = gap + freeSpace / (count - 1)
84+
let position = 0
85+
return sizes.map((size, index) => {
86+
const axisPosition = position
87+
position += size + (index < count - 1 ? spacing : 0)
88+
return axisPosition
89+
})
90+
}
91+
92+
export function getCrossAxisOffset(childSize: number, innerSize: number, align: FlexAlign) {
93+
if (align === 'start') return 0
94+
if (align === 'end') return innerSize - childSize
95+
return (innerSize - childSize) / 2
96+
}
97+
98+
export function getMinimumContentSize(
99+
editor: Editor,
100+
shape: FlexContainerBoxShape,
101+
children: TLShape[]
102+
) {
103+
if (children.length === 0) {
104+
return { w: FLEX_CONTAINER_EMPTY_WIDTH, h: FLEX_CONTAINER_EMPTY_HEIGHT }
105+
}
106+
107+
const bounds = children.map((child) => editor.getShapeGeometry(child).bounds)
108+
const totalGap = FLEX_CONTAINER_GAP * Math.max(0, children.length - 1)
109+
110+
if (shape.props.direction === 'horizontal') {
111+
return {
112+
w: bounds.reduce((sum, b) => sum + b.width, 0) + totalGap + FLEX_CONTAINER_PADDING * 2,
113+
h: Math.max(...bounds.map((b) => b.height)) + FLEX_CONTAINER_PADDING * 2,
114+
}
115+
}
116+
117+
return {
118+
w: Math.max(...bounds.map((b) => b.width)) + FLEX_CONTAINER_PADDING * 2,
119+
h: bounds.reduce((sum, b) => sum + b.height, 0) + totalGap + FLEX_CONTAINER_PADDING * 2,
120+
}
121+
}
122+
123+
export function getDesiredSize(editor: Editor, shape: FlexContainerBoxShape, children: TLShape[]) {
124+
const minimum = getMinimumContentSize(editor, shape, children)
125+
return {
126+
w: Math.max(shape.props.w, minimum.w),
127+
h: Math.max(shape.props.h, minimum.h),
128+
}
129+
}
130+
131+
export function getChildPositions(
132+
editor: Editor,
133+
shape: FlexContainerBoxShape,
134+
children: TLShape[],
135+
size = getDesiredSize(editor, shape, children)
136+
) {
137+
const bounds = children.map((child) => editor.getShapeGeometry(child).bounds)
138+
const innerW = size.w - FLEX_CONTAINER_PADDING * 2
139+
const innerH = size.h - FLEX_CONTAINER_PADDING * 2
140+
141+
if (shape.props.direction === 'horizontal') {
142+
const mainPositions = getMainAxisPositions(
143+
bounds.map((b) => b.width),
144+
innerW,
145+
shape.props.justify,
146+
FLEX_CONTAINER_GAP
147+
)
148+
149+
return bounds.map((b, index) => ({
150+
x: FLEX_CONTAINER_PADDING + mainPositions[index],
151+
y: FLEX_CONTAINER_PADDING + getCrossAxisOffset(b.height, innerH, shape.props.align),
152+
}))
153+
}
154+
155+
const mainPositions = getMainAxisPositions(
156+
bounds.map((b) => b.height),
157+
innerH,
158+
shape.props.justify,
159+
FLEX_CONTAINER_GAP
160+
)
161+
162+
return bounds.map((b, index) => ({
163+
x: FLEX_CONTAINER_PADDING + getCrossAxisOffset(b.width, innerW, shape.props.align),
164+
y: FLEX_CONTAINER_PADDING + mainPositions[index],
165+
}))
166+
}
167+
168+
export function getDropInLineStyle(
169+
editor: Editor,
170+
shape: FlexContainerBoxShape,
171+
children: TLShape[],
172+
dropIndex: number
173+
): React.CSSProperties {
174+
const movingIds = new Set(editor.getSelectedShapeIds())
175+
const layoutChildren = children.filter((child) => !movingIds.has(child.id))
176+
177+
if (shape.props.direction === 'horizontal') {
178+
const x = getDropLineCoordinate(editor, layoutChildren, dropIndex, 'x', shape.props.w)
179+
return {
180+
left: x - 1,
181+
top: FLEX_CONTAINER_PADDING,
182+
width: 2,
183+
height: shape.props.h - FLEX_CONTAINER_PADDING * 2,
184+
}
185+
}
186+
187+
const y = getDropLineCoordinate(editor, layoutChildren, dropIndex, 'y', shape.props.h)
188+
return {
189+
left: FLEX_CONTAINER_PADDING,
190+
top: y - 1,
191+
width: shape.props.w - FLEX_CONTAINER_PADDING * 2,
192+
height: 2,
193+
}
194+
}
195+
196+
export function getDropOutLineStyle(
197+
shape: FlexContainerBoxShape,
198+
point: { x: number; y: number }
199+
): React.CSSProperties {
200+
if (shape.props.direction === 'horizontal') {
201+
const x = point.x < shape.props.w / 2 ? 0 : shape.props.w
202+
return {
203+
left: x - 1,
204+
top: FLEX_CONTAINER_PADDING,
205+
width: 2,
206+
height: shape.props.h - FLEX_CONTAINER_PADDING * 2,
207+
}
208+
}
209+
210+
const y = point.y < shape.props.h / 2 ? 0 : shape.props.h
211+
return {
212+
left: FLEX_CONTAINER_PADDING,
213+
top: y - 1,
214+
width: shape.props.w - FLEX_CONTAINER_PADDING * 2,
215+
height: 2,
216+
}
217+
}
218+
219+
function getDropLineCoordinate(
220+
editor: Editor,
221+
children: TLShape[],
222+
dropIndex: number,
223+
axis: 'x' | 'y',
224+
size: number
225+
) {
226+
if (children.length === 0) return size / 2
227+
if (dropIndex <= 0) return children[0][axis] - FLEX_CONTAINER_GAP / 2
228+
229+
const previous = children[Math.min(dropIndex - 1, children.length - 1)]
230+
const previousBounds = editor.getShapeGeometry(previous).bounds
231+
const previousEnd = previous[axis] + (axis === 'x' ? previousBounds.width : previousBounds.height)
232+
233+
if (dropIndex >= children.length) return previousEnd + FLEX_CONTAINER_GAP / 2
234+
235+
const next = children[dropIndex]
236+
return (previousEnd + next[axis]) / 2
237+
}

0 commit comments

Comments
 (0)