Flex layout shape
A frame-like shape that lets the browser's flexbox engine arrange its children.
import { memo, useLayoutEffect, useRef } from 'react'
import {
BaseFrameLikeShapeUtil,
Box,
Editor,
Geometry2d,
Group2d,
HTMLContainer,
RecordProps,
Rectangle2d,
T,
TLDragShapesOutInfo,
TLShape,
TLShapeId,
TLShapePartial,
TLComponents,
Tldraw,
TldrawUiButtonIcon,
TldrawUiContextualToolbar,
TldrawUiToolbarButton,
canonicalizeRotation,
clamp,
createShapeId,
getIndicesBetween,
last,
resizeBox,
toDomPrecision,
track,
useEditor,
useValue,
type TLResizeInfo,
} from 'tldraw'
import {
FLEX_CONTAINER_DEFAULT_LABEL,
FLEX_CONTAINER_GAP,
FLEX_CONTAINER_PADDING,
getChildPositions,
getDesiredSize,
getDropInLineStyle,
getDropOutLineStyle,
getFlexContainerStyles,
getMinimumContentSize,
type FlexContainerLayoutProps,
type FrameLabelBoxShape,
} from './FlexContainerHelpers'
import 'tldraw/tldraw.css'
import './flex-layout.css'
// There's a guide at the bottom of this file!
const FLEX_LAYOUT_SHAPE_TYPE = 'flex-layout'
const FLEX_LAYOUT_ID = createShapeId('flex-layout-example')
// [1]
declare module 'tldraw' {
export interface TLGlobalShapePropsMap {
[FLEX_LAYOUT_SHAPE_TYPE]: FlexContainerLayoutProps & {
w: number
h: number
}
}
}
// [2]
type FlexLayoutShape = TLShape<typeof FLEX_LAYOUT_SHAPE_TYPE>
// [3]
const FRAME_HEADING_EXTRA_WIDTH = 12
const FRAME_HEADING_MIN_WIDTH = 32
const FRAME_HEADING_NOCOLORS_OFFSET_X = -7
const FRAME_HEADING_OFFSET_Y = 4
function getContainerHeadingSide(editor: Editor, shape: FrameLabelBoxShape): 0 | 1 | 2 | 3 {
const pageRotation = canonicalizeRotation(editor.getShapePageTransform(shape.id)!.rotation())
const offsetRotation = pageRotation + Math.PI / 4
const scaledRotation = (offsetRotation * (2 / Math.PI) + 4) % 4
return Math.floor(scaledRotation) as 0 | 1 | 2 | 3
}
function getContainerHeadingOpts(width: number) {
return {
fontSize: 12,
fontFamily: 'Inter, sans-serif',
textAlign: 'start' as const,
width,
height: 24,
padding: 0,
lineHeight: 1,
fontStyle: 'normal',
fontWeight: 'normal',
overflow: 'truncate-ellipsis' as const,
verticalTextAlign: 'middle' as const,
offsetY: -(32 + 2),
offsetX: 0,
}
}
function getContainerHeadingSize(
editor: Editor,
shape: FrameLabelBoxShape,
opts: ReturnType<typeof getContainerHeadingOpts>,
label: string
) {
const spans = editor.textMeasure.measureTextSpans(label, opts)
const firstSpan = spans[0]
const lastSpan = last(spans)!
const width = lastSpan.box.w + lastSpan.box.x - firstSpan.box.x
return new Box(0, -opts.height, width, opts.height)
}
function getContainerHeadingTranslation(shape: FrameLabelBoxShape, side: 0 | 1 | 2 | 3) {
const u = 'px'
const r = 'deg'
switch (side) {
case 0:
return ''
case 3:
return `translate(${toDomPrecision(shape.props.w)}${u}, 0${u}) rotate(90${r})`
case 2:
return `translate(${toDomPrecision(shape.props.w)}${u}, ${toDomPrecision(shape.props.h)}${u}) rotate(180${r})`
case 1:
return `translate(0${u}, ${toDomPrecision(shape.props.h)}${u}) rotate(270${r})`
default:
throw Error('labelSide out of bounds')
}
}
function getFlexContainerLabelGeometry(
editor: Editor,
shape: FrameLabelBoxShape,
label = FLEX_CONTAINER_DEFAULT_LABEL
) {
const z = editor.getEfficientZoomLevel()
const labelSide = getContainerHeadingSide(editor, shape)
const isVertical = labelSide % 2 === 1
const rotatedTopEdgeWidth = isVertical ? shape.props.h : shape.props.w
const opts = getContainerHeadingOpts(rotatedTopEdgeWidth)
const headingSize = getContainerHeadingSize(editor, shape, opts, label)
const extraWidth = FRAME_HEADING_EXTRA_WIDTH / z
const minWidth = FRAME_HEADING_MIN_WIDTH / z
const maxWidth = rotatedTopEdgeWidth + extraWidth
const labelWidth = headingSize.w / z
const labelHeight = headingSize.h / z
const clampedLabelWidth = clamp(labelWidth + extraWidth, minWidth, maxWidth)
const offsetX = FRAME_HEADING_NOCOLORS_OFFSET_X / z
const offsetY = FRAME_HEADING_OFFSET_Y / z
const width = isVertical ? labelHeight : clampedLabelWidth
const height = isVertical ? clampedLabelWidth : labelHeight
let x: number
let y: number
switch (labelSide) {
case 0:
x = offsetX
y = -(labelHeight + offsetY)
break
case 1:
x = -(labelHeight + offsetY)
y = shape.props.h - (offsetX + clampedLabelWidth)
break
case 2:
x = shape.props.w - (offsetX + clampedLabelWidth)
y = shape.props.h + offsetY
break
case 3:
x = shape.props.w + offsetY
y = offsetX
break
}
return new Rectangle2d({
x,
y,
width,
height,
isFilled: true,
isLabel: true,
excludeFromShapeBounds: true,
})
}
const FlexContainerHeading = memo(function FlexContainerHeading({
shape,
label = FLEX_CONTAINER_DEFAULT_LABEL,
}: {
shape: FrameLabelBoxShape
label?: string
}) {
const editor = useEditor()
const { side, translation } = useValue(
'flex container heading',
() => {
const labelSide = getContainerHeadingSide(editor, shape)
return {
side: labelSide,
translation: getContainerHeadingTranslation(shape, labelSide),
}
},
[editor, shape]
)
return (
<div
className="tl-frame-heading"
style={{
overflow: 'hidden',
maxWidth: `calc(var(--tl-zoom) * ${
side === 0 || side === 2 ? Math.ceil(shape.props.w) : Math.ceil(shape.props.h)
}px + var(--tl-frame-offset-width))`,
bottom: '100%',
transform: `${translation} scale(min(var(--tl-scale), 3.5)) translateX(-7px)`,
}}
>
<div
className="tl-frame-heading-hit-area flex-layout-heading__label"
style={{
color: 'var(--tl-color-text-1)',
backgroundColor: '#fff',
boxShadow: 'inset 0px 0px 0px 1px var(--tl-color-muted-1)',
}}
>
{label}
</div>
</div>
)
})
// [4]
class FlexLayoutShapeUtil extends BaseFrameLikeShapeUtil<FlexLayoutShape> {
static override type = FLEX_LAYOUT_SHAPE_TYPE
static override props: RecordProps<FlexLayoutShape> = {
w: T.nonZeroNumber,
h: T.nonZeroNumber,
direction: T.literalEnum('horizontal', 'vertical'),
align: T.literalEnum('start', 'center', 'end'),
justify: T.literalEnum('start', 'center', 'end', 'space-between'),
}
override canResize() {
return true
}
override canResizeChildren() {
return false
}
override getDefaultProps(): FlexLayoutShape['props'] {
return {
w: 420,
h: 240,
direction: 'horizontal',
align: 'center',
justify: 'center',
}
}
override getGeometry(shape: FlexLayoutShape): Geometry2d {
return new Group2d({
children: [
new Rectangle2d({
width: shape.props.w,
height: shape.props.h,
isFilled: false,
}),
getFlexContainerLabelGeometry(this.editor, shape, FLEX_CONTAINER_DEFAULT_LABEL),
],
})
}
override getText(_shape: FlexLayoutShape) {
return FLEX_CONTAINER_DEFAULT_LABEL
}
override getAriaDescriptor(_shape: FlexLayoutShape) {
return FLEX_CONTAINER_DEFAULT_LABEL
}
override component(shape: FlexLayoutShape) {
return (
<>
<FlexContainerHeading shape={shape} label={FLEX_CONTAINER_DEFAULT_LABEL} />
<FlexLayoutShapeComponent shape={shape} />
</>
)
}
// [4a]
override onResize(shape: FlexLayoutShape, info: TLResizeInfo<FlexLayoutShape>) {
const children = getFlexLayoutChildren(this.editor, shape.id)
const minimum = getMinimumContentSize(this.editor, shape, children)
return resizeBox(shape, info, {
minWidth: minimum.w,
minHeight: minimum.h,
})
}
override onResizeEnd(_initialShape: FlexLayoutShape, currentShape: FlexLayoutShape) {
relayoutFlexLayoutChildren(this.editor, currentShape.id)
}
// [4b]
override onDragShapesOut(shape: FlexLayoutShape, shapes: TLShape[], info: TLDragShapesOutInfo) {
if (info.nextDraggingOverShapeId) return
const childrenToMove = shapes.filter((movingShape) => movingShape.parentId === shape.id)
if (!childrenToMove.length) return
this.editor.reparentShapes(childrenToMove, this.editor.getCurrentPageId())
resizeFlexLayoutToChildren(this.editor, shape.id)
}
// [4c]
override onDropShapesOver(shape: FlexLayoutShape, shapes: TLShape[]) {
const dropIndex = getDropIndex(this.editor, shape, shapes)
dropShapesIntoLayout(this.editor, shape, shapes, dropIndex)
}
// [4d]
override onTranslateEnd(_initialShape: FlexLayoutShape, currentShape: FlexLayoutShape) {
resizeFlexLayoutToChildren(this.editor, currentShape.id)
}
override getIndicatorPath(shape: FlexLayoutShape) {
const path = new Path2D()
path.rect(0, 0, shape.props.w, shape.props.h)
return path
}
}
const shapeUtils = [FlexLayoutShapeUtil]
// [5]
function FlexLayoutShapeComponent({ shape }: { shape: FlexLayoutShape }) {
const editor = useEditor()
const rMeasure = useRef<HTMLDivElement>(null)
const children = useValue(
'flex layout children',
() =>
editor
.getSortedChildIdsForParent(shape.id)
.map((id) => editor.getShape(id))
.filter((child) => child !== undefined),
[editor, shape.id]
)
const layoutKey = children
.map((child) => {
const bounds = editor.getShapeGeometry(child).bounds
return `${child.id}:${child.x}:${child.y}:${bounds.width}:${bounds.height}`
})
.join('|')
const dropIndicator = useValue(
'flex layout drop indicator',
() => getDropIndicator(editor, shape, children),
[editor, shape.id, shape.props.direction, shape.props.align, shape.props.justify, layoutKey]
)
const isTranslatingSelectedChild = useValue(
'flex layout is translating selected child',
() => getIsTranslatingSelectedChild(editor, shape),
[editor, shape.id]
)
useLayoutEffect(() => {
if (isTranslatingSelectedChild) return
const elm = rMeasure.current
if (!elm) return
const measureElm = elm
resizeFlexLayoutToChildren(editor, shape.id)
function updateChildrenFromFlexbox() {
const updates: TLShapePartial[] = Array.from(
measureElm.querySelectorAll<HTMLElement>('[data-shape-id]')
).flatMap((childElm) => {
const childId = childElm.dataset.shapeId as TLShapeId | undefined
if (!childId) return []
const child = editor.getShape(childId)
if (!child || child.parentId !== shape.id) return []
const x = childElm.offsetLeft
const y = childElm.offsetTop
if (Math.abs(child.x - x) < 0.5 && Math.abs(child.y - y) < 0.5) return []
return [{ id: child.id, type: child.type, x, y } as TLShapePartial]
})
if (updates.length) {
editor.updateShapes(updates)
}
}
updateChildrenFromFlexbox()
const observer = new ResizeObserver(updateChildrenFromFlexbox)
observer.observe(measureElm)
for (const childElm of measureElm.querySelectorAll<HTMLElement>('[data-shape-id]')) {
observer.observe(childElm)
}
return () => observer.disconnect()
}, [editor, shape, layoutKey, isTranslatingSelectedChild, shape.props.align, shape.props.justify])
return (
<HTMLContainer
className="flex-layout-shape"
data-drop-active={!!dropIndicator}
style={{
width: shape.props.w,
height: shape.props.h,
}}
>
<div
ref={rMeasure}
className="flex-layout-shape__measure"
style={{
width: shape.props.w,
height: shape.props.h,
padding: FLEX_CONTAINER_PADDING,
gap: FLEX_CONTAINER_GAP,
...getFlexContainerStyles(shape),
}}
>
{children.map((child) => {
const bounds = editor.getShapeGeometry(child).bounds
return (
<div
key={child.id}
data-shape-id={child.id}
style={{
width: bounds.width,
height: bounds.height,
}}
/>
)
})}
</div>
{dropIndicator ? (
<div
className="flex-layout-shape__drop-line"
data-direction={shape.props.direction}
data-drop-type={dropIndicator.type}
style={dropIndicator.style}
/>
) : null}
</HTMLContainer>
)
}
// [6]
const FlexLayoutToolbar = track(function FlexLayoutToolbar() {
const editor = useEditor()
const shape = useValue(
'selected flex layout shape',
() => {
if (!editor.isIn('select.idle')) return null
const onlySelectedShape = editor.getOnlySelectedShape()
return onlySelectedShape?.type === FLEX_LAYOUT_SHAPE_TYPE
? (onlySelectedShape as FlexLayoutShape)
: null
},
[editor]
)
if (!shape) return null
const updateLayoutProps = (props: Partial<FlexContainerLayoutProps>) => {
editor.run(() => {
editor.updateShapes([{ id: shape.id, type: shape.type, props } as TLShapePartial])
relayoutFlexLayoutChildren(editor, shape.id)
})
editor.getContainer().focus()
}
const isHorizontal = shape.props.direction === 'horizontal'
const getSelectionBounds = () => {
const bounds = editor.getSelectionRotatedScreenBounds()
if (!bounds) return undefined
return new Box(bounds.x, bounds.y, bounds.width, 0)
}
return (
<TldrawUiContextualToolbar getSelectionBounds={getSelectionBounds} label="Flex layout">
<TldrawUiToolbarButton
type="icon"
title="Horizontal"
isActive={shape.props.direction === 'horizontal'}
onClick={() => updateLayoutProps({ direction: 'horizontal' })}
>
<TldrawUiButtonIcon small icon="stack-horizontal" />
</TldrawUiToolbarButton>
<TldrawUiToolbarButton
type="icon"
title="Vertical"
isActive={shape.props.direction === 'vertical'}
onClick={() => updateLayoutProps({ direction: 'vertical' })}
>
<TldrawUiButtonIcon small icon="stack-vertical" />
</TldrawUiToolbarButton>
<TldrawUiToolbarButton
type="icon"
title={isHorizontal ? 'Align left' : 'Align top'}
isActive={shape.props.justify === 'start'}
onClick={() => updateLayoutProps({ justify: 'start' })}
>
<TldrawUiButtonIcon
small
icon={isHorizontal ? 'horizontal-align-start' : 'vertical-align-start'}
/>
</TldrawUiToolbarButton>
<TldrawUiToolbarButton
type="icon"
title={isHorizontal ? 'Align center' : 'Align middle'}
isActive={shape.props.justify === 'center'}
onClick={() => updateLayoutProps({ justify: 'center' })}
>
<TldrawUiButtonIcon
small
icon={isHorizontal ? 'horizontal-align-middle' : 'vertical-align-middle'}
/>
</TldrawUiToolbarButton>
<TldrawUiToolbarButton
type="icon"
title={isHorizontal ? 'Align right' : 'Align bottom'}
isActive={shape.props.justify === 'end'}
onClick={() => updateLayoutProps({ justify: 'end' })}
>
<TldrawUiButtonIcon
small
icon={isHorizontal ? 'horizontal-align-end' : 'vertical-align-end'}
/>
</TldrawUiToolbarButton>
<TldrawUiToolbarButton
type="icon"
title={isHorizontal ? 'Distribute horizontally' : 'Distribute vertically'}
isActive={shape.props.justify === 'space-between'}
onClick={() => updateLayoutProps({ justify: 'space-between' })}
>
<TldrawUiButtonIcon
small
icon={isHorizontal ? 'distribute-horizontal' : 'distribute-vertical'}
/>
</TldrawUiToolbarButton>
<TldrawUiToolbarButton
type="icon"
title={isHorizontal ? 'Align top' : 'Align left'}
isActive={shape.props.align === 'start'}
onClick={() => updateLayoutProps({ align: 'start' })}
>
<TldrawUiButtonIcon
small
icon={isHorizontal ? 'vertical-align-start' : 'horizontal-align-start'}
/>
</TldrawUiToolbarButton>
<TldrawUiToolbarButton
type="icon"
title={isHorizontal ? 'Align middle' : 'Align center'}
isActive={shape.props.align === 'center'}
onClick={() => updateLayoutProps({ align: 'center' })}
>
<TldrawUiButtonIcon
small
icon={isHorizontal ? 'vertical-align-middle' : 'horizontal-align-middle'}
/>
</TldrawUiToolbarButton>
<TldrawUiToolbarButton
type="icon"
title={isHorizontal ? 'Align bottom' : 'Align right'}
isActive={shape.props.align === 'end'}
onClick={() => updateLayoutProps({ align: 'end' })}
>
<TldrawUiButtonIcon
small
icon={isHorizontal ? 'vertical-align-end' : 'horizontal-align-end'}
/>
</TldrawUiToolbarButton>
</TldrawUiContextualToolbar>
)
})
const components: TLComponents = { InFrontOfTheCanvas: FlexLayoutToolbar }
// [7]
function getFlexLayoutChildren(editor: Editor, shapeId: TLShapeId) {
return editor
.getSortedChildIdsForParent(shapeId)
.map((id) => editor.getShape(id))
.filter((child): child is TLShape => !!child)
}
function getIsTranslatingSelectedChild(editor: Editor, shape: FlexLayoutShape) {
return (
editor.getPath() === 'select.translating' &&
editor.getSelectedShapes().some((selectedShape) => selectedShape.parentId === shape.id)
)
}
function getDropIndex(editor: Editor, shape: FlexLayoutShape, movingShapes: TLShape[]) {
const movingIds = new Set(movingShapes.map((movingShape) => movingShape.id))
const children = editor
.getSortedChildIdsForParent(shape.id)
.map((id) => editor.getShape(id))
.filter((child): child is TLShape => !!child && !movingIds.has(child.id))
const point = editor.getPointInShapeSpace(shape, editor.inputs.getCurrentPagePoint())
const axis = shape.props.direction === 'horizontal' ? 'x' : 'y'
for (let i = 0; i < children.length; i++) {
const child = children[i]
const bounds = editor.getShapeGeometry(child).bounds
const midpoint = child[axis] + (axis === 'x' ? bounds.width : bounds.height) / 2
if (point[axis] < midpoint) return i
}
return children.length
}
function dropShapesIntoLayout(
editor: Editor,
shape: FlexLayoutShape,
shapes: TLShape[],
dropIndex: number
) {
const shapesToDrop = shapes.filter((movingShape) => {
if (movingShape.id === shape.id) return false
return !editor.hasAncestor(shape, movingShape.id)
})
if (shapesToDrop.length === 0) return
const movingIds = new Set(shapesToDrop.map((movingShape) => movingShape.id))
const remainingChildren = editor
.getSortedChildIdsForParent(shape.id)
.map((id) => editor.getShape(id))
.filter((child): child is TLShape => !!child && !movingIds.has(child.id))
const nextChildren = [...remainingChildren]
nextChildren.splice(dropIndex, 0, ...shapesToDrop)
const indices = getIndicesBetween(undefined, undefined, nextChildren.length)
const desiredSize = getDesiredSize(editor, shape, nextChildren)
const childPositions = getChildPositions(editor, shape, nextChildren, desiredSize)
editor.run(() => {
editor.reparentShapes(shapesToDrop, shape.id)
editor.updateShape<FlexLayoutShape>({
id: shape.id,
type: shape.type,
props: desiredSize,
})
editor.updateShapes(
nextChildren.map((child, index) => ({
id: child.id,
type: child.type,
index: indices[index],
...childPositions[index],
})) as TLShapePartial[]
)
})
}
function relayoutFlexLayoutChildren(editor: Editor, shapeId: TLShapeId) {
const shape = editor.getShape(shapeId) as FlexLayoutShape | undefined
if (!shape) return
const children = getFlexLayoutChildren(editor, shapeId)
if (children.length === 0) return
const childPositions = getChildPositions(editor, shape, children)
editor.updateShapes(
children.map((child, index) => ({
id: child.id,
type: child.type,
...childPositions[index],
})) as TLShapePartial[]
)
}
function resizeFlexLayoutToChildren(editor: Editor, shapeId: TLShapeId) {
const shape = editor.getShape(shapeId) as FlexLayoutShape | undefined
if (!shape) return
const children = getFlexLayoutChildren(editor, shapeId)
const desiredSize = getDesiredSize(editor, shape, children)
if (
Math.abs(shape.props.w - desiredSize.w) < 0.5 &&
Math.abs(shape.props.h - desiredSize.h) < 0.5
) {
return
}
editor.updateShape<FlexLayoutShape>({
id: shape.id,
type: shape.type,
props: desiredSize,
})
}
function getDropIndicator(editor: Editor, shape: FlexLayoutShape, children: TLShape[]) {
if (editor.getPath() !== 'select.translating') return null
const pagePoint = editor.inputs.getCurrentPagePoint()
const point = editor.getPointInShapeSpace(shape, pagePoint)
const isInside = editor.isPointInShape(shape.id, pagePoint, { hitInside: true })
const selectedShapes = editor.getSelectedShapes()
const isDraggingChild = selectedShapes.some(
(selectedShape) => selectedShape.parentId === shape.id
)
const isDraggingOver = editor.getHintingShapeIds().includes(shape.id)
if (isInside && (isDraggingOver || isDraggingChild)) {
return {
type: 'in',
style: getDropInLineStyle(
editor,
shape,
children,
getDropIndex(editor, shape, selectedShapes)
),
} as const
}
if (isDraggingChild) {
return {
type: 'out',
style: getDropOutLineStyle(shape, point),
} as const
}
return null
}
// [8]
export default function FlexLayoutExample() {
return (
<div className="tldraw__editor">
<Tldraw
shapeUtils={shapeUtils}
components={components}
onMount={(editor) => {
const childA = createShapeId('flex-child-a')
const childB = createShapeId('flex-child-b')
const orphanC = createShapeId('flex-orphan-c')
editor.createShapes([
{
id: FLEX_LAYOUT_ID,
type: FLEX_LAYOUT_SHAPE_TYPE,
x: 160,
y: 160,
},
{
id: childA,
type: 'geo',
x: FLEX_CONTAINER_PADDING,
y: FLEX_CONTAINER_PADDING,
parentId: FLEX_LAYOUT_ID,
props: { w: 128, h: 72, geo: 'rectangle', color: 'red', fill: 'solid' },
},
{
id: childB,
type: 'geo',
x: FLEX_CONTAINER_PADDING + 96 + FLEX_CONTAINER_GAP,
y: FLEX_CONTAINER_PADDING,
parentId: FLEX_LAYOUT_ID,
props: { w: 96, h: 96, geo: 'rectangle', color: 'green', fill: 'solid' },
},
{
id: orphanC,
type: 'geo',
x: 480,
y: 480,
props: { w: 80, h: 144, geo: 'rectangle', color: 'blue', fill: 'solid' },
},
])
resizeFlexLayoutToChildren(editor, FLEX_LAYOUT_ID)
editor.select(FLEX_LAYOUT_ID)
}}
/>
</div>
)
}
/*
Introduction:
This example shows a frame-like custom shape that uses CSS flexbox to arrange its children.
[1]
Extend TLGlobalShapePropsMap with `FlexContainerLayoutProps` (direction, align, justify) plus
width and height.
[2]
Define the shape type using TLShape with the shape's type as a type argument.
[3]
Frame-style label geometry and heading component, following the same rotation-aware side logic
as the built-in FrameShapeUtil. `getFlexContainerLabelGeometry` returns a `Rectangle2d` marked
`isLabel` and `excludeFromShapeBounds`, positioned just outside the shape, so clicking the label
selects the layout without the label growing the shape's bounds.
[4]
The shape util. BaseFrameLikeShapeUtil gives us frame-like behavior: clipping children,
reparenting shapes in while they're dragged over the layout, and releasing them when dragged
out. The geometry is an unfilled body plus the label hit area, so clicks on interior padding
pass through to the canvas like a frame.
[4a]
Allow resizing, but clamp width and height to the minimum needed to fit all children, padding,
and gaps. Relayout children when a resize finishes.
[4b]
When a child is dragged out to empty page space, reparent it back to the page (as the base
class would) and shrink the layout around the remaining children.
[4c]
On drop, finalize the child order (from the pointer position) and positions. The base class
has already reparented the dropped shapes during the drag; this override just arranges them.
[4d]
Keep the container's size in sync after the layout itself is moved.
[5]
Render the layout shape. A hidden measurement layer runs real CSS flexbox with a placeholder
div per child at the child's size, then a ResizeObserver reads each placeholder's offset and
writes it back to the child shape's x/y. That's what lets the browser do the layout math while
the store stays the source of truth. The effect is skipped while a child is being dragged so
it doesn't fight the pointer.
[6]
A contextual toolbar shown when a single layout shape is selected, built from
`TldrawUiContextualToolbar` and `TldrawUiToolbarButton`, with direction, justify, and align
controls. Each change re-runs the layout math from `FlexContainerHelpers.tsx` so the children
snap immediately rather than waiting for the ResizeObserver.
[7]
Helpers for computing the drop index from the pointer, reparenting dropped shapes with new
fractional indices, relayout, and the drop-in / drop-out indicator lines.
[8]
Mount the editor with a layout containing two children plus a loose shape to drag in.
Visual styles for the shape live in `flex-layout.css`. Size math, axis positioning, and drop
indicator geometry are in `FlexContainerHelpers.tsx`.
*/
The layout shape extends BaseFrameLikeShapeUtil, so it clips its children and reparents shapes in and out as they're dragged over it, like a frame. What's different is how children get positioned: the shape renders a hidden flex container with a placeholder div per child, and a ResizeObserver reads each placeholder's offset and writes it back to the child's x/y. A contextual toolbar exposes the flex direction, justify-content, and align-items for the selected layout.
The layout grows to fit its children. Dragging a shape over it shows an insertion line and, on drop, onDropShapesOver sets the child order from the pointer position. Dragging a child out to empty page space returns it to the page and shrinks the layout.
Try dragging the loose blue shape into the layout, reordering children by dragging, and switching direction and alignment in the toolbar. For a container that computes layouts itself instead, see the frame layout modes example.