Flex layout shape

A custom frame-like shape that lays out children with CSS flexbox.

import { memo, useLayoutEffect, useRef } from 'react'
import {
	BaseFrameLikeShapeUtil,
	Box,
	Editor,
	Geometry2d,
	Group2d,
	HTMLContainer,
	RecordProps,
	Rectangle2d,
	T,
	TLDragShapesOutInfo,
	TLDropShapesOverInfo,
	TLShape,
	TLShapeId,
	TLShapePartial,
	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>

// [4]
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
) {
	if (process.env.NODE_ENV === 'test') {
		return new Box(0, -opts.height, shape.props.w, opts.height)
	}

	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>
	)
})

// [3]
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} />
			</>
		)
	}

	// [3a]
	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)
	}

	// [3b]
	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)
	}

	// [3c]
	override onDropShapesOver(
		shape: FlexLayoutShape,
		shapes: TLShape[],
		_info: TLDropShapesOverInfo
	) {
		const dropIndex = getDropIndex(this.editor, shape, shapes)
		dropShapesIntoLayout(this.editor, shape, shapes, dropIndex)
	}

	// [3d]
	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"
				data-testid="flex-layout.horizontal"
				data-isactive={shape.props.direction === 'horizontal'}
				onClick={() => updateLayoutProps({ direction: 'horizontal' })}
			>
				<TldrawUiButtonIcon small icon="stack-horizontal" />
			</TldrawUiToolbarButton>
			<TldrawUiToolbarButton
				type="icon"
				title="Vertical"
				data-testid="flex-layout.vertical"
				data-isactive={shape.props.direction === 'vertical'}
				onClick={() => updateLayoutProps({ direction: 'vertical' })}
			>
				<TldrawUiButtonIcon small icon="stack-vertical" />
			</TldrawUiToolbarButton>
			<TldrawUiToolbarButton
				type="icon"
				title={isHorizontal ? 'Align left' : 'Align top'}
				data-testid="flex-layout.justify-start"
				data-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'}
				data-testid="flex-layout.justify-center"
				data-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'}
				data-testid="flex-layout.justify-end"
				data-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'}
				data-testid="flex-layout.justify-space-between"
				data-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'}
				data-testid="flex-layout.align-start"
				data-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'}
				data-testid="flex-layout.align-center"
				data-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'}
				data-testid="flex-layout.align-end"
				data-isactive={shape.props.align === 'end'}
				onClick={() => updateLayoutProps({ align: 'end' })}
			>
				<TldrawUiButtonIcon
					small
					icon={isHorizontal ? 'vertical-align-end' : 'horizontal-align-end'}
				/>
			</TldrawUiToolbarButton>
		</TldrawUiContextualToolbar>
	)
})

// [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={{ InFrontOfTheCanvas: FlexLayoutToolbar }}
				onMount={(editor) => {
					;(window as any).editor = 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]
Create the shape util. BaseFrameLikeShapeUtil gives us frame-like behavior, such as clipping
children and drag-and-drop reparenting.

	[3a]
	Allow resizing, but clamp width and height to the minimum needed to fit all children, padding,
	and gaps. Relayout children when a resize finishes.

	[3b]
	Override drag-out so the layout updates after a child leaves.

	[3c]
	Finalize dropped child order and positions on pointer-up. Frame-like hover behavior handles
	temporary reparenting during the drag.

	[3d]
	Keep container size in sync after the layout itself is translated.

The util composes its geometry from an unfilled body plus a frame-style label hit area, and
renders `FlexContainerHeading` for an uneditable title (select the layout by clicking the label;
clicks on interior padding pass through, like a frame).

[4]
Frame-style label geometry and heading component. The label hit area follows the same rotation-
aware side logic as the built-in FrameShapeUtil. `getFlexContainerLabelGeometry` returns a
Rectangle2d positioned outside the shape bounds so clicking the label selects the layout.

[5]
Render the layout shape. A hidden measurement layer runs real CSS flexbox, then writes measured
child positions back to the store via a ResizeObserver.

[6]
A contextual toolbar shown when the layout shape is selected. Uses TldrawUiContextualToolbar and
TldrawUiToolbarButton to provide direction, justify, and align controls.

[7]
Helpers for reparenting, relayout, and drop indicators.

[8]
Mount the editor with demo shapes and register the toolbar component.

Visual styles for the shape live in `flex-layout.css`. Size math, axis positioning, and drop
indicator geometry are in `FlexContainerHelpers.tsx`.
*/

This example shows a custom shape that can contain child shapes and arrange them with the browser's flexbox layout engine. The only editable layout option is the direction: horizontal or vertical.

The layout shape grows and shrinks to fit its children. Because it extends BaseFrameLikeShapeUtil, dragging a shape over the layout reparents it into the layout while it is hovering, just like a frame. On pointer-up, the example finalizes the child order, resizes the layout, and positions every child according to the current flex direction.

Dragging an existing child out of the layout shows an exit line. If the child is dropped over empty page space, it is reparented back to the page and the layout resizes around its remaining children.

Is this page helpful?
Prev
Editable custom shape
Next
Arrow binding options