import {
	getIndexAbove,
	getIndexBetween,
	getIndices,
	getIndicesAbove,
	getIndicesBetween,
	sortByIndex,
} from '@tldraw/indices'
import {
	Box2d,
	EASINGS,
	MatLike,
	Matrix2d,
	Matrix2dModel,
	PI2,
	Vec2d,
	VecLike,
	approximately,
	areAnglesCompatible,
	clamp,
	intersectPolygonPolygon,
	pointInPolygon,
} from '@tldraw/primitives'
import {
	Box2dModel,
	CameraRecordType,
	InstancePageStateRecordType,
	PageRecordType,
	TLArrowShape,
	TLAsset,
	TLAssetId,
	TLAssetPartial,
	TLColorStyle,
	TLColorType,
	TLCursor,
	TLCursorType,
	TLDOCUMENT_ID,
	TLDocument,
	TLFrameShape,
	TLGroupShape,
	TLImageAsset,
	TLInstance,
	TLInstanceId,
	TLInstancePageState,
	TLNullableShapeProps,
	TLPOINTER_ID,
	TLPage,
	TLPageId,
	TLParentId,
	TLRecord,
	TLScribble,
	TLShape,
	TLShapeId,
	TLShapePartial,
	TLShapeProp,
	TLSizeStyle,
	TLStore,
	TLUnknownShape,
	TLUserDocument,
	TLVideoAsset,
	Vec2dModel,
	createCustomShapeId,
	createShapeId,
	isPageId,
	isShape,
	isShapeId,
} from '@tldraw/tlschema'
import { ComputedCache, HistoryEntry, RecordType, UnknownRecord } from '@tldraw/tlstore'
import {
	annotateError,
	compact,
	dedupe,
	deepCopy,
	partition,
	sortById,
	structuredClone,
} from '@tldraw/utils'
import { EventEmitter } from 'eventemitter3'
import { nanoid } from 'nanoid'
import { EMPTY_ARRAY, atom, computed, transact } from 'signia'
import { ShapeInfo } from '../config/createTLStore'
import { TLUser, createTLUser } from '../config/createTLUser'
import { coreShapes, defaultShapes } from '../config/defaultShapes'
import { defaultTools } from '../config/defaultTools'
import {
	ANIMATION_MEDIUM_MS,
	BLACKLISTED_PROPS,
	COARSE_DRAG_DISTANCE,
	DEFAULT_ANIMATION_OPTIONS,
	DRAG_DISTANCE,
	FOLLOW_CHASE_PAN_SNAP,
	FOLLOW_CHASE_PAN_UNSNAP,
	FOLLOW_CHASE_PROPORTION,
	FOLLOW_CHASE_ZOOM_SNAP,
	FOLLOW_CHASE_ZOOM_UNSNAP,
	GRID_INCREMENT,
	HAND_TOOL_FRICTION,
	MAJOR_NUDGE_FACTOR,
	MAX_PAGES,
	MAX_SHAPES_PER_PAGE,
	MAX_ZOOM,
	MINOR_NUDGE_FACTOR,
	MIN_ZOOM,
	STYLES,
	SVG_PADDING,
	ZOOMS,
} from '../constants'
import { exportPatternSvgDefs } from '../hooks/usePattern'
import { WeakMapCache } from '../utils/WeakMapCache'
import { dataUrlToFile, getMediaAssetFromFile } from '../utils/assets'
import { getIncrementedName, uniqueId } from '../utils/data'
import { setPropsForNextShape } from '../utils/props-for-next-shape'
import { applyRotationToSnapshotShapes, getRotationSnapshot } from '../utils/rotation'
import { arrowBindingsIndex } from './derivations/arrowBindingsIndex'
import { parentsToChildrenWithIndexes } from './derivations/parentsToChildrenWithIndexes'
import { shapeIdsInCurrentPage } from './derivations/shapeIdsInCurrentPage'
import { ActiveAreaManager, getActiveAreaScreenSpace } from './managers/ActiveAreaManager'
import { CameraManager } from './managers/CameraManager'
import { ClickManager } from './managers/ClickManager'
import { DprManager } from './managers/DprManager'
import { HistoryManager } from './managers/HistoryManager'
import { SnapManager } from './managers/SnapManager'
import { TextManager } from './managers/TextManager'
import { TickManager } from './managers/TickManager'
import { UserPreferencesManager } from './managers/UserPreferencesManager'
import { TLArrowUtil } from './shapeutils/TLArrowUtil/TLArrowUtil'
import { getCurvedArrowInfo } from './shapeutils/TLArrowUtil/arrow/curved-arrow'
import {
	getArrowTerminalsInArrowSpace,
	getIsArrowStraight,
} from './shapeutils/TLArrowUtil/arrow/shared'
import { getStraightArrowInfo } from './shapeutils/TLArrowUtil/arrow/straight-arrow'
import { TLFrameUtil } from './shapeutils/TLFrameUtil/TLFrameUtil'
import { TLGroupUtil } from './shapeutils/TLGroupUtil/TLGroupUtil'
import { TLResizeMode, TLShapeUtil } from './shapeutils/TLShapeUtil'
import { TLTextUtil } from './shapeutils/TLTextUtil/TLTextUtil'
import { TLExportColors } from './shapeutils/shared/TLExportColors'
import { RootState } from './statechart/RootState'
import { StateNode, StateNodeConstructor } from './statechart/StateNode'
import { TLClipboardModel } from './types/clipboard-types'
import { TLEventMap } from './types/emit-types'
import { TLEventInfo, TLPinchEventInfo, TLPointerEventInfo } from './types/event-types'
import { RequiredKeys } from './types/misc-types'
import { TLResizeHandle } from './types/selection-types'

/** @public */
export type TLChange<T extends UnknownRecord = any> = HistoryEntry<T>

/** @public */
export type AnimationOptions = Partial<{
	duration: number
	easing: typeof EASINGS.easeInOutCubic
}>

/** @public */
export type ViewportOptions = Partial<{
	/** Whether to animate the viewport change or not. Defaults to true. */
	stopFollowing: boolean
}>

/** @public */
export interface AppOptions {
	/**
	 * The Store instance to use for keeping the app's data. This may be prepopulated, e.g. by loading
	 * from a server or database.
	 */
	store: TLStore
	/**
	 * An array of shapes to use in the app. These will be used to create and manage shapes in the app.
	 */
	shapes?: Record<string, ShapeInfo>
	/**
	 * An array of tools to use in the app. These will be used to handle events and manage user interactions in the app.
	 */
	tools?: StateNodeConstructor[]
	/**
	 * A user defined externally to replace the default user.
	 */
	user?: TLUser
	/**
	 * Should return a containing html element which has all the styles applied to the app. If not
	 * given, the body element will be used.
	 */
	getContainer: () => HTMLElement
}

/** @public */
export function isShapeWithHandles(shape: TLShape) {
	return shape.type === 'arrow' || shape.type === 'line' || shape.type === 'draw'
}

/** @public */
export class App extends EventEmitter<TLEventMap> {
	constructor({
		store,
		user,
		tools = defaultTools,
		shapes = defaultShapes,
		getContainer,
	}: AppOptions) {
		super()

		this.store = store

		this.user = new UserPreferencesManager(user ?? createTLUser())

		this.getContainer = getContainer ?? (() => document.body)

		this.textMeasure = new TextManager(this)

		this.root = new RootState(this)

		// Shapes.
		// Accept shapes from constructor parameters which may not conflict with the root note's core tools.
		const shapeUtils = Object.fromEntries(
			Object.values(coreShapes).map(({ util: Util }) => [Util.type, new Util(this, Util.type)])
		)

		for (const [type, { util: Util }] of Object.entries(shapes)) {
			if (shapeUtils[type]) {
				throw Error(`May not overwrite core shape of type "${type}".`)
			}
			if (type !== Util.type) {
				throw Error(`Shape util's type "${Util.type}" does not match provided type "${type}".`)
			}
			shapeUtils[type] = new Util(this, Util.type)
		}
		this.shapeUtils = shapeUtils

		// Tools.
		// Accept tools from constructor parameters which may not conflict with the root note's default or
		// "baked in" tools, select and zoom.
		const uniqueTools = Object.fromEntries(tools.map((Ctor) => [Ctor.id, Ctor]))
		for (const [id, Ctor] of Object.entries(uniqueTools)) {
			if (this.root.children?.[id]) {
				throw Error(`Can't override tool with id "${id}"`)
			}

			this.root.children![id] = new Ctor(this)
		}

		if (typeof window !== 'undefined' && 'navigator' in window) {
			this.isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
			this.isIos = !!navigator.userAgent.match(/iPad/i) || !!navigator.userAgent.match(/iPhone/i)
			this.isChromeForIos = /crios.*safari/i.test(navigator.userAgent)
		} else {
			this.isSafari = false
			this.isIos = false
			this.isChromeForIos = false
		}

		// Set styles
		this.colors = new Map(App.styles.color.map((c) => [c.id, `var(--palette-${c.id})`]))

		this.store.onBeforeDelete = (record) => {
			if (record.typeName === 'shape') {
				this._shapeWillBeDeleted(record)
			} else if (record.typeName === 'page') {
				this._pageWillBeDeleted(record)
			}
		}

		this.store.onAfterChange = (prev, next) => {
			this._updateDepth++
			if (this._updateDepth > 1000) {
				console.error('[onAfterChange] Maximum update depth exceeded, bailing out.')
			}
			if (prev.typeName === 'shape' && next.typeName === 'shape') {
				this._shapeDidChange(prev, next)
			} else if (
				prev.typeName === 'instance_page_state' &&
				next.typeName === 'instance_page_state'
			) {
				this._tabStateDidChange(prev, next)
			}

			this._updateDepth--
		}
		this.store.onAfterCreate = (record) => {
			if (record.typeName === 'shape' && this.isShapeOfType(record, TLArrowUtil)) {
				this._arrowDidUpdate(record)
			}
		}

		this._shapeIds = shapeIdsInCurrentPage(this.store, () => this.currentPageId)
		this._parentIdsToChildIds = parentsToChildrenWithIndexes(this.store)

		this.disposables.add(
			this.store.listen((changes) => {
				this.emit('change', changes as TLChange)
			})
		)

		const container = this.getContainer()
		const focusin = () => {
			this._isFocused.set(true)
		}
		const focusout = () => {
			this._isFocused.set(false)
		}

		container.addEventListener('focusin', focusin)
		container.addEventListener('focus', focusin)
		container.addEventListener('focusout', focusout)
		container.addEventListener('blur', focusout)

		this.disposables.add(() => {
			container.removeEventListener('focusin', focusin)
			container.removeEventListener('focus', focusin)
			container.removeEventListener('focusout', focusout)
			container.removeEventListener('blur', focusout)
		})

		this.store.ensureStoreIsUsable()

		// clear ephemeral state
		this.setInstancePageState(
			{
				editingId: null,
				hoveredId: null,
				erasingIds: [],
			},
			true
		)

		this.root.enter(undefined, 'initial')

		if (this.instanceState.followingUserId) {
			this.stopFollowingUser()
		}

		this.updateCullingBounds()

		requestAnimationFrame(() => {
			this._tickManager.start()
		})
	}

	/**
	 * The editor's store
	 *
	 * @public
	 */
	readonly store: TLStore

	/**
	 * The root state of the statechart.
	 *
	 * @public
	 */
	readonly root: RootState

	/**
	 * A cache of shape ids in the current page.
	 *
	 * @internal
	 */
	private readonly _shapeIds: ReturnType<typeof shapeIdsInCurrentPage>

	/**
	 * A set of functions to call when the app is disposed.
	 *
	 * @public
	 */
	readonly disposables = new Set<() => void>()

	/** @internal */
	private _dprManager = new DprManager(this)

	/** @internal */
	private _cameraManager = new CameraManager(this)

	/** @internal */
	private _activeAreaManager = new ActiveAreaManager(this)

	/** @internal */
	private _tickManager = new TickManager(this)

	/** @internal */
	private _updateDepth = 0

	/**
	 * A manager for the app's snapping feature.
	 *
	 * @public
	 */
	readonly snaps = new SnapManager(this)

	/**
	 * @internal
	 */
	readonly user: UserPreferencesManager

	/**
	 * Whether the editor is running in Safari.
	 *
	 * @public
	 */
	readonly isSafari: boolean

	/**
	 * Whether the editor is running on iOS.
	 *
	 * @public
	 */
	readonly isIos: boolean

	/**
	 * Whether the editor is running on iOS.
	 *
	 * @public
	 */
	readonly isChromeForIos: boolean

	// Flags

	private _canMoveCamera = atom('can move camera', true)

	/**
	 * Set whether the editor's camera can move.
	 *
	 * @example
	 *
	 * ```ts
	 * app.canMoveCamera = false
	 * ```
	 *
	 * @param canMove - Whether the camera can move.
	 * @public
	 */
	get canMoveCamera() {
		return this._canMoveCamera.value
	}

	set canMoveCamera(canMove: boolean) {
		this._canMoveCamera.set(canMove)
	}

	private _isFocused = atom('_isFocused', false)

	/**
	 * Whether or not the editor is focused.
	 *
	 * @public
	 */
	get isFocused() {
		return this._isFocused.value
	}

	/**
	 * The current HTML element containing the editor.
	 *
	 * @example
	 *
	 * ```ts
	 * const container = app.getContainer()
	 * ```
	 *
	 * @public
	 */
	getContainer: () => HTMLElement

	/**
	 * The editor's instanceId (defined in its store.props).
	 *
	 * @example
	 *
	 * ```ts
	 * const instanceId = app.instanceId
	 * ```
	 *
	 * @public
	 */
	get instanceId(): TLInstanceId {
		return this.store.props.instanceId
	}

	/** @internal */
	annotateError(
		error: unknown,
		{
			origin,
			willCrashApp,
			tags,
			extras,
		}: {
			origin: string
			willCrashApp: boolean
			tags?: Record<string, string | boolean | number>
			extras?: Record<string, unknown>
		}
	) {
		const defaultAnnotations = this.createErrorAnnotations(origin, willCrashApp)
		annotateError(error, {
			tags: { ...defaultAnnotations.tags, ...tags },
			extras: { ...defaultAnnotations.extras, ...extras },
		})
		if (willCrashApp) {
			this.store.markAsPossiblyCorrupted()
		}
	}

	/** @internal */
	createErrorAnnotations(
		origin: string,
		willCrashApp: boolean | 'unknown'
	): {
		tags: { origin: string; willCrashApp: boolean | 'unknown' }
		extras: {
			activeStateNode?: string
			selectedShapes?: TLUnknownShape[]
			editingShape?: TLUnknownShape
			inputs?: Record<string, unknown>
		}
	} {
		try {
			return {
				tags: {
					origin: origin,
					willCrashApp,
				},
				extras: {
					activeStateNode: this.root.path.value,
					selectedShapes: this.selectedShapes,
					editingShape: this.editingId ? this.getShapeById(this.editingId) : undefined,
					inputs: this.inputs,
				},
			}
		} catch {
			return {
				tags: {
					origin: origin,
					willCrashApp,
				},
				extras: {},
			}
		}
	}

	/** @internal */
	private _crashingError: unknown | null = null

	/**
	 * We can't use an `atom` here because there's a chance that when `crashAndReportError` is called,
	 * we're in a transaction that's about to be rolled back due to the same error we're currently
	 * reporting.
	 *
	 * Instead, to listen to changes to this value, you need to listen to app's `crash` event.
	 *
	 * @internal
	 */
	get crashingError() {
		return this._crashingError
	}

	/** @internal */
	crash(error: unknown) {
		this._crashingError = error
		this.store.markAsPossiblyCorrupted()
		this.emit('crash', { error })
	}

	get devicePixelRatio() {
		return this._dprManager.dpr.value
	}

	private _openMenus = atom('open-menus', [] as string[])

	/**
	 * A set of strings representing any open menus. When menus are open,
	 * certain interactions will behave differently; for example, when a
	 * draw tool is selected and a menu is open, a pointer-down will not
	 * create a dot (because the user is probably trying to close the menu)
	 * however a pointer-down event followed by a drag will begin drawing
	 * a line (because the user is BOTH trying to close the menu AND start
	 * drawing a line).
	 *
	 * @public
	 */
	@computed get openMenus(): string[] {
		return this._openMenus.value
	}

	/**
	 * Add an open menu.
	 *
	 * ```ts
	 * app.addOpenMenu('menu-id')
	 * ```
	 * @public
	 */
	addOpenMenu = (id: string) => {
		const menus = new Set(this.openMenus)
		if (!menus.has(id)) {
			menus.add(id)
			this._openMenus.set([...menus])
		}
		return this
	}

	/**
	 * Delete an open menu.
	 *
	 * ```ts
	 * app.deleteOpenMenu('menu-id')
	 * ```
	 * @public
	 */
	deleteOpenMenu = (id: string) => {
		const menus = new Set(this.openMenus)
		if (menus.has(id)) {
			menus.delete(id)
			this._openMenus.set([...menus])
		}
		return this
	}

	/**
	 * Get whether any menus are open.
	 *
	 * @public
	 */
	@computed get isMenuOpen() {
		return this.openMenus.length > 0
	}

	/** @internal */
	private _isCoarsePointer = atom<boolean>('isCoarsePointer', false as any)

	/**
	 * Whether the user is using a "coarse" pointer, such as on a touch screen.
	 *
	 * @public
	 */
	get isCoarsePointer() {
		return this._isCoarsePointer.value
	}

	set isCoarsePointer(v) {
		this._isCoarsePointer.set(v)
	}

	/** @internal */
	private _isChangingStyle = atom<boolean>('isChangingStyle', false as any)

	/** @internal */
	private _isChangingStyleTimeout = -1 as any

	/**
	 * Whether the user is currently changing the style of a shape. This may cause the UI to change.
	 *
	 * @example
	 *
	 * ```ts
	 * app.isChangingStyle = true
	 * ```
	 *
	 * @public
	 */
	get isChangingStyle() {
		return this._isChangingStyle.value
	}

	set isChangingStyle(v) {
		this._isChangingStyle.set(v)
		// Clear any reset timeout
		clearTimeout(this._isChangingStyleTimeout)
		if (v) {
			// If we've set to true, set a new reset timeout to change the value back to false after 2 seonds
			this._isChangingStyleTimeout = setTimeout(() => (this.isChangingStyle = false), 2000)
		}
	}

	/**
	 * A cache of page transforms.
	 *
	 * @internal
	 */
	@computed private get _pageTransformCache(): ComputedCache<Matrix2d, TLShape> {
		return this.store.createComputedCache<Matrix2d, TLShape>('pageTransformCache', (shape) => {
			if (isPageId(shape.parentId)) {
				return this.getTransform(shape)
			}
			// some weird circular type thing here that I had to work wround with (as any)
			const parent = (this._pageTransformCache as any).get(shape.parentId)

			return Matrix2d.Compose(parent, this.getTransform(shape))
		})
	}

	/**
	 * A cache of axis aligned page bounding boxes.
	 *
	 * @internal
	 */
	@computed private get _pageBoundsCache(): ComputedCache<Box2d, TLShape> {
		return this.store.createComputedCache<Box2d, TLShape>('pageBoundsCache', (shape) => {
			const pageTransform = this._pageTransformCache.get(shape.id)

			if (!pageTransform) return new Box2d()

			const result = Box2d.FromPoints(
				Matrix2d.applyToPoints(pageTransform, this.getShapeUtil(shape).outline(shape))
			)

			return result
		})
	}

	/**
	 * A cache of page masks used for clipping.
	 *
	 * @internal
	 */
	@computed private get _pageMaskCache(): ComputedCache<VecLike[], TLShape> {
		return this.store.createComputedCache<VecLike[], TLShape>('pageMaskCache', (shape) => {
			if (isPageId(shape.parentId)) {
				return undefined
			}

			const frameAncestors = this.getAncestorsById(shape.id).filter((s) => s.type === 'frame')

			if (frameAncestors.length === 0) return undefined

			const pageMask = frameAncestors
				.map<VecLike[] | undefined>((s) =>
					// Apply the frame transform to the frame outline to get the frame outline in page space
					Matrix2d.applyToPoints(this._pageTransformCache.get(s.id)!, this.getOutline(s))
				)
				.reduce((acc, b) => (b && acc ? intersectPolygonPolygon(acc, b) ?? undefined : undefined))

			return pageMask
		})
	}

	/**
	 * Get the page mask for a shape.
	 *
	 * @example
	 *
	 * ```ts
	 * const pageMask = app.getPageMaskById(shape.id)
	 * ```
	 *
	 * @param id - The id of the shape to get the page mask for.
	 * @returns The page mask for the shape.
	 * @public
	 */
	getPageMaskById(id: TLShapeId) {
		return this._pageMaskCache.get(id)
	}

	/**
	 * A cache of clip paths used for clipping.
	 *
	 * @internal
	 */
	@computed private get _clipPathCache(): ComputedCache<string, TLShape> {
		return this.store.createComputedCache<string, TLShape>('clipPathCache', (shape) => {
			const pageMask = this._pageMaskCache.get(shape.id)
			if (!pageMask) return undefined
			const pageTransform = this._pageTransformCache.get(shape.id)
			if (!pageTransform) return undefined

			if (pageMask.length === 0) {
				return `polygon(0px 0px, 0px 0px, 0px 0px)`
			}

			const localMask = Matrix2d.applyToPoints(Matrix2d.Inverse(pageTransform), pageMask)

			return `polygon(${localMask.map((p) => `${p.x}px ${p.y}px`).join(',')})`
		})
	}

	/**
	 * Get the clip path for a shape.
	 *
	 * @example
	 *
	 * ```ts
	 * const clipPath = app.getClipPathById(shape.id)
	 * ```
	 *
	 * @param id - The shape id.
	 * @returns The clip path or undefined.
	 * @public
	 */
	getClipPathById(id: TLShapeId) {
		return this._clipPathCache.get(id)
	}

	/**
	 * A cache of parents to children.
	 *
	 * @internal
	 */
	private readonly _parentIdsToChildIds: ReturnType<typeof parentsToChildrenWithIndexes>

	/**
	 * Dispose the app.
	 *
	 * @public
	 */
	dispose() {
		this.disposables.forEach((dispose) => dispose())
		this.disposables.clear()
	}

	/**
	 * A manager for the app's history.
	 *
	 * @readonly
	 */
	readonly history = new HistoryManager(
		this,
		() => this._complete(),
		(error) => {
			this.annotateError(error, { origin: 'history.batch', willCrashApp: true })
			this.crash(error)
		}
	)

	/**
	 * Undo to the last mark.
	 *
	 * @example
	 *
	 * ```ts
	 * app.undo()
	 * ```
	 *
	 * @public
	 */
	undo() {
		return this.history.undo()
	}

	/**
	 * Whether the app can undo.
	 *
	 * @public
	 */
	@computed get canUndo() {
		return this.history.numUndos > 0
	}

	/**
	 * Redo to the next mark.
	 *
	 * @example
	 *
	 * ```ts
	 * app.redo()
	 * ```
	 *
	 * @public
	 */
	redo() {
		this.history.redo()
		return this
	}

	/**
	 * Whether the app can redo.
	 *
	 * @public
	 */
	@computed get canRedo() {
		return this.history.numRedos > 0
	}

	/**
	 * Create a new "mark", or stopping point, in the undo redo history. Creating a mark will clear
	 * any redos.
	 *
	 * @example
	 *
	 * ```ts
	 * app.mark()
	 * app.mark('flip shapes')
	 * ```
	 *
	 * @param reason - The reason for the mark.
	 * @param onUndo - Whether to stop at the mark when undoing.
	 * @param onRedo - Whether to stop at the mark when redoing.
	 * @public
	 */
	mark(reason?: string, onUndo?: boolean, onRedo?: boolean) {
		return this.history.mark(reason, onUndo, onRedo)
	}

	/**
	 * Clear all marks in the undo stack back to the next mark.
	 *
	 * @example
	 *
	 * ```ts
	 * app.bail()
	 * ```
	 *
	 * @public
	 */
	bail() {
		this.history.bail()
		return this
	}

	/**
	 * Clear all marks in the undo stack back to the mark with the provided mark id.
	 *
	 * @example
	 *
	 * ```ts
	 * app.bailToMark('creating')
	 * ```
	 *
	 * @public
	 */
	bailToMark(id: string) {
		this.history.bailToMark(id)
		return this
	}

	/**
	 * Run a function in a batch, which will be undone/redone as a single action.
	 *
	 * @example
	 *
	 * ```ts
	 * app.batch(() => {
	 * 	app.selectAll()
	 * 	app.deleteShapes()
	 * 	app.createShapes(myShapes)
	 * 	app.selectNone()
	 * })
	 *
	 * app.undo() // will undo all of the above
	 * ```
	 *
	 * @public
	 */
	batch(fn: () => void) {
		this.history.batch(fn)
		return this
	}

	/**
	 * A map of shape utility classes (TLShapeUtils) by shape type.
	 *
	 * @public
	 */
	shapeUtils: { readonly [K in string]?: TLShapeUtil<TLUnknownShape> }

	/**
	 * Get a shape util by its definition.
	 *
	 * @example
	 *
	 * ```ts
	 * app.getShapeUtil(TLArrowUtil)
	 * ```
	 *
	 * @param util - The shape util.
	 * @public
	 */
	getShapeUtil<C extends { new (...args: any[]): TLShapeUtil<any>; type: string }>(
		util: C
	): InstanceType<C>
	/**
	 * Get a shape util from a shape itself.
	 *
	 * @example
	 *
	 * ```ts
	 * const util = app.getShapeUtil(myShape)
	 * const util = app.getShapeUtil<TLArrowUtil>(myShape)
	 * const util = app.getShapeUtil(TLArrowUtil)
	 * ```
	 *
	 * @param shape - A shape or shape partial.
	 * @public
	 */
	getShapeUtil<S extends TLUnknownShape>(shape: S | TLShapePartial<S>): TLShapeUtil<S>
	getShapeUtil<T extends TLShapeUtil>({
		type,
	}: {
		type: T extends TLShapeUtil<infer R> ? R['type'] : string
	}): T {
		return this.shapeUtils[type] as T
	}

	/**
	 * A cache of children for each parent.
	 *
	 * @internal
	 */
	private _childIdsCache = new WeakMapCache<any[], TLShapeId[]>()

	/**
	 * Get an array of all the children of a shape.
	 *
	 * @example
	 *
	 * ```ts
	 * app.getSortedChildIds('frame1')
	 * ```
	 *
	 * @param parentId - The id of the parent shape.
	 * @public
	 */
	getSortedChildIds(parentId: TLParentId): TLShapeId[] {
		const withIndices = this._parentIdsToChildIds.value[parentId]
		if (!withIndices) return EMPTY_ARRAY
		return this._childIdsCache.get(withIndices, () => withIndices.map(([id]) => id))
	}

	/**
	 * Run a visitor function for all descendants of a shape.
	 *
	 * @example
	 *
	 * ```ts
	 * app.visitDescendants('frame1', myCallback)
	 * ```
	 *
	 * @param parentId - The id of the parent shape.
	 * @param visitor - The visitor function.
	 * @public
	 */
	visitDescendants(parentId: TLParentId, visitor: (id: TLShapeId) => void | false) {
		const children = this.getSortedChildIds(parentId)
		for (const id of children) {
			if (visitor(id) === false) continue
			this.visitDescendants(id, visitor)
		}
	}

	/**
	 * The editor's current erasing ids.
	 *
	 * @public
	 */
	@computed get erasingIds() {
		return this.pageState.erasingIds
	}

	/**
	 * The editor's current hinting ids.
	 *
	 * @public
	 */
	@computed get hintingIds() {
		return this.pageState.hintingIds
	}

	/**
	 * A derived set containing the current erasing ids.
	 *
	 * @public
	 */
	@computed get erasingIdsSet() {
		// todo: Make incremental derivation, so that this only gets updated when erasingIds changes: we're creating this too often!
		return new Set<TLShapeId>(this.erasingIds)
	}

	/**
	 * Get all the current props among the users selected shapes
	 *
	 * @internal
	 */
	private _extractSharedProps(shape: TLShape, sharedProps: TLNullableShapeProps) {
		if (shape.type === 'group') {
			// For groups, ignore the props of the group shape and instead include
			// the props of the group's children. These are the shapes that would have
			// their props changed if the user called `setProp` on the current selection.
			const childIds = this._parentIdsToChildIds.value[shape.id]
			if (!childIds) return

			for (let i = 0, n = childIds.length; i < n; i++) {
				this._extractSharedProps(this.getShapeById(childIds[i][0])!, sharedProps)
			}
		} else {
			const props = Object.entries(shape.props)
			let prop: [TLShapeProp, any]
			for (let i = 0, n = props.length; i < n; i++) {
				prop = props[i] as [TLShapeProp, any]

				// We should probably white list rather than black list here
				if (BLACKLISTED_PROPS.has(prop[0])) continue

				// Check the value of this prop on the shared props object.
				switch (sharedProps[prop[0]]) {
					case undefined: {
						// If this key hasn't been defined yet in the shared props object,
						// we can set it to the value from the shape's props object.
						sharedProps[prop[0]] = prop[1]
						break
					}
					case null:
					case prop[1]: {
						// If the value in the shared props object matches the value from
						// the shape's props object exactly—or if there is already a mixed
						// value (null) in the shared props object—then this is a noop. We
						// want to leave the value as it is in the shared props object.
						continue
					}
					default: {
						// If there's a value in the shared props object that isn't null AND
						// that isn't undefined AND that doesn't match the shape's props object,
						// then we've got a conflict, mixed props, so set the value to null.
						sharedProps[prop[0]] = null
					}
				}
			}
		}
	}

	/**
	 * A derived object containing all current props among the user's selected shapes.
	 *
	 * @internal
	 */
	private _selectionSharedProps = computed<TLNullableShapeProps>('_selectionSharedProps', () => {
		const { selectedShapes } = this

		const sharedProps = {} as TLNullableShapeProps

		for (let i = 0, n = selectedShapes.length; i < n; i++) {
			this._extractSharedProps(selectedShapes[i], sharedProps)
		}

		return sharedProps as TLNullableShapeProps
	})

	/** @internal */
	private _prevProps: any = {}

	/**
	 * A derived object containing either all current props among the user's selected shapes, or else
	 * the user's most recent prop choices that correspond to the current active state (i.e. the
	 * selected tool).
	 *
	 * @internal
	 */
	@computed get props(): TLNullableShapeProps | null {
		let next: TLNullableShapeProps | null

		// If we're in selecting and if we have a selection,
		// return the shared props from the current selection
		if (this.isIn('select') && this.selectedIds.length > 0) {
			next = this._selectionSharedProps.value
		} else {
			// Otherwise, pull the style props from the app state
			// (the most recent choices made by the user) that are
			// exposed by the current state (i.e. the active tool).
			const currentState = this.root.current.value!
			if (currentState.styles.length === 0) {
				next = null
			} else {
				const { propsForNextShape } = this.instanceState
				next = Object.fromEntries(
					currentState.styles.map((k) => {
						return [k, propsForNextShape[k]]
					})
				)
			}
		}

		// todo: any way to improve this? still faster than rendering the style panel every frame
		if (JSON.stringify(this._prevProps) === JSON.stringify(next)) {
			return this._prevProps
		}

		this._prevProps = next

		return next
	}

	/**
	 * An array of all of the shapes on the current page.
	 *
	 * @public
	 */
	get shapeIds() {
		return this._shapeIds.value
	}

	/**
	 * _invalidParents is used to trigger the 'onChildrenChange' callback that shapes can have.
	 *
	 * @internal
	 */
	private readonly _invalidParents = new Set<TLShapeId>()

	/** @internal */
	private _complete() {
		const { lastUpdatedPageId, lastUsedTabId } = this.userDocumentSettings
		if (lastUsedTabId !== this.instanceId || lastUpdatedPageId !== this.currentPageId) {
			this.store.put([
				{
					...this.userDocumentSettings,
					lastUsedTabId: this.instanceId,
					lastUpdatedPageId: this.currentPageId,
				},
			])
		}

		for (const parentId of this._invalidParents) {
			this._invalidParents.delete(parentId)
			const parent = this.getShapeById(parentId)
			if (!parent) continue

			const util = this.getShapeUtil(parent)
			const changes = util.onChildrenChange?.(parent)

			if (changes?.length) {
				this.updateShapes(changes, true)
			}
		}

		this.emit('update')
	}

	/** @internal */
	@computed
	private get _arrowBindingsIndex() {
		return arrowBindingsIndex(this.store)
	}

	/** GetArrowsBoundTo */
	getArrowsBoundTo(shapeId: TLShapeId) {
		return this._arrowBindingsIndex.value[shapeId] || EMPTY_ARRAY
	}

	/** @internal */
	private _reparentArrow(arrowId: TLShapeId) {
		const arrow = this.getShapeById<TLArrowShape>(arrowId)
		if (!arrow) return
		const { start, end } = arrow.props
		const startShape = start.type === 'binding' ? this.getShapeById(start.boundShapeId) : undefined
		const endShape = end.type === 'binding' ? this.getShapeById(end.boundShapeId) : undefined

		const parentPageId = this.getParentPageId(arrow)
		if (!parentPageId) return

		let nextParentId: TLParentId
		if (startShape && endShape) {
			// if arrow has two bindings, always parent arrow to closest common ancestor of the bindings
			nextParentId = this.findCommonAncestor([startShape, endShape]) ?? parentPageId
		} else if (startShape || endShape) {
			// if arrow has one binding, keep arrow on its own page
			nextParentId = parentPageId
		} else {
			return
		}

		if (nextParentId && nextParentId !== arrow.parentId) {
			this.reparentShapesById([arrowId], nextParentId)
		}

		const reparentedArrow = this.getShapeById<TLArrowShape>(arrowId)
		if (!reparentedArrow) throw Error('no reparented arrow')

		const startSibling = this.getNearestSiblingShape(reparentedArrow, startShape)
		const endSibling = this.getNearestSiblingShape(reparentedArrow, endShape)

		let highestSibling: TLShape | undefined

		if (startSibling && endSibling) {
			highestSibling = startSibling.index > endSibling.index ? startSibling : endSibling
		} else if (startSibling && !endSibling) {
			highestSibling = startSibling
		} else if (endSibling && !startSibling) {
			highestSibling = endSibling
		} else {
			return
		}

		let finalIndex: string

		const higherSiblings = this.getSortedChildIds(highestSibling.parentId)
			.map((id) => this.getShapeById(id)!)
			.filter((sibling) => sibling.index > highestSibling!.index)

		if (higherSiblings.length) {
			// there are siblings above the highest bound sibling, we need to
			// insert between them.

			// if the next sibling is also a bound arrow though, we can end up
			// all fighting for the same indexes. so lets find the next
			// non-arrow sibling...
			const nextHighestNonArrowSibling = higherSiblings.find((sibling) => sibling.type !== 'arrow')

			if (
				// ...then, if we're above the last shape we want to be above...
				reparentedArrow.index > highestSibling.index &&
				// ...but below the next non-arrow sibling...
				(!nextHighestNonArrowSibling || reparentedArrow.index < nextHighestNonArrowSibling.index)
			) {
				// ...then we're already in the right place. no need to update!
				return
			}

			// otherwise, we need to find the index between the highest sibling
			// we want to be above, and the next highest sibling we want to be
			// below:
			finalIndex = getIndexBetween(highestSibling.index, higherSiblings[0].index)
		} else {
			// if there are no siblings above us, we can just get the next index:
			finalIndex = getIndexAbove(highestSibling.index)
		}

		if (finalIndex !== reparentedArrow.index) {
			this.updateShapes([{ id: arrowId, type: 'arrow', index: finalIndex }])
		}
	}

	/** @internal */
	private _unbindArrowTerminal(arrow: TLArrowShape, handleId: 'start' | 'end') {
		const { x, y } = getArrowTerminalsInArrowSpace(this, arrow)[handleId]
		this.store.put([{ ...arrow, props: { ...arrow.props, [handleId]: { type: 'point', x, y } } }])
	}

	// private _shapeWillUpdate = (prev: TLShape, next: TLShape) => {
	// 	const update = this.getShapeUtil(next).onUpdate?.(prev, next)
	// 	return update ?? next
	// }

	@computed
	private get _allPageStates() {
		return this.store.query.records('instance_page_state')
	}

	/** @internal */
	private _shapeWillBeDeleted(deletedShape: TLShape) {
		// if the deleted shape has a parent shape make sure we call it's onChildrenChange callback
		if (deletedShape.parentId && isShapeId(deletedShape.parentId)) {
			this._invalidParents.add(deletedShape.parentId)
		}
		// clean up any arrows bound to this shape
		const bindings = this._arrowBindingsIndex.value[deletedShape.id]
		if (bindings?.length) {
			for (const { arrowId, handleId } of bindings) {
				const arrow = this.getShapeById<TLArrowShape>(arrowId)
				if (!arrow) continue
				this._unbindArrowTerminal(arrow, handleId)
			}
		}
		const pageStates = this._allPageStates.value

		const deletedIds = new Set([deletedShape.id])
		const updates = compact(
			pageStates.map((pageState) => {
				return this._cleanupInstancePageState(pageState, deletedIds)
			})
		)

		if (updates.length) {
			this.store.put(updates)
		}
	}

	/** @internal */
	private _arrowDidUpdate(arrow: TLArrowShape) {
		// if the shape is an arrow and its bound shape is on another page
		// or was deleted, unbind it
		for (const handle of ['start', 'end'] as const) {
			const terminal = arrow.props[handle]
			if (terminal.type !== 'binding') continue
			const boundShape = this.getShapeById(terminal.boundShapeId)
			const isShapeInSamePageAsArrow =
				this.getParentPageId(arrow) === this.getParentPageId(boundShape)
			if (!boundShape || !isShapeInSamePageAsArrow) {
				this._unbindArrowTerminal(arrow, handle)
			}
		}

		// always check the arrow parents
		this._reparentArrow(arrow.id)
	}

	/** @internal */
	private _cleanupInstancePageState(
		prevPageState: TLInstancePageState,
		shapesNoLongerInPage: Set<TLShapeId>
	) {
		let nextPageState = null as null | TLInstancePageState

		const selectedIds = prevPageState.selectedIds.filter((id) => !shapesNoLongerInPage.has(id))
		if (selectedIds.length !== prevPageState.selectedIds.length) {
			if (!nextPageState) nextPageState = { ...prevPageState }
			nextPageState.selectedIds = selectedIds
		}

		const erasingIds = prevPageState.erasingIds.filter((id) => !shapesNoLongerInPage.has(id))
		if (erasingIds.length !== prevPageState.erasingIds.length) {
			if (!nextPageState) nextPageState = { ...prevPageState }
			nextPageState.erasingIds = erasingIds
		}

		if (prevPageState.hoveredId && shapesNoLongerInPage.has(prevPageState.hoveredId)) {
			if (!nextPageState) nextPageState = { ...prevPageState }
			nextPageState.hoveredId = null
		}

		if (prevPageState.editingId && shapesNoLongerInPage.has(prevPageState.editingId)) {
			if (!nextPageState) nextPageState = { ...prevPageState }
			nextPageState.editingId = null
		}

		const hintingIds = prevPageState.hintingIds.filter((id) => !shapesNoLongerInPage.has(id))
		if (hintingIds.length !== prevPageState.hintingIds.length) {
			if (!nextPageState) nextPageState = { ...prevPageState }
			nextPageState.hintingIds = hintingIds
		}

		if (prevPageState.focusLayerId && shapesNoLongerInPage.has(prevPageState.focusLayerId)) {
			if (!nextPageState) nextPageState = { ...prevPageState }
			nextPageState.focusLayerId = null
		}
		return nextPageState
	}

	/** @internal */
	private _shapeDidChange(prev: TLShape, next: TLShape) {
		if (this.isShapeOfType(next, TLArrowUtil)) {
			this._arrowDidUpdate(next)
		}

		// if the shape's parent changed and it is bound to an arrow, update the arrow's parent
		if (prev.parentId !== next.parentId) {
			const reparentBoundArrows = (id: TLShapeId) => {
				const boundArrows = this._arrowBindingsIndex.value[id]
				if (boundArrows?.length) {
					for (const arrow of boundArrows) {
						this._reparentArrow(arrow.arrowId)
					}
				}
			}
			reparentBoundArrows(next.id)
			this.visitDescendants(next.id, reparentBoundArrows)
		}

		// if this shape moved to a new page, clean up any previous page's instance state
		if (prev.parentId !== next.parentId && isPageId(next.parentId)) {
			const allMovingIds = new Set([prev.id])
			this.visitDescendants(prev.id, (id) => {
				allMovingIds.add(id)
			})

			for (const instancePageState of this.store.query.records('instance_page_state').value) {
				if (instancePageState.pageId === next.parentId) continue
				const nextPageState = this._cleanupInstancePageState(instancePageState, allMovingIds)

				if (nextPageState) {
					this.store.put([nextPageState])
				}
			}
		}

		if (prev.parentId && isShapeId(prev.parentId)) {
			this._invalidParents.add(prev.parentId)
		}

		if (next.parentId !== prev.parentId && isShapeId(next.parentId)) {
			this._invalidParents.add(next.parentId)
		}
	}

	/** @internal */
	private _tabStateDidChange(prev: TLInstancePageState, next: TLInstancePageState) {
		if (prev?.selectedIds !== next?.selectedIds) {
			// ensure that descendants and ascenants are not selected at the same time
			const filtered = next.selectedIds.filter((id) => {
				let parentId = this.getShapeById(id)?.parentId
				while (isShapeId(parentId)) {
					if (next.selectedIds.includes(parentId)) {
						return false
					}
					parentId = this.getShapeById(parentId)?.parentId
				}
				return true
			})

			const nextFocusLayerId =
				filtered.length === 0
					? next?.focusLayerId
					: this.findCommonAncestor(
							compact(filtered.map((id) => this.getShapeById(id))),
							(shape) => shape.type === 'group'
					  )

			if (filtered.length !== next.selectedIds.length || nextFocusLayerId != next.focusLayerId) {
				this.store.put([{ ...next, selectedIds: filtered, focusLayerId: nextFocusLayerId ?? null }])
			}
		}
	}

	/** @internal */
	private _pageWillBeDeleted(page: TLPage) {
		// page was deleted, need to check whether it's the current page and select another one if so
		const instanceStates = this.store.query.exec('instance', { currentPageId: { eq: page.id } })

		if (!instanceStates.length) return
		const backupPageId = this.pages.find((p) => p.id !== page.id)?.id

		if (!backupPageId) return

		this.store.put(instanceStates.map((state) => ({ ...state, currentPageId: backupPageId })))
	}

	/* -------------------- Shortcuts ------------------- */

	/** The global document settings that applies to all users */
	@computed get documentSettings() {
		return this.store.get(TLDOCUMENT_ID)!
	}

	/** @internal */
	updateDocumentSettings(settings: Partial<TLDocument>) {
		this.store.put([{ ...this.documentSettings, ...settings }])
	}

	get gridSize() {
		return this.documentSettings.gridSize
	}

	/** @internal */
	get projectName() {
		return this.documentSettings.name
	}

	/** @internal */
	setProjectName(name: string) {
		this.updateDocumentSettings({ name })
	}

	get isSnapMode() {
		return this.userDocumentSettings.isSnapMode
	}

	setSnapMode(isSnapMode: boolean) {
		if (isSnapMode !== this.isSnapMode) {
			this.updateUserDocumentSettings({ isSnapMode }, true)
		}
		return this
	}

	get isDarkMode() {
		return this.user.isDarkMode
	}

	setDarkMode(isDarkMode: boolean) {
		if (isDarkMode !== this.isDarkMode) {
			this.user.updateUserPreferences({ isDarkMode })
		}
		return this
	}

	get animationSpeed() {
		return this.user.animationSpeed
	}

	setAnimationSpeed(animationSpeed: number) {
		if (animationSpeed !== this.animationSpeed) {
			this.user.updateUserPreferences({ animationSpeed })
		}
		return this
	}

	get isFocusMode() {
		return this.instanceState.isFocusMode
	}

	setFocusMode(isFocusMode: boolean) {
		if (isFocusMode !== this.isFocusMode) {
			this.updateInstanceState({ isFocusMode }, true)
		}
		return this
	}

	get isToolLocked() {
		return this.instanceState.isToolLocked
	}

	setToolLocked(isToolLocked: boolean) {
		if (isToolLocked !== this.isToolLocked) {
			this.updateInstanceState({ isToolLocked }, true)
		}
		return this
	}

	/** @internal */
	@computed private get _userDocumentSettings() {
		return this.store.query.record('user_document')
	}

	get userDocumentSettings(): TLUserDocument {
		return this._userDocumentSettings.value!
	}

	get isGridMode() {
		return this.userDocumentSettings.isGridMode
	}

	setGridMode(isGridMode: boolean): this {
		if (isGridMode !== this.isGridMode) {
			this.updateUserDocumentSettings({ isGridMode }, true)
		}
		return this
	}

	private _isReadOnly = atom<boolean>('isReadOnly', false as any)

	/** @internal */
	setReadOnly(isReadOnly: boolean): this {
		this._isReadOnly.set(isReadOnly)
		if (isReadOnly) {
			this.setSelectedTool('hand')
		}
		return this
	}

	get isReadOnly() {
		return this._isReadOnly.value
	}

	/** @internal */
	private _isPenMode = atom<boolean>('isPenMode', false as any)

	/** @internal */
	private _touchEventsRemainingBeforeExitingPenMode = 0

	get isPenMode() {
		return this._isPenMode.value
	}

	setPenMode(isPenMode: boolean): this {
		if (isPenMode) this._touchEventsRemainingBeforeExitingPenMode = 3
		if (isPenMode !== this.isPenMode) {
			this._isPenMode.set(isPenMode)
		}
		return this
	}

	// User / User App State

	/** The current tab state */
	get instanceState(): TLInstance {
		return this.store.get(this.instanceId)!
	}

	get cursor() {
		return this.instanceState.cursor
	}

	get brush() {
		return this.instanceState.brush
	}

	get zoomBrush() {
		return this.instanceState.zoomBrush
	}

	get scribble() {
		return this.instanceState.scribble
	}

	/** @internal */
	@computed private get _pageState() {
		return this.store.query.record(
			'instance_page_state',
			() => {
				return {
					pageId: { eq: this.currentPageId },
					instanceId: { eq: this.instanceId },
				}
			},
			'app._pageState'
		)
	}

	/**
	 * The current page state.
	 *
	 * @public
	 */
	get pageState(): TLInstancePageState {
		return this._pageState.value!
	}

	/** The current camera. */
	@computed get camera() {
		return this.store.get(this.pageState.cameraId)!
	}

	/** The current camera zoom level. */
	@computed get zoomLevel() {
		return this.camera.z
	}

	/**
	 * The current selected ids.
	 *
	 * @public
	 */
	@computed get selectedIds() {
		return this.pageState.selectedIds
	}

	/**
	 * The current selected ids as a set
	 *
	 * @public
	 */
	@computed get selectedIdsSet(): ReadonlySet<TLShapeId> {
		return new Set(this.selectedIds)
	}

	// Pages

	/** @internal */
	@computed private get _pages() {
		return this.store.query.records('page')
	}

	/**
	 * Info about the project's current pages.
	 *
	 * @public
	 * @readonly
	 */
	@computed get pages() {
		return this._pages.value.sort(sortByIndex)
	}

	/**
	 * The current page.
	 *
	 * @public
	 */
	get currentPage() {
		return this.getPageById(this.currentPageId)!
	}

	/**
	 * The current page id.
	 *
	 * @public
	 */
	get currentPageId() {
		return this.instanceState.currentPageId
	}

	/**
	 * Get a page by its ID.
	 *
	 * @example
	 *
	 * ```ts
	 * app.getPageById(myPage.id)
	 * ```
	 *
	 * @public
	 */
	getPageById(id: TLPage['id']) {
		return this.store.get(id)
	}

	/** @internal */
	@computed private get _pageStates() {
		return this.store.query.records('instance_page_state', () => ({
			instanceId: { eq: this.instanceId },
		}))
	}

	/**
	 * Get a page state by its id.
	 *
	 * @example
	 *
	 * ```ts
	 * app.getPageStateByPageId('page1')
	 * ```
	 *
	 * @public
	 */
	getPageStateByPageId(id: TLPageId) {
		return this._pageStates.value.find((p) => p.pageId === id)
	}

	/**
	 * Get a page by its ID.
	 *
	 * @example
	 *
	 * ```ts
	 * app.getPageById(myPage.id)
	 * ```
	 *
	 * @public
	 */
	getPageInfoById(id: TLPage['id']) {
		return this.store.get(id)
	}

	/** Get shapes on a page. */
	getShapeIdsInPage(pageId: TLPageId): Set<TLShapeId> {
		const result = this.store.query.exec('shape', { parentId: { eq: pageId } })
		return this.getShapeAndDescendantIds(result.map((s) => s.id))
	}

	/* --------------------- Shapes --------------------- */

	/**
	 * Get the local transform for a shape as a matrix model. This transform reflects both its
	 * translation (x, y) from from either its parent's top left corner, if the shape's parent is
	 * another shape, or else from the 0,0 of the page, if the shape's parent is the page; and the
	 * shape's rotation.
	 *
	 * @example
	 *
	 * ```ts
	 * app.getTransform(myShape)
	 * ```
	 *
	 * @param shape - The shape to get the local transform for.
	 * @public
	 */
	getTransform(shape: TLShape) {
		const util = this.getShapeUtil(shape)
		return util.transform(shape)
	}

	/**
	 * Get the local transform of a shape's parent as a matrix model.
	 *
	 * @example
	 *
	 * ```ts
	 * app.getParentTransform(myShape)
	 * ```
	 *
	 * @param shape - The shape to get the parent transform for.
	 * @public
	 */
	getParentTransform(shape: TLShape) {
		if (isPageId(shape.parentId)) {
			return Matrix2d.Identity()
		}
		return this._pageTransformCache.get(shape.parentId) ?? Matrix2d.Identity()
	}

	/**
	 * Get the page transform (or absolute transform) of a shape.
	 *
	 * @example
	 *
	 * ```ts
	 * app.getPageTransform(myShape)
	 * ```
	 *
	 * @param shape - The shape to get the page transform for.
	 * @public
	 */
	getPageTransform(shape: TLShape) {
		return this.getPageTransformById(shape.id)
	}

	/**
	 * Get the page transform (or absolute transform) of a shape by its id.
	 *
	 * @example
	 *
	 * ```ts
	 * app.getPageTransformById(myShape)
	 * ```
	 *
	 * @param id - The if of the shape to get the page transform for.
	 * @public
	 */
	getPageTransformById(id: TLShapeId) {
		return this._pageTransformCache.get(id)
	}

	/**
	 * Get the page point (or absolute point) of a shape.
	 *
	 * @example
	 *
	 * ```ts
	 * app.getPagePoint(myShape)
	 * ```
	 *
	 * @param shape - The shape to get the page point for.
	 * @public
	 */
	getPagePointById(id: TLShapeId) {
		const pageTransform = this.getPageTransformById(id)
		if (!pageTransform) return
		return Matrix2d.applyToPoint(pageTransform, new Vec2d())
	}

	/**
	 * Get the page point (or absolute point) of a shape.
	 *
	 * @example
	 *
	 * ```ts
	 * app.getPagePoint(myShape)
	 * ```
	 *
	 * @param shape - The shape to get the page point for.
	 * @public
	 */
	getPageCenter(shape: TLShape) {
		const pageTransform = this.getPageTransformById(shape.id)
		if (!pageTransform) return null
		const util = this.getShapeUtil(shape)
		const center = util.center(shape)
		return Matrix2d.applyToPoint(pageTransform, center)
	}

	/**
	 * Get the page point (or absolute point) of a shape by its id.
	 *
	 * @example
	 *
	 * ```ts
	 * app.getPagePoint(myShape)
	 * ```
	 *
	 * @param id - The shape id to get the page point for.
	 * @public
	 */
	getPageCenterById(id: TLShapeId) {
		const shape = this.getShapeById(id)!
		return this.getPageCenter(shape)
	}

	/**
	 * Get the page rotation (or absolute rotation) of a shape.
	 *
	 * @example
	 *
	 * ```ts
	 * app.getPageRotation(myShape)
	 * ```
	 *
	 * @param shape - The shape to get the page rotation for.
	 * @public
	 */
	getPageRotation(shape: TLShape): number {
		return this.getPageRotationById(shape.id)
	}

	/**
	 * Get the page rotation (or absolute rotation) of a shape by its id.
	 *
	 * @param id - The id of the shape to get the page rotation for.
	 */
	getPageRotationById(id: TLShapeId): number {
		const pageTransform = this.getPageTransformById(id)
		if (pageTransform) {
			return Matrix2d.Decompose(pageTransform).rotation
		}
		return 0
	}

	/**
	 * Get the local bounds of a shape.
	 *
	 * @example
	 *
	 * ```ts
	 * app.getBounds(myShape)
	 * ```
	 *
	 * @param shape - The shape to get the bounds for.
	 * @public
	 */
	getBounds(shape: TLShape): Box2d {
		return this.getShapeUtil(shape).bounds(shape)
	}

	/**
	 * Get the local bounds of a shape by its id.
	 *
	 * @example
	 *
	 * ```ts
	 * app.getBoundsById(myShape)
	 * ```
	 *
	 * @param id - The id of the shape to get the bounds for.
	 * @public
	 */
	getBoundsById(id: TLShapeId): Box2d | undefined {
		const shape = this.getShapeById(id)
		if (!shape) return undefined
		return this.getBounds(shape)
	}

	/**
	 * Get the page (or absolute) bounds of a shape.
	 *
	 * @example
	 *
	 * ```ts
	 * app.getPageBounds(myShape)
	 * ```
	 *
	 * @param shape - The shape to get the bounds for.
	 * @public
	 */
	getPageBounds(shape: TLShape): Box2d | undefined {
		return this.getPageBoundsById(shape.id)
	}

	/**
	 * Get the page (or absolute) bounds of a shape by its id.
	 *
	 * @example
	 *
	 * ```ts
	 * app.getPageBoundsById(myShape)
	 * ```
	 *
	 * @param id - The id of the shape to get the page bounds for.
	 * @public
	 */
	getPageBoundsById(id: TLShapeId): Box2d | undefined {
		return this._pageBoundsCache.get(id)
	}

	/**
	 * Get the page (or absolute) bounds of a shape, incorporating any masks. For example, if the
	 * shape were the child of a frame and was half way out of the frame, the bounds would be the half
	 * of the shape that was in the frame.
	 *
	 * @example
	 *
	 * ```ts
	 * app.getMaskedPageBounds(myShape)
	 * ```
	 *
	 * @param shape - The shape to get the masked bounds for.
	 * @public
	 */
	getMaskedPageBounds(shape: TLShape): Box2d | undefined {
		return this.getMaskedPageBoundsById(shape.id)
	}

	/**
	 * Get the page (or absolute) bounds of a shape by its id, incorporating any masks. For example,
	 * if the shape were the child of a frame and was half way out of the frame, the bounds would be
	 * the half of the shape that was in the frame.
	 *
	 * @example
	 *
	 * ```ts
	 * app.getMaskedPageBoundsById(myShape)
	 * ```
	 *
	 * @param id - The id of the shape to get the masked page bounds for.
	 * @public
	 */
	getMaskedPageBoundsById(id: TLShapeId): Box2d | undefined {
		const pageBounds = this._pageBoundsCache.get(id)
		if (!pageBounds) return
		const pageMask = this._pageMaskCache.get(id)
		if (pageMask) {
			const intersection = intersectPolygonPolygon(pageMask, pageBounds.corners)
			if (!intersection) return
			return Box2d.FromPoints(intersection)
		}
		return pageBounds
	}

	/**
	 * Get the local outline of a shape.
	 *
	 * @example
	 *
	 * ```ts
	 * app.getOutline(myShape)
	 * ```
	 *
	 * @param shape - The shape to get the outline for.
	 * @public
	 */
	getOutline(shape: TLShape) {
		return this.getShapeUtil(shape).outline(shape)
	}

	/**
	 * Get the local outline of a shape.
	 *
	 * @example
	 *
	 * ```ts
	 * app.getOutlineById(myShape)
	 * ```
	 *
	 * @param id - The shape id to get the outline for.
	 * @public
	 */
	getOutlineById(id: TLShapeId) {
		return this.getOutline(this.getShapeById(id)!)
	}

	/**
	 * Get the ancestors of a shape.
	 *
	 * @example
	 *
	 * ```ts
	 * const ancestors = app.getAncestors(myShape)
	 * ```
	 *
	 * @param shape - The shape to get the ancestors for.
	 * @public
	 */
	getAncestors(shape: TLShape, acc: TLShape[] = []): TLShape[] {
		const parentId = shape.parentId
		if (isPageId(parentId)) {
			acc.reverse()
			return acc
		}

		const parent = this.store.get(parentId)!
		acc.push(parent)
		return this.getAncestors(parent, acc)
	}

	/**
	 * Get the ancestors of a shape by its id.
	 *
	 * @example
	 *
	 * ```ts
	 * const ancestors = app.getAncestorsById(myShape)
	 * ```
	 *
	 * @param id - The id of the shape to get the ancestors for.
	 * @public
	 */
	getAncestorsById(id: TLShapeId, acc: TLShape[] = []): TLShape[] {
		const shape = this.getShapeById(id)!
		return this.getAncestors(shape, acc)
	}

	/**
	 * Find the first ancestor matching the given predicate
	 *
	 * @example
	 *
	 * ```ts
	 * const ancestor = app.findAncestor(myShape)
	 * ```
	 *
	 * @param shape - The shape to check the ancestors for.
	 * @public
	 */
	findAncestor(shape: TLShape, predicate: (parent: TLShape) => boolean): TLShape | undefined {
		const parentId = shape.parentId

		if (isPageId(parentId)) {
			return undefined
		}

		const parent = this.getShapeById(parentId)

		if (parent) {
			if (predicate(parent)) {
				return parent
			}
			return this.findAncestor(parent, predicate)
		}

		return undefined
	}

	/** Returns true if the the given shape has the given ancestor */
	hasAncestor(shape: TLShape | undefined, ancestorId: TLShapeId): boolean {
		if (!shape) return false
		if (shape.parentId === ancestorId) return true
		return this.hasAncestor(this.getParentShape(shape), ancestorId)
	}

	/**
	 * Get the common ancestor of two or more shapes that matches a predicate.
	 *
	 * @param shapes - The shapes to check.
	 * @param predicate - The predicate to match.
	 */
	findCommonAncestor(
		shapes: TLShape[],
		predicate?: (shape: TLShape) => boolean
	): TLShapeId | undefined {
		if (shapes.length === 0) {
			return
		}
		if (shapes.length === 1) {
			const parentId = shapes[0].parentId
			if (isPageId(parentId)) {
				return
			}
			return predicate ? this.findAncestor(shapes[0], predicate)?.id : parentId
		}

		const [nodeA, ...others] = shapes
		let ancestor = this.getParentShape(nodeA)
		while (ancestor) {
			// TODO: this is not ideal, optimize
			if (predicate && !predicate(ancestor)) {
				ancestor = this.getParentShape(ancestor)
				continue
			}
			if (others.every((shape) => this.hasAncestor(shape, ancestor!.id))) {
				return ancestor!.id
			}
			ancestor = this.getParentShape(ancestor)
		}
		return undefined
	}

	/**
	 * Check whether a shape is within the bounds of the current viewport.
	 *
	 * @param id - The id of the shape to check.
	 * @public
	 */
	isShapeInViewport(id: TLShapeId) {
		const pageBounds = this.getPageBoundsById(id)
		if (!pageBounds) return false
		return this.viewportPageBounds.includes(pageBounds)
	}

	/**
	 * Check whether a shape or its parent is locked.
	 *
	 * @param id - The id of the shape to check.
	 * @public
	 */
	isShapeOrAncestorLocked(shape?: TLShape): boolean {
		if (shape === undefined) return false
		if (shape.isLocked) return true
		return this.isShapeOrAncestorLocked(this.getParentShape(shape))
	}

	private computeUnorderedRenderingShapes(
		ids: TLParentId[],
		{
			cullingBounds,
			cullingBoundsExpanded,
			erasingIdsSet,
			editingId,
		}: {
			cullingBounds?: Box2d
			cullingBoundsExpanded?: Box2d
			erasingIdsSet?: Set<TLShapeId>
			editingId?: TLShapeId | null
		} = {}
	) {
		// Here we get the shape as well as any of its children, as well as their
		// opacities. If the shape is being erased, and none of its ancestors are
		// being erased, then we reduce the opacity of the shape and all of its
		// ancestors; but we don't apply this effect more than once among a set
		// of descendants so that it does not compound.

		// This is designed to keep all the shapes in a single list which
		// allows the DOM nodes to be reused even when they become children
		// of other nodes.

		const renderingShapes: {
			id: TLShapeId
			index: number
			backgroundIndex: number
			opacity: number
			isCulled: boolean
			isInViewport: boolean
			maskedPageBounds: Box2d | undefined
		}[] = []

		let nextIndex = MAX_SHAPES_PER_PAGE
		let nextBackgroundIndex = 0

		const addShapeById = (id: TLParentId, parentOpacity: number, isAncestorErasing: boolean) => {
			if (PageRecordType.isId(id)) {
				for (const childId of this.getSortedChildIds(id)) {
					addShapeById(childId, parentOpacity, isAncestorErasing)
				}
				return
			}

			const shape = this.getShapeById(id)
			if (!shape) return

			// todo: move opacity to a property of shape, rather than a property of props
			let opacity = (+(shape.props as { opacity: string }).opacity ?? 1) * parentOpacity
			let isShapeErasing = false

			if (!isAncestorErasing && erasingIdsSet?.has(id)) {
				isShapeErasing = true
				opacity *= 0.32
			}

			// If a child is outside of its parent's clipping bounds, then bounds will be undefined.
			const maskedPageBounds = this.getMaskedPageBoundsById(id)

			// Whether the shape is on screen. Use the "strict" viewport here.
			const isInViewport = maskedPageBounds
				? cullingBounds?.includes(maskedPageBounds) ?? true
				: false

			// Whether the shape should actually be culled / unmounted.
			// - Use the "expanded" culling viewport to include shapes that are just off-screen.
			// - Editing shapes should never be culled.
			const isCulled = maskedPageBounds
				? (editingId !== id && !cullingBoundsExpanded?.includes(maskedPageBounds)) ?? true
				: true

			renderingShapes.push({
				id,
				index: nextIndex,
				backgroundIndex: nextBackgroundIndex,
				opacity,
				isCulled,
				isInViewport,
				maskedPageBounds,
			})

			nextIndex += 1
			nextBackgroundIndex += 1

			const childIds = this.getSortedChildIds(id)
			if (!childIds.length) return

			let backgroundIndexToRestore = null
			if (this.getShapeUtil(shape).providesBackgroundForChildren(shape)) {
				backgroundIndexToRestore = nextBackgroundIndex
				nextBackgroundIndex = nextIndex
				nextIndex += MAX_SHAPES_PER_PAGE
			}

			for (const childId of childIds) {
				addShapeById(childId, opacity, isAncestorErasing || isShapeErasing)
			}

			if (backgroundIndexToRestore !== null) {
				nextBackgroundIndex = backgroundIndexToRestore
			}
		}

		for (const id of ids) {
			addShapeById(id, 1, false)
		}

		return renderingShapes
	}

	/**
	 * Get the shapes that should be displayed in the current viewport.
	 *
	 * @public
	 */
	@computed get renderingShapes() {
		const renderingShapes = this.computeUnorderedRenderingShapes([this.currentPageId], {
			cullingBounds: this.cullingBounds,
			cullingBoundsExpanded: this.cullingBoundsExpanded,
			erasingIdsSet: this.erasingIdsSet,
			editingId: this.editingId,
		})

		// Its IMPORTANT that the result be sorted by id AND include the index
		// that the shape should be displayed at. Steve, this is the past you
		// telling the present you not to change this.

		// We want to sort by id because moving elements about in the DOM will
		// cause the element to get removed by react as it moves the DOM node. This
		// causes <iframes/> to re-render which is hella annoying and a perf
		// drain. By always sorting by 'id' we keep the shapes always in the
		// same order; but we later use index to set the element's 'z-index'
		// to change the "rendered" position in z-space.
		return renderingShapes.sort(sortById)
	}

	/**
	 * The common bounds of all of the shapes on the page.
	 *
	 * @public
	 */
	@computed get allShapesCommonBounds(): Box2d | null {
		let commonBounds = null as Box2d | null

		this.shapeIds.forEach((shapeId) => {
			const bounds = this.getMaskedPageBoundsById(shapeId)
			if (bounds) {
				if (commonBounds) {
					commonBounds.expand(bounds)
				} else {
					commonBounds = bounds.clone()
				}
			}
		})

		return commonBounds
	}

	/**
	 * Get the corners of a shape in page space.
	 *
	 * @example
	 *
	 * ```ts
	 * const corners = app.getPageCorners(myShape)
	 * ```
	 *
	 * @param shape - The shape to get the corners for.
	 * @public
	 */
	getPageCorners(shape: TLShape): Vec2d[] {
		const ancestors = this.getAncestors(shape)
		const corners = this.getBounds(shape).corners

		const transform = Matrix2d.Compose(
			...ancestors.flatMap((s) => [Matrix2d.Translate(s.x, s.y), Matrix2d.Rotate(s.rotation)]),
			Matrix2d.Translate(shape.x, shape.y),
			Matrix2d.Rotate(shape.rotation, 0, 0)
		)

		return Matrix2d.applyToPoints(transform, corners)
	}

	/**
	 * Test whether a point (in page space) will will a shape. This method takes into account masks,
	 * such as when a shape is the child of a frame and is partially clipped by the frame.
	 *
	 * @example
	 *
	 * ```ts
	 * app.isPointInShape({ x: 100, y: 100 }, myShape)
	 * ```
	 *
	 * @param point - The page point to test.
	 * @param shape - The shape to test against.
	 * @public
	 */
	isPointInShape(point: VecLike, shape: TLShape): boolean {
		const util = this.getShapeUtil(shape)

		const pageMask = this._pageMaskCache.get(shape.id)

		if (pageMask) {
			const hit = pointInPolygon(point, pageMask)
			if (!hit) return false
		}

		return util.hitTestPoint(shape, this.getPointInShapeSpace(shape, point))
	}

	/**
	 * Get the shapes, if any, at a given page point.
	 *
	 * @example
	 *
	 * ```ts
	 * app.getShapesAtPoint({ x: 100, y: 100 })
	 * ```
	 *
	 * @param point - The page point to test.
	 * @public
	 */
	getShapesAtPoint(point: VecLike): TLShape[] {
		return this.shapesArray.filter((shape) => {
			// Check the page mask too
			const pageMask = this._pageMaskCache.get(shape.id)
			if (pageMask) {
				return pointInPolygon(point, pageMask)
			}

			// Otherwise, use the shape's own hit test method
			return this.getShapeUtil(shape).hitTestPoint(shape, this.getPointInShapeSpace(shape, point))
		})
	}

	/**
	 * Convert a point in page space to a point in the local space of a shape. For example, if a
	 * shape's page point were `{ x: 100, y: 100 }`, a page point at `{ x: 110, y: 110 }` would be at
	 * `{ x: 10, y: 10 }` in the shape's local space.
	 *
	 * @example
	 *
	 * ```ts
	 * app.getPointInShapeSpace(myShape, { x: 100, y: 100 })
	 * ```
	 *
	 * @param shape - The shape to get the point in the local space of.
	 * @param point - The page point to get in the local space of the shape.
	 * @public
	 */
	getPointInShapeSpace(shape: TLShape, point: VecLike): Vec2d {
		return Matrix2d.applyToPoint(Matrix2d.Inverse(this.getPageTransform(shape)!), point)
	}

	/**
	 * Convert a delta in page space to a point in the local space of a shape. For example, if a
	 * shape's page point were `{ x: 100, y: 100 }`, a page point at `{ x: 110, y: 110 }` would be at
	 * `{ x: 10, y: 10 }` in the shape's local space.
	 *
	 * @example
	 *
	 * ```ts
	 * app.getPointInShapeSpace(myShape.id, { x: 100, y: 100 })
	 * ```
	 *
	 * @param shape - The shape to get the point in the local space of.
	 * @param point - The page point to get in the local space of the shape.
	 * @public
	 */
	getPointInParentSpace(shapeId: TLShapeId, point: VecLike): Vec2d {
		const shape = this.getShapeById(shapeId)!
		if (!shape) {
			return new Vec2d(0, 0)
		}
		if (isPageId(shape.parentId)) return Vec2d.From(point)

		const parentTransform = this.getPageTransformById(shape.parentId)
		if (!parentTransform) return Vec2d.From(point)

		return Matrix2d.applyToPoint(Matrix2d.Inverse(parentTransform), point)
	}

	/**
	 * Convert a delta in page space to a delta in the local space of a shape.
	 *
	 * @example
	 *
	 * ```ts
	 * app.getDeltaInShapeSpace(myShape, { x: 100, y: 100 })
	 * ```
	 *
	 * @param shape - The shape to get the delta in the local space of.
	 * @param delta - The page delta to convert.
	 * @public
	 */
	getDeltaInShapeSpace(shape: TLShape, delta: VecLike): Vec2d {
		const pageTransform = this.getPageTransform(shape)
		if (!pageTransform) return Vec2d.From(delta)
		return Vec2d.Rot(delta, -Matrix2d.Decompose(pageTransform).rotation)
	}

	/**
	 * Convert a delta in page space to a delta in the parent space of a shape.
	 *
	 * @example
	 *
	 * ```ts
	 * app.getDeltaInParentSpace(myShape, { x: 100, y: 100 })
	 * ```
	 *
	 * @param shape - The shape to get the delta in the parent space of.
	 * @param delta - The page delta to convert.
	 * @public
	 */
	getDeltaInParentSpace(shape: TLShape, delta: VecLike): Vec2d {
		if (isPageId(shape.parentId)) return Vec2d.From(delta)

		const parent = this.getShapeById(shape.parentId)
		if (!parent) return Vec2d.From(delta)

		return this.getDeltaInShapeSpace(parent, delta)
	}

	/**
	 * For a given set of ids, get a map containing the ids of their parents and the children of those
	 * parents.
	 *
	 * @example
	 *
	 * ```ts
	 * app.getParentsMappedToChildren(['id1', 'id2', 'id3'])
	 * ```
	 *
	 * @param ids - The ids to get the parents and children of.
	 * @public
	 */
	getParentsMappedToChildren(ids: TLShapeId[]) {
		const shapes = ids.map((id) => this.store.get(id)!)
		const parents = new Map<TLParentId, Set<TLShape>>()
		shapes.forEach((shape) => {
			if (!parents.has(shape.parentId)) {
				parents.set(shape.parentId, new Set())
			}
			parents.get(shape.parentId)?.add(shape)
		})
		return parents
	}

	/* -------------------- Viewport -------------------- */

	/**
	 * Update the viewport. The viewport will measure the size and screen position of its container
	 * element. This should be done whenever the container's position on the screen changes.
	 *
	 * @example
	 *
	 * ```ts
	 * app.updateViewportScreenBounds()
	 * ```
	 *
	 * @param center - Whether to preserve the viewport page center as the viewport changes.
	 *   (optional)
	 * @public
	 */
	updateViewportScreenBounds(center = false) {
		const container = this.getContainer()

		if (!container) return this
		const rect = container.getBoundingClientRect()
		const screenBounds = new Box2d(0, 0, Math.max(rect.width, 1), Math.max(rect.height, 1))

		const boundsAreEqual = screenBounds.equals(this.viewportScreenBounds)

		// Get the current value
		const { _willSetInitialBounds } = this

		if (boundsAreEqual) {
			this._willSetInitialBounds = false
		} else {
			if (_willSetInitialBounds) {
				// If we have just received the initial bounds, don't center the camera.
				this._willSetInitialBounds = false
				this.updateInstanceState({ screenBounds: screenBounds.toJson() }, true, true)
			} else {
				const { zoomLevel } = this
				if (center) {
					const before = this.viewportPageCenter
					this.updateInstanceState({ screenBounds: screenBounds.toJson() }, true, true)
					const after = this.viewportPageCenter
					if (!this.instanceState.followingUserId) {
						this.pan((after.x - before.x) * zoomLevel, (after.y - before.y) * zoomLevel)
					}
				} else {
					const before = this.screenToPage(0, 0)
					this.updateInstanceState({ screenBounds: screenBounds.toJson() }, true, true)
					const after = this.screenToPage(0, 0)
					if (!this.instanceState.followingUserId) {
						this.pan((after.x - before.x) * zoomLevel, (after.y - before.y) * zoomLevel)
					}
				}
			}
		}

		this._cameraManager.tick()
		this.updateCullingBounds()

		const { editingId } = this

		if (editingId) {
			this.panZoomIntoView([editingId])
		}

		return this
	}

	/**
	 * The bounds of the editor's viewport in screen space.
	 *
	 * @public
	 */
	@computed get viewportScreenBounds() {
		const { x, y, w, h } = this.instanceState.screenBounds
		return new Box2d(x, y, w, h)
	}

	/**
	 * The center of the editor's viewport in screen space.
	 *
	 * @public
	 */
	@computed get viewportScreenCenter() {
		return this.viewportScreenBounds.center
	}

	/**
	 * The current viewport in page space.
	 *
	 * @public
	 */
	@computed get viewportPageBounds() {
		const { x, y, w, h } = this.viewportScreenBounds
		const tl = this.screenToPage(x, y)
		const br = this.screenToPage(x + w, y + h)
		return new Box2d(tl.x, tl.y, br.x - tl.x, br.y - tl.y)
	}

	/**
	 * The current culling bounds in page space, used for checking which shapes are "on screen".
	 *
	 * @public
	 */
	@computed get cullingBounds() {
		return this._cullingBounds.value
	}

	/** @internal */
	readonly _cullingBounds = atom('culling viewport', new Box2d())

	/**
	 * The current culling bounds in page space, expanded slightly. Used for determining which shapes
	 * to render and which to "cull".
	 *
	 * @public
	 */
	@computed get cullingBoundsExpanded() {
		return this._cullingBoundsExpanded.value
	}

	/** @internal */
	readonly _cullingBoundsExpanded = atom('culling viewport expanded', new Box2d())

	/**
	 * Update the culling bounds. This should be called when the viewport has stopped changing, such
	 * as at the end of a pan, zoom, or animation.
	 *
	 * @example
	 *
	 * ```ts
	 * app.updateCullingBounds()
	 * ```
	 *
	 * @internal
	 */
	updateCullingBounds(): this {
		const { viewportPageBounds } = this
		if (viewportPageBounds.equals(this._cullingBounds.__unsafe__getWithoutCapture())) return this
		this._cullingBounds.set(viewportPageBounds.clone())
		this._cullingBoundsExpanded.set(viewportPageBounds.clone().expandBy(100 / this.zoomLevel))
		return this
	}

	/**
	 * The center of the viewport in page space.
	 *
	 * @public
	 */
	@computed get viewportPageCenter() {
		return this.viewportPageBounds.center
	}

	/**
	 * Convert a point in screen space to a point in page space.
	 *
	 * @example
	 *
	 * ```ts
	 * app.screenToPage(100, 100)
	 * ```
	 *
	 * @param x - The x coordinate of the point in screen space.
	 * @param y - The y coordinate of the point in screen space.
	 * @param camera - The camera to use. Defaults to the current camera.
	 * @public
	 */
	screenToPage(x: number, y: number, z = 0.5, camera: Vec2dModel = this.camera) {
		const { screenBounds } = this.store.unsafeGetWithoutCapture(this.instanceId)!
		const { x: cx, y: cy, z: cz = 1 } = camera
		return {
			x: (x - screenBounds.x) / cz - cx,
			y: (y - screenBounds.y) / cz - cy,
			z,
		}
	}

	/**
	 * Convert a point in page space to a point in screen space.
	 *
	 * @example
	 *
	 * ```ts
	 * app.pageToScreen(100, 100)
	 * ```
	 *
	 * @param x - The x coordinate of the point in screen space.
	 * @param y - The y coordinate of the point in screen space.
	 * @param camera - The camera to use. Defaults to the current camera.
	 * @public
	 */
	pageToScreen(x: number, y: number, z = 0.5, camera: Vec2dModel = this.camera) {
		const { x: cx, y: cy, z: cz = 1 } = camera
		return {
			x: x + cx * cz,
			y: y + cy * cz,
			z,
		}
	}

	/* Focus Layers */

	get focusLayerId() {
		return this.pageState.focusLayerId ?? this.currentPageId
	}

	get focusLayerShape(): TLShape | undefined {
		const id = this.pageState.focusLayerId
		if (!id) {
			return
		}
		return this.getShapeById(id)
	}

	popFocusLayer() {
		const current = this.pageState.focusLayerId
		const focusedShape = current && this.getShapeById(current)

		if (focusedShape) {
			// If we have a focused layer, look for an ancestor of the focused shape that is a group
			const match = this.findAncestor(focusedShape, (s) => s.type === 'group')
			// If we have an ancestor that can become a focused layer, set it as the focused layer
			this.setFocusLayer(match?.id ?? null)
			this.select(focusedShape.id)
		} else {
			// If there's no focused shape, then clear the focus layer and clear selection
			this.setFocusLayer(null)
			this.selectNone()
		}

		return this
	}

	/**
	 * Set the focus layer to the given shape id.
	 *
	 * @param next - The next focus layer id or null to reset the focus layer to the page
	 * @public
	 */
	setFocusLayer(next: null | TLShapeId) {
		this._setFocusLayer(next)
		return this
	}

	/** @internal */
	private _setFocusLayer = this.history.createCommand(
		'setFocusLayer',
		(next: null | TLShapeId) => {
			// When we first click an empty canvas we don't want this to show up in the undo stack
			if (next === null && !this.canUndo) {
				return
			}
			const prev = this.pageState.focusLayerId
			return { data: { prev, next }, preservesRedoStack: true, squashing: true }
		},
		{
			do: ({ next }) => {
				this.store.update(this.pageState.id, (s) => ({ ...s, focusLayerId: next }))
			},
			undo: ({ prev }) => {
				this.store.update(this.pageState.id, (s) => ({ ...s, focusLayerId: prev }))
			},
			squash({ prev }, { next }) {
				return { prev, next }
			},
		}
	)

	/**
	 * Set the hinted shape ids.
	 *
	 * @param ids - The ids to set as hinted.
	 * @public
	 */
	setHintingIds(ids: TLShapeId[]): this {
		// always ephemeral
		this.store.update(this.pageState.id, (s) => ({ ...s, hintingIds: dedupe(ids) }))
		return this
	}

	/**
	 * The current editing shape's id.
	 *
	 * @public
	 */
	get editingId() {
		return this.pageState.editingId
	}

	/**
	 * The current cropping shape's id.
	 *
	 * @public
	 */
	get croppingId() {
		return this.pageState.croppingId
	}

	@computed get editingShape() {
		if (!this.editingId) return null
		return this.getShapeById(this.editingId) ?? null
	}

	/**
	 * Set the current editing id.
	 *
	 * @param id - The id of the shape to edit or null to clear the editing id.
	 * @public
	 */
	setEditingId(id: TLShapeId | null): this {
		if (!id) {
			this.setInstancePageState({ editingId: null })
		} else {
			if (id !== this.editingId) {
				const shape = this.getShapeById(id)!
				const util = this.getShapeUtil(shape)
				if (shape && util.canEdit(shape)) {
					this.setInstancePageState({ editingId: id, hoveredId: null }, false)
					const { viewportPageBounds } = this
					const localEditingBounds = util.getEditingBounds(shape)!
					const pageTransform = this.getPageTransformById(id)!
					const pageEditingBounds = Box2d.FromPoints(
						Matrix2d.applyToPoints(pageTransform, localEditingBounds.corners)
					)

					if (!viewportPageBounds.contains(pageEditingBounds)) {
						if (
							pageEditingBounds.width > viewportPageBounds.width ||
							pageEditingBounds.height > viewportPageBounds.height
						) {
							this.zoomToBounds(
								pageEditingBounds.minX,
								pageEditingBounds.minY,
								pageEditingBounds.width,
								pageEditingBounds.height
							)
						} else {
							this.centerOnPoint(pageEditingBounds.midX, pageEditingBounds.midY)
						}
					}
				}
			}
		}

		return this
	}

	setCroppingId(id: TLShapeId | null): this {
		if (id !== this.croppingId) {
			if (!id) {
				this.setInstancePageState({ croppingId: null })
				if (this.isInAny('select.crop', 'select.pointing_crop_handle', 'select.cropping')) {
					this.setSelectedTool('select.idle')
				}
			} else {
				const shape = this.getShapeById(id)!
				const util = this.getShapeUtil(shape)
				if (shape && util.canCrop(shape)) {
					this.setInstancePageState({ croppingId: id, hoveredId: null })
				}
			}
		}
		return this
	}

	getParentIdForNewShapeAtPoint(point: VecLike, shapeType: TLShape['type']) {
		const shapes = this.sortedShapesArray

		for (let i = shapes.length - 1; i >= 0; i--) {
			const shape = shapes[i]
			const util = this.getShapeUtil(shape)
			if (!util.canReceiveNewChildrenOfType(shape, shapeType)) continue
			const maskedPageBounds = this.getMaskedPageBoundsById(shape.id)
			if (
				maskedPageBounds &&
				maskedPageBounds.containsPoint(point) &&
				util.hitTestPoint(shape, this.getPointInShapeSpace(shape, point))
			) {
				return shape.id
			}
		}

		return this.focusLayerId
	}

	getDroppingShape(point: VecLike, droppingShapes: TLShape[] = []) {
		const shapes = this.sortedShapesArray

		for (let i = shapes.length - 1; i >= 0; i--) {
			const shape = shapes[i]
			// don't allow dropping a shape on itself or one of it's children
			if (droppingShapes.find((s) => s.id === shape.id || this.hasAncestor(shape, s.id))) continue
			const util = this.getShapeUtil(shape)
			if (!util.canDropShapes(shape, droppingShapes)) continue
			const maskedPageBounds = this.getMaskedPageBoundsById(shape.id)
			if (
				maskedPageBounds &&
				maskedPageBounds.containsPoint(point) &&
				util.hitTestPoint(shape, this.getPointInShapeSpace(shape, point))
			) {
				return shape
			}
		}

		return undefined
	}

	// This returns the node that should be selected when you click on this one, assuming there is nothing
	// already selected. It will not return anything higher than or including the current focus layer.
	getOutermostSelectableShape(shape: TLShape, filter?: (shape: TLShape) => boolean): TLShape {
		let match = shape
		let node = shape as TLShape | undefined
		while (node) {
			if (
				node.type === 'group' &&
				this.focusLayerId !== node.id &&
				!this.hasAncestor(this.focusLayerShape, node.id) &&
				(filter?.(node) ?? true)
			) {
				match = node
			} else if (this.focusLayerId === node.id) {
				break
			}
			node = this.getParentShape(node)
		}

		return match
	}

	/* --------------------- Shapes --------------------- */

	/**
	 * The app's set of styles.
	 *
	 * @public
	 */
	static styles = STYLES

	/**
	 * The current page bounds of all the selected shapes (Not the same thing as the page bounds of
	 * the selection bounding box when the selection has been rotated)
	 *
	 * @readonly
	 * @public
	 */
	@computed get selectedPageBounds(): Box2d | null {
		const {
			pageState: { selectedIds },
		} = this

		if (selectedIds.length === 0) return null

		return Box2d.Common(compact(selectedIds.map((id) => this.getPageBoundsById(id))))
	}

	/** The rotation of the selection bounding box. */
	@computed get selectionRotation(): number {
		const { selectedIds } = this
		if (selectedIds.length === 0) {
			return 0
		}
		if (selectedIds.length === 1) {
			return this.getPageRotationById(this.selectedIds[0])
		}

		const allRotations = selectedIds.map((id) => this.getPageRotationById(id) % (Math.PI / 2))
		// if the rotations are all compatible with each other, return the rotation of any one of them
		if (allRotations.every((rotation) => Math.abs(rotation - allRotations[0]) < Math.PI / 180)) {
			return this.getPageRotationById(selectedIds[0])
		}
		return 0
	}

	@computed get selectionBounds(): Box2d | undefined {
		const { selectedIds } = this

		if (selectedIds.length === 0) {
			return undefined
		}

		const { selectionRotation } = this
		if (selectionRotation === 0) {
			return this.selectedPageBounds!
		}

		if (selectedIds.length === 1) {
			const bounds = this.getBounds(this.getShapeById(selectedIds[0])!).clone()
			bounds.point = Matrix2d.applyToPoint(this.getPageTransformById(selectedIds[0])!, bounds.point)
			return bounds
		}

		// need to 'un-rotate' all the outlines of the existing nodes so we can fit them inside a box
		const allPoints = this.selectedIds
			.flatMap((id) => {
				const pageTransform = this.getPageTransformById(id)
				if (!pageTransform) return []
				return this.getOutlineById(id).map((point) => Matrix2d.applyToPoint(pageTransform, point))
			})
			.map((p) => Vec2d.Rot(p, -selectionRotation))
		const box = Box2d.FromPoints(allPoints)
		// now position box so that it's top-left corner is in the right place
		box.point = box.point.rot(selectionRotation)
		return box
	}

	@computed get selectionPageCenter() {
		const { selectionBounds, selectionRotation } = this
		if (!selectionBounds) return null
		return Vec2d.RotWith(selectionBounds.center, selectionBounds.point, selectionRotation)
	}

	/**
	 * An array containing all of the shapes in the current page.
	 *
	 * @example
	 *
	 * ```ts
	 * app.shapesArray
	 * ```
	 *
	 * @readonly
	 * @public
	 */
	@computed get shapesArray() {
		return Array.from(this.shapeIds, (id) => this.store.get(id)! as TLShape)
	}

	/**
	 * An array containing all of the shapes in the current page, sorted in z-index order (accounting
	 * for nested shapes): e.g. A, B, BA, BB, C.
	 *
	 * @example
	 *
	 * ```ts
	 * app.sortedShapesArray
	 * ```
	 *
	 * @readonly
	 * @public
	 */
	@computed get sortedShapesArray(): TLShape[] {
		const shapes = new Set(this.shapesArray.sort(sortByIndex))

		const results: TLShape[] = []

		function pushShapeWithDescendants(shape: TLShape): void {
			results.push(shape)
			shapes.delete(shape)

			shapes.forEach((otherShape) => {
				if (otherShape.parentId === shape.id) {
					pushShapeWithDescendants(otherShape)
				}
			})
		}

		shapes.forEach((shape) => {
			const parent = this.getShapeById(shape.parentId)
			if (!isShape(parent)) {
				pushShapeWithDescendants(shape)
			}
		})

		return results
	}

	/**
	 * An array containing all of the currently selected shapes.
	 *
	 * @example
	 *
	 * ```ts
	 * app.selectedShapes
	 * ```
	 *
	 * @public
	 * @readonly
	 */
	@computed get selectedShapes(): TLShape[] {
		const { selectedIds } = this.pageState
		return compact(selectedIds.map((id) => this.store.get(id)))
	}

	/**
	 * The app's only selected shape.
	 *
	 * @example
	 *
	 * ```ts
	 * app.onlySelectedShape
	 * ```
	 *
	 * @returns Null if there is no shape or more than one selected shape, otherwise the selected
	 *   shape.
	 * @public
	 * @readonly
	 */
	@computed get onlySelectedShape(): TLShape | null {
		const { selectedShapes } = this
		return selectedShapes.length === 1 ? selectedShapes[0] : null
	}

	/**
	 * Get whether a shape matches the type of a TLShapeUtil.
	 *
	 * @example
	 *
	 * ```ts
	 * const isArrowShape = isShapeOfType(someShape, TLArrowUtil)
	 * ```
	 *
	 * @param util - the TLShapeUtil constructor to test against
	 * @param shape - the shape to test
	 *
	 * @public
	 */
	isShapeOfType<T extends TLUnknownShape>(
		shape: TLUnknownShape,
		util: { new (...args: any): TLShapeUtil<T>; type: string }
	): shape is T {
		return shape.type === util.type
	}

	/**
	 * Get a shape by its id.
	 *
	 * @example
	 *
	 * ```ts
	 * app.getShapeById('box1')
	 * ```
	 *
	 * @param id - The id of the shape to get.
	 * @public
	 */
	getShapeById<T extends TLShape = TLShape>(id: TLParentId): T | undefined {
		if (!isShapeId(id)) return undefined
		return this.store.get(id) as T
	}

	/**
	 * Get the parent shape for a given shape. Returns undefined if the shape is the direct child of
	 * the page.
	 *
	 * @example
	 *
	 * ```ts
	 * app.getParentShape(myShape)
	 * ```
	 *
	 * @public
	 */
	getParentShape(shape?: TLShape): TLShape | undefined {
		if (shape === undefined || !isShapeId(shape.parentId)) return undefined
		return this.store.get(shape.parentId)
	}

	/**
	 * If siblingShape and targetShape are siblings, this returns targetShape. If targetShape has an
	 * ancestor who is a sibling of siblingShape, this returns that ancestor. Otherwise, this returns
	 * undefined
	 */
	private getNearestSiblingShape(
		siblingShape: TLShape,
		targetShape: TLShape | undefined
	): TLShape | undefined {
		if (!targetShape) {
			return undefined
		}
		if (targetShape.parentId === siblingShape.parentId) {
			return targetShape
		}

		const ancestor = this.findAncestor(
			targetShape,
			(ancestor) => ancestor.parentId === siblingShape.parentId
		)

		return ancestor
	}

	/** Get the id of the containing page for a given shape. */
	getParentPageId(shape?: TLShape): TLPageId | undefined {
		if (shape === undefined) return undefined
		if (isPageId(shape.parentId)) {
			return shape.parentId
		} else {
			return this.getParentPageId(this.getShapeById(shape.parentId))
		}
	}

	/**
	 * Get whether the given shape is the descendant of the given page.
	 *
	 * @example
	 *
	 * ```ts
	 * app.isShapeInPage(myShape)
	 * app.isShapeInPage(myShape, 'page1')
	 * ```
	 *
	 * @param shape - The shape to check.
	 * @param pageId - The id of the page to check against. Defaults to the current page.
	 * @public
	 */
	isShapeInPage(shape: TLShape, pageId = this.currentPageId): boolean {
		let shapeIsInPage = false

		if (shape.parentId === pageId) {
			shapeIsInPage = true
		} else {
			let parent = this.getShapeById(shape.parentId)
			isInPageSearch: while (parent) {
				if (parent.parentId === pageId) {
					shapeIsInPage = true
					break isInPageSearch
				}
				parent = this.getShapeById(parent.parentId)
			}
		}

		return shapeIsInPage
	}

	/* --------------------- Styles --------------------- */

	/**
	 * A mapping of color ids to CSS color values.
	 *
	 * @internal
	 */
	private colors: Map<TLColorStyle['id'], string>

	/**
	 * A mapping of size ids to size values.
	 *
	 * @internal
	 */
	private sizes = {
		s: 2,
		m: 3.5,
		l: 5,
		xl: 10,
	}

	/**
	 * Get the CSS color value for a given color id.
	 *
	 * @example
	 *
	 * ```ts
	 * app.getCssColor('red')
	 * ```
	 *
	 * @param id - The id of the color to get.
	 * @public
	 */
	getCssColor(id: TLColorStyle['id']): string {
		return this.colors.get(id)!
	}

	/**
	 * Get the stroke width value for a given size id.
	 *
	 * @example
	 *
	 * ```ts
	 * app.getStrokeWidth('m')
	 * ```
	 *
	 * @param id - The id of the size to get.
	 * @public
	 */
	getStrokeWidth(id: TLSizeStyle['id']): number {
		return this.sizes[id]
	}

	/* ------------------- Statechart ------------------- */

	/**
	 * The id of the current selected tool.
	 *
	 * @public
	 */
	get currentToolId(): string {
		const activeTool = this.root.current.value
		let activeToolId = activeTool?.id

		// Often a tool will transition into one of the following select states after the initial pointerdown: 'translating', 'resizing', 'dragging_handle'
		// It should then supply the tool id to the `onInteractionEnd` property to tell us which tool initially triggered the interaction.
		// If tool lock mode is on then tldraw will switch to the given tool id.
		// If tool lock mode is off then tldraw will switch back to the select tool when the interaction ends.

		if (activeToolId === 'select' || activeToolId === 'zoom') {
			const currentChildState = activeTool?.current.value as any
			activeToolId = currentChildState?.info?.onInteractionEnd ?? 'select'
		}

		return activeToolId ?? 'select'
	}

	/**
	 * Set the selected tool.
	 *
	 * @example
	 *
	 * ```ts
	 * app.setSelectedTool('hand')
	 * app.setSelectedTool('hand', { date: Date.now() })
	 * ```
	 *
	 * @param id - The id of the tool to select.
	 * @param info - Arbitrary data to pass along into the transition.
	 * @public
	 */
	setSelectedTool(id: string, info = {}) {
		this.root.transition(id, info)
		return this
	}

	/**
	 * Get a descendant by its path.
	 *
	 * @example
	 *
	 * ```ts
	 * state.getStateDescendant('select')
	 * state.getStateDescendant('select.brushing')
	 * ```
	 *
	 * @param path - The descendant's path of state ids, separated by periods.
	 * @public
	 */
	getStateDescendant(path: string): StateNode | undefined {
		const ids = path.split('.').reverse()
		let state = this.root as StateNode
		while (ids.length > 0) {
			const id = ids.pop()
			if (!id) return state
			const childState = state.children?.[id]
			if (!childState) return undefined
			state = childState
		}
		return state
	}

	/**
	 * Get whether a certain tool (or other state node) is currently active.
	 *
	 * @example
	 *
	 * ```ts
	 * app.isIn('select')
	 * app.isIn('select.brushing')
	 * ```
	 *
	 * @param path - The path of active states, separated by periods.
	 * @public
	 */
	isIn(path: string): boolean {
		const ids = path.split('.').reverse()
		let state = this.root as StateNode
		while (ids.length > 0) {
			const id = ids.pop()
			if (!id) return true
			const current = state.current.value
			if (current?.id === id) {
				if (ids.length === 0) return true
				state = current
				continue
			} else return false
		}
		return false
	}

	/**
	 * Get whether the state node is in any of the given active paths.
	 *
	 * @example
	 *
	 * ```ts
	 * state.isInAny('select', 'erase')
	 * state.isInAny('select.brushing', 'erase.idle')
	 * ```
	 *
	 * @public
	 */
	isInAny(...paths: string[]): boolean {
		return paths.some((path) => this.isIn(path))
	}

	/* --------------------- Inputs --------------------- */

	/**
	 * The app's current input state.
	 *
	 * @public
	 */
	inputs = {
		/** The most recent pointer down's position in page space. */
		originPagePoint: new Vec2d(),
		/** The most recent pointer down's position in screen space. */
		originScreenPoint: new Vec2d(),
		/** The previous pointer position in page space. */
		previousPagePoint: new Vec2d(),
		/** The previous pointer position in screen space. */
		previousScreenPoint: new Vec2d(),
		/** The most recent pointer position in page space. */
		currentPagePoint: new Vec2d(),
		/** The most recent pointer position in screen space. */
		currentScreenPoint: new Vec2d(),
		/** A set containing the currently pressed keys. */
		keys: new Set<string>(),
		/** A set containing the currently pressed buttons. */
		buttons: new Set<number>(),
		/** Whether the input is from a pe. */
		isPen: false,
		/** Whether the shift key is currently pressed. */
		shiftKey: false,
		/** Whether the control or command key is currently pressed. */
		ctrlKey: false,
		/** Whether the alt or option key is currently pressed. */
		altKey: false,
		/** Whether the user is dragging. */
		isDragging: false,
		/** Whether the user is pointing. */
		isPointing: false,
		/** Whether the user is pinching. */
		isPinching: false,
		/** Whether the user is editing. */
		isEditing: false,
		/** Whether the user is panning. */
		isPanning: false,
		/** Veclocity of mouse pointer, in pixels per millisecond */
		pointerVelocity: new Vec2d(),
	}

	/**
	 * Update the input points from a pointer or pinch event.
	 *
	 * @param info - The event info.
	 * @internal
	 */
	private _updateInputsFromEvent(info: TLPointerEventInfo | TLPinchEventInfo) {
		const { previousScreenPoint, previousPagePoint, currentScreenPoint, currentPagePoint } =
			this.inputs

		const { screenBounds } = this.store.unsafeGetWithoutCapture(this.instanceId)!
		const { x: sx, y: sy, z: sz } = info.point
		const { x: cx, y: cy, z: cz } = this.camera

		previousScreenPoint.setTo(currentScreenPoint)
		previousPagePoint.setTo(currentPagePoint)

		const px = (sx - screenBounds.x) / cz - cx
		const py = (sy - screenBounds.y) / cz - cy

		currentScreenPoint.set(sx, sy)
		currentPagePoint.set(px, py, sz ?? 0.5)

		this.inputs.isPen = info.type === 'pointer' && info.isPen

		// Reset velocity on pointer down
		if (info.name === 'pointer_down') {
			this.inputs.pointerVelocity = new Vec2d()
		}

		// todo: We only have to do this if there are multiple users in the document
		this.store.put([
			{
				id: TLPOINTER_ID,
				typeName: 'pointer',
				x: currentPagePoint.x,
				y: currentPagePoint.y,
				lastActivityTimestamp: Date.now(),
			},
		])
	}

	/* --------------------- Events --------------------- */

	/**
	 * A manager for recording multiple click events.
	 *
	 * @internal
	 */
	protected _clickManager = new ClickManager(this)

	/**
	 * Prevent a double click event from firing the next time the user clicks
	 *
	 * @public
	 */
	cancelDoubleClick() {
		this._clickManager.cancelDoubleClickTimeout()
	}

	/**
	 * The previous cursor. Used for restoring the cursor after pan events.
	 *
	 * @internal
	 */
	private _prevCursor: TLCursorType = 'default'

	/** @internal */
	private _shiftKeyTimeout = -1 as any

	/** @internal */
	private _setShiftKeyTimeout = () => {
		this.inputs.shiftKey = false
		this.dispatch({
			type: 'keyboard',
			name: 'key_up',
			key: 'Shift',
			shiftKey: this.inputs.shiftKey,
			ctrlKey: this.inputs.ctrlKey,
			altKey: this.inputs.altKey,
			code: 'ShiftLeft',
		})
	}

	/** @internal */
	private _altKeyTimeout = -1 as any

	/** @internal */
	private _setAltKeyTimeout = () => {
		this.inputs.altKey = false
		this.dispatch({
			type: 'keyboard',
			name: 'key_up',
			key: 'Alt',
			shiftKey: this.inputs.shiftKey,
			ctrlKey: this.inputs.ctrlKey,
			altKey: this.inputs.altKey,
			code: 'AltLeft',
		})
	}

	/** @internal */
	private _ctrlKeyTimeout = -1 as any

	/** @internal */
	private _setCtrlKeyTimeout = () => {
		this.inputs.ctrlKey = false
		this.dispatch({
			type: 'keyboard',
			name: 'key_up',
			key: 'Ctrl',
			shiftKey: this.inputs.shiftKey,
			ctrlKey: this.inputs.ctrlKey,
			altKey: this.inputs.altKey,
			code: 'CtrlLeft',
		})
	}

	/** @internal */
	private _restoreToolId = 'select'

	/** @internal */
	private _pinchStart = 1

	/** @internal */
	private _didPinch = false

	/** @internal */
	private _selectedIdsAtPointerDown: TLShapeId[] = []

	/** @internal */
	capturedPointerId: number | null = null

	/**
	 * Dispatch an event to the app.
	 *
	 * @example
	 *
	 * ```ts
	 * app.dispatch(myPointerEvent)
	 * ```
	 *
	 * @param info - The event info.
	 * @public
	 */
	dispatch = (info: TLEventInfo): this => {
		// prevent us from spamming similar event errors if we're crashed.
		// todo: replace with new readonly mode?
		if (this.crashingError) return this

		const { inputs } = this
		const { type } = info

		this.batch(() => {
			if (info.type === 'misc') {
				// stop panning if the interaction is cancelled or completed
				if (info.name === 'cancel' || info.name === 'complete') {
					this.inputs.isDragging = false

					if (this.inputs.isPanning) {
						this.inputs.isPanning = false
						this.setCursor({
							type: this._prevCursor,
						})
					}
				}

				this.root.handleEvent(info)
				return
			}

			if (info.shiftKey) {
				clearInterval(this._shiftKeyTimeout)
				this._shiftKeyTimeout = -1
				inputs.shiftKey = true
			} else if (!info.shiftKey && inputs.shiftKey && this._shiftKeyTimeout === -1) {
				this._shiftKeyTimeout = setTimeout(this._setShiftKeyTimeout, 150)
			}

			if (info.altKey) {
				clearInterval(this._altKeyTimeout)
				this._altKeyTimeout = -1
				inputs.altKey = true
			} else if (!info.altKey && inputs.altKey && this._altKeyTimeout === -1) {
				this._altKeyTimeout = setTimeout(this._setAltKeyTimeout, 150)
			}

			if (info.ctrlKey) {
				clearInterval(this._ctrlKeyTimeout)
				this._ctrlKeyTimeout = -1
				inputs.ctrlKey = true /** @internal */ /** @internal */ /** @internal */
			} else if (!info.ctrlKey && inputs.ctrlKey && this._ctrlKeyTimeout === -1) {
				this._ctrlKeyTimeout = setTimeout(this._setCtrlKeyTimeout, 150)
			}

			const { originPagePoint, originScreenPoint, currentPagePoint, currentScreenPoint } = inputs

			if (!inputs.isPointing) {
				inputs.isDragging = false
			}

			switch (type) {
				case 'pinch': {
					if (!this.canMoveCamera) return
					this._updateInputsFromEvent(info)

					switch (info.name) {
						case 'pinch_start': {
							if (inputs.isPinching) return

							if (!inputs.isEditing) {
								this._pinchStart = this.camera.z
								if (!this._selectedIdsAtPointerDown.length) {
									this._selectedIdsAtPointerDown = this.selectedIds.slice()
								}

								this._didPinch = true

								inputs.isPinching = true

								this.interrupt()
							}

							return // Stop here!
						}
						case 'pinch': {
							if (!inputs.isPinching) return

							const {
								point: { x, y, z = 1 },
								delta: { x: dx, y: dy },
							} = info

							const {
								camera: { x: cx, y: cy, z: cz },
							} = this

							const zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z))

							this.setCamera(
								cx + dx / cz - x / cz + x / zoom,
								cy + dy / cz - y / cz + y / zoom,
								zoom
							)

							return // Stop here!
						}
						case 'pinch_end': {
							if (!inputs.isPinching) return this

							inputs.isPinching = false
							const { _selectedIdsAtPointerDown } = this
							this.setSelectedIds(this._selectedIdsAtPointerDown, true)
							this._selectedIdsAtPointerDown = []

							const {
								camera: { x: cx, y: cy, z: cz },
							} = this

							let zoom: number | undefined

							if (cz > 0.9 && cz < 1.05) {
								zoom = 1
							} else if (cz > 0.49 && cz < 0.505) {
								zoom = 0.5
							}

							if (cz > this._pinchStart - 0.1 && cz < this._pinchStart + 0.05) {
								zoom = this._pinchStart
							}

							if (zoom !== undefined) {
								const { x, y } = this.viewportScreenCenter
								this.animateCamera(
									cx + (x / zoom - x) - (x / cz - x),
									cy + (y / zoom - y) - (y / cz - y),
									zoom,
									{ duration: 100 }
								)
							}

							if (this._didPinch) {
								this._didPinch = false
								requestAnimationFrame(() => {
									if (!this._didPinch) {
										this.setSelectedIds(_selectedIdsAtPointerDown, true)
									}
								})
							}

							return // Stop here!
						}
					}
				}
				case 'wheel': {
					if (!this.canMoveCamera) return

					if (this.isMenuOpen) {
						// noop
					} else {
						if (inputs.ctrlKey) {
							// todo: Start or update the zoom end interval

							// If the alt or ctrl keys are pressed,
							// zoom or pan the camera and then return.
							const { x, y } = this.inputs.currentScreenPoint
							const { x: cx, y: cy, z: cz } = this.camera

							const zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, cz + (info.delta.z ?? 0) * cz))

							this.setCamera(
								cx + (x / zoom - x) - (x / cz - x),
								cy + (y / zoom - y) - (y / cz - y),
								zoom
							)

							// We want to return here because none of the states in our
							// statechart should respond to this event (a camera zoom)
							return
						}

						// Update the camera here, which will dispatch a pointer move...
						// this will also update the pointer position, etc
						this.pan(info.delta.x, info.delta.y)

						if (
							!inputs.isDragging &&
							inputs.isPointing &&
							originPagePoint.dist(currentPagePoint) >
								(this.isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE) / this.zoomLevel
						) {
							inputs.isDragging = true
						}
					}
					break
				}
				case 'pointer': {
					// If we're pinching, return
					if (inputs.isPinching) return

					this._updateInputsFromEvent(info)

					const { isPen } = info

					switch (info.name) {
						case 'pointer_down': {
							this._selectedIdsAtPointerDown = this.selectedIds.slice()

							// Firefox bug fix...
							// If it's a left-mouse-click, we store the pointer id for later user
							if (info.button === 0) {
								this.capturedPointerId = info.pointerId
							}

							// Add the button from the buttons set
							inputs.buttons.add(info.button)

							inputs.isPointing = true
							inputs.isDragging = false

							if (this.isPenMode) {
								if (!isPen) {
									// decrement the remaining taps before exiting pen mode
									this._touchEventsRemainingBeforeExitingPenMode--
									if (this._touchEventsRemainingBeforeExitingPenMode === 0) {
										this.setPenMode(false)
									} else {
										return
									}
								} else {
									// reset the remaining taps before exiting pen mode
									this._touchEventsRemainingBeforeExitingPenMode = 3
								}
							} else {
								if (isPen) {
									this.setPenMode(true)
								}
							}

							if (info.button === 5) {
								// Eraser button activates eraser
								this._restoreToolId = this.currentToolId
								this.complete()
								this.setSelectedTool('eraser')
							} else if (info.button === 1) {
								// Middle mouse pan activates panning
								if (!this.inputs.isPanning) {
									this._prevCursor = this.instanceState.cursor.type
								}

								this.inputs.isPanning = true
							}

							if (this.inputs.isPanning) {
								this.stopCameraAnimation()
								this.setCursor({
									type: 'grabbing',
								})
								return this
							}

							originScreenPoint.setTo(currentScreenPoint)
							originPagePoint.setTo(currentPagePoint)
							break
						}
						case 'pointer_move': {
							// If the user is in pen mode, but the pointer is not a pen, stop here.
							if (!isPen && this.isPenMode) {
								return
							}

							if (this.inputs.isPanning && this.inputs.isPointing) {
								// Handle panning
								const { currentScreenPoint, previousScreenPoint } = this.inputs
								const delta = Vec2d.Sub(currentScreenPoint, previousScreenPoint)
								this.pan(delta.x, delta.y)
								return
							}

							if (
								!inputs.isDragging &&
								inputs.isPointing &&
								originPagePoint.dist(currentPagePoint) >
									(this.isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE) / this.zoomLevel
							) {
								inputs.isDragging = true
							}
							break
						}
						case 'pointer_up': {
							// Remove the button from the buttons set
							inputs.buttons.delete(info.button)

							inputs.isPointing = false
							inputs.isDragging = false

							if (this.isMenuOpen) {
								// Surpressing pointerup here as <ContextMenu/> doesn't seem to do what we what here.
								return
							}

							if (!isPen && this.isPenMode) {
								return
							}

							// Firefox bug fix...
							// If it's the same pointer that we stored earlier...
							// ... then it's probably still a left-mouse-click!
							if (this.capturedPointerId === info.pointerId) {
								this.capturedPointerId = null
								info.button = 0
							}

							if (inputs.isPanning) {
								if (info.button === 1) {
									if (!this.inputs.keys.has(' ')) {
										inputs.isPanning = false

										this.slideCamera({
											speed: Math.min(2, this.inputs.pointerVelocity.len()),
											direction: this.inputs.pointerVelocity,
											friction: HAND_TOOL_FRICTION,
										})
										this.setCursor({
											type: this._prevCursor,
										})
									} else {
										this.slideCamera({
											speed: Math.min(2, this.inputs.pointerVelocity.len()),
											direction: this.inputs.pointerVelocity,
											friction: HAND_TOOL_FRICTION,
										})
										this.setCursor({
											type: 'grab',
										})
									}
								} else if (info.button === 0) {
									this.slideCamera({
										speed: Math.min(2, this.inputs.pointerVelocity.len()),
										direction: this.inputs.pointerVelocity,
										friction: HAND_TOOL_FRICTION,
									})
									this.setCursor({
										type: 'grab',
									})
								}
							} else {
								if (info.button === 5) {
									// Eraser button activates eraser
									this.complete()
									this.setSelectedTool(this._restoreToolId)
								}
							}

							break
						}
					}

					break
				}
				case 'keyboard': {
					switch (info.name) {
						case 'key_down': {
							// Add the key from the keys set
							inputs.keys.add(info.code)

							// If the space key is pressed (but meta / control isn't!) activate panning
							if (!info.ctrlKey && info.code === 'Space') {
								if (!this.inputs.isPanning) {
									this._prevCursor = this.instanceState.cursor.type
								}

								this.inputs.isPanning = true
								this.setCursor({
									type: this.inputs.isPointing ? 'grabbing' : 'grab',
								})
							}

							break
						}
						case 'key_up': {
							// Remove the key from the keys set
							inputs.keys.delete(info.code)

							if (info.code === 'Space' && !this.inputs.buttons.has(1)) {
								this.inputs.isPanning = false
								this.setCursor({
									type: this._prevCursor,
								})
							}

							break
						}
						case 'key_repeat': {
							// nooop
							break
						}
					}
					break
				}
			}

			// Correct the info name for right / middle clicks
			if (info.type === 'pointer') {
				if (info.button === 1) {
					info.name = 'middle_click'
				} else if (info.button === 2) {
					info.name = 'right_click'
				}

				// If a pointer event, send the event to the click manager.
				if (info.isPen === this.isPenMode) {
					switch (info.name) {
						case 'pointer_down': {
							const otherEvent = this._clickManager.transformPointerDownEvent(info)
							if (info.name !== otherEvent.name) {
								this.root.handleEvent(info)
								this.emit('event', info)
								this.root.handleEvent(otherEvent)
								this.emit('event', otherEvent)
								return
							}

							break
						}
						case 'pointer_up': {
							const otherEvent = this._clickManager.transformPointerUpEvent(info)
							if (info.name !== otherEvent.name) {
								this.root.handleEvent(info)
								this.emit('event', info)
								this.root.handleEvent(otherEvent)
								this.emit('event', otherEvent)
								return
							}

							break
						}
						case 'pointer_move': {
							this._clickManager.handleMove()
							break
						}
					}
				}
			}

			// Send the event to the statechart. It will be handled by all
			// active states, starting at the root.
			this.root.handleEvent(info)
			this.emit('event', info)
		})

		return this
	}

	replaceStoreContentsWithRecordsForOtherDocument(records: TLRecord[]) {
		transact(() => {
			this.store.clear()
			const [shapes, nonShapes] = partition(records, (record) => record.typeName === 'shape')
			this.store.put(nonShapes, 'initialize')
			this.store.ensureStoreIsUsable()
			this.store.put(shapes, 'initialize')
			this.history.clear()
			this.updateViewportScreenBounds()
			this.updateCullingBounds()

			const bounds = this.allShapesCommonBounds
			if (bounds) {
				this.zoomToBounds(bounds.minX, bounds.minY, bounds.width, bounds.height, 1)
			}
		})
	}

	getContent(ids: TLShapeId[] = this.selectedIds): TLClipboardModel | undefined {
		if (!ids) return
		if (ids.length === 0) return

		const pageTransforms: Record<string, Matrix2dModel> = {}

		let shapes = dedupe(
			ids
				.map((id) => this.getShapeById(id)!)
				.sort(sortByIndex)
				.flatMap((shape) => {
					const allShapes = [shape]
					this.visitDescendants(shape.id, (descendant) => {
						allShapes.push(this.getShapeById(descendant)!)
					})
					return allShapes
				})
		)

		shapes = shapes.map((shape) => {
			pageTransforms[shape.id] = this.getPageTransformById(shape.id)!

			shape = structuredClone(shape) as typeof shape

			if (this.isShapeOfType(shape, TLArrowUtil)) {
				const startBindingId =
					shape.props.start.type === 'binding' ? shape.props.start.boundShapeId : undefined

				const endBindingId =
					shape.props.end.type === 'binding' ? shape.props.end.boundShapeId : undefined

				const info = this.getShapeUtil(TLArrowUtil).getArrowInfo(shape)

				if (shape.props.start.type === 'binding') {
					if (!shapes.some((s) => s.id === startBindingId)) {
						// Uh oh, the arrow's bound-to shape isn't among the shapes
						// that we're getting the content for. We should try to adjust
						// the arrow so that it appears in the place it would be
						if (info?.isValid) {
							const { x, y } = info.start.point
							shape.props.start = {
								type: 'point',
								x,
								y,
							}
						} else {
							const { start } = getArrowTerminalsInArrowSpace(this, shape)
							shape.props.start = {
								type: 'point',
								x: start.x,
								y: start.y,
							}
						}
					}
				}

				if (shape.props.end.type === 'binding') {
					if (!shapes.some((s) => s.id === endBindingId)) {
						if (info?.isValid) {
							const { x, y } = info.end.point
							shape.props.end = {
								type: 'point',
								x,
								y,
							}
						} else {
							const { end } = getArrowTerminalsInArrowSpace(this, shape)
							shape.props.end = {
								type: 'point',
								x: end.x,
								y: end.y,
							}
						}
					}
				}

				const infoAfter = getIsArrowStraight(shape)
					? getStraightArrowInfo(this, shape)
					: getCurvedArrowInfo(this, shape)

				if (info?.isValid && infoAfter?.isValid && !getIsArrowStraight(shape)) {
					const mpA = Vec2d.Med(info.start.handle, info.end.handle)
					const distA = Vec2d.Dist(info.middle, mpA)
					const distB = Vec2d.Dist(infoAfter.middle, mpA)
					if (shape.props.bend < 0) {
						shape.props.bend += distB - distA
					} else {
						shape.props.bend -= distB - distA
					}
				}

				return shape
			}

			return shape
		})

		const rootShapeIds: TLShapeId[] = []

		shapes.forEach((shape) => {
			if (shapes.find((s) => s.id === shape.parentId) === undefined) {
				// Need to get page point and rotation of the shape because shapes in
				// groups use local position/rotation

				const pagePoint = this.getPagePointById(shape.id)!
				const pageRotation = this.getPageRotationById(shape.id)!
				shape.x = pagePoint.x
				shape.y = pagePoint.y
				shape.rotation = pageRotation
				shape.parentId = this.currentPageId

				rootShapeIds.push(shape.id)
			}
		})

		const assetsSet = new Set<TLAssetId>()

		shapes.forEach((shape) => {
			if ('assetId' in shape.props) {
				if (shape.props.assetId !== null) {
					assetsSet.add(shape.props.assetId)
				}
			}
		})

		return {
			shapes,
			rootShapeIds,
			schema: this.store.schema.serialize(),
			assets: compact(Array.from(assetsSet).map((id) => this.getAssetById(id))),
		}
	}

	/* --------------------- Commands --------------------- */

	putContent(
		content: TLClipboardModel,
		options: {
			point?: VecLike
			select?: boolean
			preservePosition?: boolean
			preserveIds?: boolean
		} = {}
	): this {
		if (this.isReadOnly) return this

		if (!content.schema) {
			throw Error('Could not put content: content is missing a schema.')
		}

		const { select = false, preserveIds = false, preservePosition = false } = options
		let { point = undefined } = options

		// decide on a parent for the put shapes; if the parent is among the put shapes(?) then use its parent

		const { currentPageId } = this
		const { assets, shapes, rootShapeIds } = content

		const idMap = new Map<any, TLShapeId>(shapes.map((shape) => [shape.id, createShapeId()]))

		// By default, the paste parent will be the current page.
		let pasteParentId = this.currentPageId as TLPageId | TLShapeId
		let lowestDepth = Infinity
		let lowestAncestors: TLShape[] = []

		// Among the selected shapes, find the shape with the fewest ancestors and use its first ancestor.
		for (const shape of this.selectedShapes) {
			if (lowestDepth === 0) break

			const ancestors = this.getAncestors(shape)
			if (shape.type === 'frame') ancestors.push(shape)

			const depth = shape.type === 'frame' ? ancestors.length + 1 : ancestors.length

			if (depth < lowestDepth) {
				lowestDepth = depth
				lowestAncestors = ancestors
				pasteParentId = shape.type === 'frame' ? shape.id : shape.parentId
			} else if (depth === lowestDepth) {
				if (lowestAncestors.length !== ancestors.length) {
					throw Error(`Ancestors: ${lowestAncestors.length} !== ${ancestors.length}`)
				}

				if (lowestAncestors.length === 0) {
					pasteParentId = currentPageId
					break
				} else {
					pasteParentId = currentPageId
					for (let i = 0; i < lowestAncestors.length; i++) {
						if (ancestors[i] !== lowestAncestors[i]) break
						pasteParentId = ancestors[i].id
					}
				}
			}
		}

		let isDuplicating = false

		if (!isPageId(pasteParentId)) {
			const parent = this.getShapeById(pasteParentId)
			if (parent) {
				if (!this.viewportPageBounds.includes(this.getPageBounds(parent)!)) {
					pasteParentId = currentPageId
				} else {
					if (rootShapeIds.length === 1) {
						const rootShape = shapes.find((s) => s.id === rootShapeIds[0])!
						if (
							this.isShapeOfType(parent, TLFrameUtil) &&
							this.isShapeOfType(rootShape, TLFrameUtil) &&
							rootShape.props.w === parent?.props.w &&
							rootShape.props.h === parent?.props.h
						) {
							isDuplicating = true
						}
					}
				}
			} else {
				pasteParentId = currentPageId
			}
		}

		if (!isDuplicating) {
			isDuplicating = idMap.has(pasteParentId)
		}

		if (isDuplicating) {
			pasteParentId = this.getShapeById(pasteParentId)!.parentId
		}

		let index = this.getHighestIndexForParent(pasteParentId)

		const rootShapes: TLShape[] = []

		const newShapes: TLShape[] = shapes.map((shape): TLShape => {
			let newShape: TLShape

			if (preserveIds) {
				newShape = deepCopy(shape)
				idMap.set(shape.id, shape.id)
			} else {
				const id = idMap.get(shape.id)!

				// Create the new shape (new except for the id)
				newShape = deepCopy({ ...shape, id })
			}

			if (rootShapeIds.includes(shape.id)) {
				newShape.parentId = currentPageId
				rootShapes.push(newShape)
			}

			// Assign the child to its new parent.

			// If the child's parent is among the putting shapes, then assign
			// it to the new parent's id.
			if (idMap.has(newShape.parentId)) {
				newShape.parentId = idMap.get(shape.parentId)!
			} else {
				rootShapeIds.push(newShape.id)
				// newShape.parentId = pasteParentId
				newShape.index = index
				index = getIndexAbove(index)
			}

			if (this.isShapeOfType(newShape, TLArrowUtil)) {
				if (newShape.props.start.type === 'binding') {
					const mappedId = idMap.get(newShape.props.start.boundShapeId)
					newShape.props.start = mappedId
						? { ...newShape.props.start, boundShapeId: mappedId }
						: // this shouldn't happen, if you copy an arrow but not it's bound shape it should
						  // convert the binding to a point at the time of copying
						  { type: 'point', x: 0, y: 0 }
				}
				if (newShape.props.end.type === 'binding') {
					const mappedId = idMap.get(newShape.props.end.boundShapeId)
					newShape.props.end = mappedId
						? { ...newShape.props.end, boundShapeId: mappedId }
						: // this shouldn't happen, if you copy an arrow but not it's bound shape it should
						  // convert the binding to a point at the time of copying
						  { type: 'point', x: 0, y: 0 }
				}
			}

			return newShape
		})

		if (newShapes.length + this.shapeIds.size > MAX_SHAPES_PER_PAGE) {
			// There's some complexity here involving children
			// that might be created without their parents, so
			// if we're going over the limit then just don't paste.
			alertMaxShapes(this)
			return this
		}

		// Migrate the new shapes

		let assetsToCreate: TLAsset[] = []

		if (assets) {
			for (let i = 0; i < assets.length; i++) {
				const asset = assets[i]
				const result = this.store.schema.migratePersistedRecord(asset, content.schema)
				if (result.type === 'success') {
					assets[i] = result.value as TLAsset
				} else {
					throw Error(
						`Could not put content: could not migrate content for asset:\n${JSON.stringify(
							asset,
							null,
							2
						)}`
					)
				}
			}

			const assetsToUpdate: (TLImageAsset | TLVideoAsset)[] = []

			assetsToCreate = assets
				.filter((asset) => !this.store.has(asset.id))
				.map((asset) => {
					if (asset.type === 'image' || asset.type === 'video') {
						if (asset.props.src && asset.props.src?.startsWith('data:image')) {
							assetsToUpdate.push(structuredClone(asset))
							asset.props.src = null
						} else {
							assetsToUpdate.push(structuredClone(asset))
						}
					}

					return asset
				})

			Promise.allSettled(
				assetsToUpdate.map(async (asset) => {
					const file = await dataUrlToFile(
						asset.props.src!,
						asset.props.name,
						asset.props.mimeType ?? 'image/png'
					)

					const newAsset = await this.onCreateAssetFromFile(file)

					return [asset, newAsset] as const
				})
			).then((assets) => {
				this.updateAssets(
					compact(
						assets.map((result) =>
							result.status === 'fulfilled'
								? { ...result.value[1], id: result.value[0].id }
								: undefined
						)
					)
				)
			})
		}

		for (let i = 0; i < newShapes.length; i++) {
			const shape = newShapes[i]
			const result = this.store.schema.migratePersistedRecord(shape, content.schema)
			if (result.type === 'success') {
				newShapes[i] = result.value as TLShape
			} else {
				throw Error(
					`Could not put content: could not migrate content for shape:\n${JSON.stringify(
						shape,
						null,
						2
					)}`
				)
			}
		}

		this.batch(() => {
			// Create any assets that need to be created
			if (assetsToCreate.length > 0) {
				this.createAssets(assetsToCreate)
			}

			// Create the shapes with root shapes as children of the page
			this.createShapes(newShapes, select)

			// And then, if needed, reparent the root shapes to the paste parent
			if (pasteParentId !== currentPageId) {
				this.reparentShapesById(
					rootShapes.map((s) => s.id),
					pasteParentId
				)
			}

			const newCreatedShapes = newShapes.map((s) => this.getShapeById(s.id)!)
			const bounds = Box2d.Common(newCreatedShapes.map((s) => this.getPageBounds(s)!))

			if (point === undefined) {
				if (!isPageId(pasteParentId)) {
					// Put the shapes in the middle of the (on screen) parent
					const shape = this.getShapeById(pasteParentId)!
					const util = this.getShapeUtil(shape)
					point = util.center(shape)
				} else {
					const { viewportPageBounds } = this
					if (preservePosition || viewportPageBounds.includes(Box2d.From(bounds))) {
						// Otherwise, put shapes where they used to be
						point = bounds.center
					} else {
						// If the old bounds are outside of the viewport...
						// put the shapes in the middle of the viewport
						point = viewportPageBounds.center
					}
				}
			}

			if (rootShapes.length === 1) {
				const onlyRoot = rootShapes[0] as TLFrameShape
				// If the old bounds are in the viewport...
				if (onlyRoot.type === 'frame') {
					while (
						this.getShapesAtPoint(point).some(
							(shape) =>
								this.isShapeOfType(shape, TLFrameUtil) &&
								shape.props.w === onlyRoot.props.w &&
								shape.props.h === onlyRoot.props.h
						)
					) {
						point.x += bounds.w + 16
					}
				}
			}

			this.updateShapes(
				rootShapes.map((s) => {
					const delta = {
						x: (s.x ?? 0) - (bounds.x + bounds.w / 2),
						y: (s.y ?? 0) - (bounds.y + bounds.h / 2),
					}

					return { id: s.id, type: s.type, x: point!.x + delta.x, y: point!.y + delta.y }
				})
			)
		})

		return this
	}

	/* --------------------- Shapes --------------------- */

	/**
	 * Get a unique id for a shape.
	 *
	 * @example
	 *
	 * ```ts
	 * app.createShapeId()
	 * app.createShapeId('box1')
	 * ```
	 *
	 * @param id - The id to use.
	 * @public
	 */
	createShapeId(id?: string) {
		return id ? createCustomShapeId(id) : createShapeId()
	}

	getHighestIndexForParent(parentId: TLShapeId | TLPageId) {
		const children = this._parentIdsToChildIds.value[parentId]

		if (!children || children.length === 0) {
			return 'a1'
		}
		return getIndexAbove(children[children.length - 1][1])
	}

	/**
	 * Create shapes.
	 *
	 * @example
	 *
	 * ```ts
	 * app.createShapes([{ id: 'box1', type: 'box' }])
	 * ```
	 *
	 * @param partials - The shape partials to create.
	 * @param select - Whether to select the created shapes. Defaults to false.
	 * @public
	 */
	createShapes(partials: TLShapePartial[], select = false) {
		this._createShapes(partials, select)
		return this
	}

	/** @internal */
	private _createShapes = this.history.createCommand(
		'createShapes',
		(partials: TLShapePartial[], select = false) => {
			if (this.isReadOnly) return null
			if (partials.length <= 0) return null

			const { shapeIds, selectedIds } = this

			const prevSelectedIds = select ? selectedIds : undefined

			const maxShapesReached = partials.length + shapeIds.size > MAX_SHAPES_PER_PAGE

			if (maxShapesReached) {
				alertMaxShapes(this)
			}

			const partialsToCreate = maxShapesReached
				? partials.slice(0, MAX_SHAPES_PER_PAGE - shapeIds.size)
				: partials

			if (partialsToCreate.length === 0) return null

			return {
				data: {
					currentPageId: this.currentPageId,
					createdIds: partials.map((p) => p.id),
					prevSelectedIds,
					partials: partialsToCreate,
					select,
				},
			}
		},
		{
			do: ({ createdIds, partials, select }) => {
				const { focusLayerId } = this

				// 1. Parents

				// Make sure that each partial will become the child of either the
				// page or another shape that exists (or that will exist) in this page.

				partials = partials.map((partial) => {
					if (
						// No parentId provided
						!partial.parentId ||
						// A parentId is proved but the parent is neither a) in the store
						// or b) among the other creating shape partials
						(!this.store.get(partial.parentId) && !partials.find((p) => p.id === partial.parentId))
					) {
						partial = { ...partial }
						const parentId = this.getParentIdForNewShapeAtPoint(
							{ x: partial.x ?? 0, y: partial.y ?? 0 },
							partial.type
						)
						partial.parentId = parentId
						// If the parent is a shape (rather than a page) then insert the
						// shapes into the shape's children. Ajust the point and page rotation to be
						// preserved relative to the parent.
						if (isShapeId(parentId)) {
							const point = this.getPointInShapeSpace(this.getShapeById(parentId)!, {
								x: partial.x ?? 0,
								y: partial.y ?? 0,
							})
							partial.x = point.x
							partial.y = point.y
							partial.rotation = -this.getPageRotationById(parentId) + (partial.rotation ?? 0)
						}
						return partial
					}
					return partial
				})

				// 2. Indices

				// Get the highest index among the parents of each of the
				// the shapes being created; we'll increment from there.

				const parentIndices = new Map<string, string>()

				const shapeRecordsToCreate: TLShape[] = []

				for (const partial of partials) {
					const util = this.getShapeUtil(partial)

					// If an index is not explicitly provided, then add the
					// shapes to the top of their parents' children; using the
					// value in parentsMappedToIndex, get the index above, use it,
					// and set it back to parentsMappedToIndex for next time.
					let index = partial.index

					if (!index) {
						const parentId = partial.parentId ?? focusLayerId
						if (!parentIndices.has(parentId)) {
							parentIndices.set(parentId, this.getHighestIndexForParent(parentId))
						}
						index = parentIndices.get(parentId)!
						parentIndices.set(parentId, getIndexAbove(index))
					}

					// The initial props starts as the shape utility's default props
					const initialProps = util.defaultProps()

					// We then look up each key in the tab state's props; and if it's there,
					// we use the value from the tab state's props instead of the default.
					// Note that props will never include opacity.
					const { propsForNextShape } = this.instanceState
					for (const key in initialProps) {
						if (key in propsForNextShape) {
							if (key === 'url') continue
							;(initialProps as any)[key] = (propsForNextShape as any)[key]
						}
					}

					// When we create the shape, take in the partial (the props coming into the
					// function) and merge it with the default props.
					let shapeRecordToCreate = (
						this.store.schema.types.shape as RecordType<
							TLShape,
							'type' | 'props' | 'index' | 'parentId'
						>
					).create({
						...partial,
						index,
						parentId: partial.parentId ?? focusLayerId,
						props: 'props' in partial ? { ...initialProps, ...partial.props } : initialProps,
					})

					if (shapeRecordToCreate.index === undefined) {
						throw Error('no index!')
					}

					const next = this.getShapeUtil(shapeRecordToCreate).onBeforeCreate?.(shapeRecordToCreate)

					if (next) {
						shapeRecordToCreate = next
					}

					shapeRecordsToCreate.push(shapeRecordToCreate)
				}

				this.store.put(shapeRecordsToCreate)

				// If we're also selecting the newly created shapes, attempt to select all of them;

				// the engine will filter out any shapes that are descendants of other new shapes.
				if (select) {
					this.store.update(this.pageState.id, (state) => ({
						...state,
						selectedIds: createdIds,
					}))
				}
			},
			undo: ({ createdIds, prevSelectedIds }) => {
				this.store.remove(createdIds)

				if (prevSelectedIds) {
					this.store.update(this.pageState.id, (state) => ({
						...state,
						selectedIds: prevSelectedIds,
					}))
				}
			},
		}
	)

	private animatingShapes = new Map<TLShapeId, string>()

	/**
	 * Animate shapes.
	 *
	 * @example
	 *
	 * ```ts
	 * app.animateShapes([{ id: 'box1', type: 'box', x: 100, y: 100 }])
	 * ```
	 *
	 * @param partials - The shape partials to update.
	 * @public
	 */
	animateShapes(
		partials: (TLShapePartial | null | undefined)[],
		options: {
			/** The animation's duration in milliseconds. */
			duration?: number
			/** The animation's easing function. */
			ease?: (t: number) => number
		} = {}
	) {
		const { duration = 500, ease = EASINGS.linear } = options

		const animationId = uniqueId()

		let remaining = duration
		let t: number

		type FromTo = { prop: string; from: number; to: number }
		type ShapeAnimation = { partial: TLShapePartial; values: FromTo[] }

		const animations: ShapeAnimation[] = []

		partials.forEach((partial) => {
			if (!partial) return

			const result: ShapeAnimation = {
				partial,
				values: [],
			}

			const shape = this.getShapeById(partial.id)!

			if (!shape) return

			for (const key of ['x', 'y', 'rotation'] as const) {
				if (partial[key] !== undefined && shape[key] !== partial[key]) {
					result.values.push({ prop: key, from: shape[key], to: partial[key] as number })
				}
			}

			animations.push(result)
			this.animatingShapes.set(shape.id, animationId)
		})

		let value: ShapeAnimation

		const handleTick = (elapsed: number) => {
			remaining -= elapsed

			if (remaining < 0) {
				const { animatingShapes } = this
				const partialsToUpdate = partials.filter(
					(p) => p && animatingShapes.get(p.id) === animationId
				)
				if (partialsToUpdate.length) {
					this.updateShapes(partialsToUpdate, false)
					// update shapes also removes the shape from animating shapes
				}

				this.removeListener('tick', handleTick)
				return
			}

			t = ease(1 - remaining / duration)

			const { animatingShapes } = this

			try {
				const tPartials: TLShapePartial[] = []

				for (let i = 0; i < animations.length; i++) {
					value = animations[i]

					if (animatingShapes.get(value.partial.id) === animationId) {
						tPartials.push({
							id: value.partial.id,
							type: value.partial.type,
							...value.values.reduce((acc, { prop, from, to }) => {
								acc[prop] = from + (to - from) * t
								return acc
							}, {} as any),
						})
					}
				}

				this._updateShapes(tPartials, true)
			} catch (e) {
				// noop
			}
		}

		this.addListener('tick', handleTick)

		return this
	}

	/**
	 * Update shapes.
	 *
	 * @example
	 *
	 * ```ts
	 * app.updateShapes([{ id: 'box1', type: 'box', x: 100, y: 100 }])
	 * ```
	 *
	 * @param partials - The shape partials to update.
	 * @param squashing - Whether the change is ephemeral.
	 * @public
	 */
	updateShapes(partials: (TLShapePartial | null | undefined)[], squashing = false) {
		let compactedPartials = compact(partials)
		if (this.animatingShapes.size > 0) {
			compactedPartials.forEach((p) => this.animatingShapes.delete(p.id))
		}

		compactedPartials = compactedPartials.filter((p) => {
			const shape = this.getShapeById(p.id)
			if (!shape) return false

			// Only allow changes to unlocked shapes or changes to the isLocked property (otherwise we cannot unlock a shape)
			if (this.isShapeOrAncestorLocked(shape) && !Object.hasOwn(p, 'isLocked')) return false
			return true
		})

		this._updateShapes(compactedPartials, squashing)
		return this
	}

	/** @internal */
	private _updateShapes = this.history.createCommand(
		'updateShapes',
		(_partials: (TLShapePartial | null | undefined)[], squashing = false) => {
			if (this.isReadOnly) return null

			const partials = compact(_partials)

			const snapshots = Object.fromEntries(
				compact(partials.map(({ id }) => this.getShapeById(id))).map((shape) => {
					return [shape.id, shape]
				})
			)

			if (partials.length <= 0) return null

			const updated = compact(
				partials.map((partial) => {
					const prev = snapshots[partial.id]
					if (!prev) return null
					let newRecord = null as null | TLShape
					for (const [k, v] of Object.entries(partial)) {
						if (v === undefined) continue
						switch (k) {
							case 'id':
							case 'type':
							case 'typeName': {
								continue
							}
							default: {
								if (v !== (prev as any)[k]) {
									if (!newRecord) {
										newRecord = { ...prev }
									}

									if (k === 'props') {
										const nextProps = { ...prev.props } as Record<string, unknown>
										for (const [propKey, propValue] of Object.entries(v as object)) {
											if (propValue === undefined) continue
											nextProps[propKey] = propValue
										}
										newRecord!.props = nextProps
									} else {
										;(newRecord as any)[k] = v
									}
								}
							}
						}
					}

					return newRecord ?? prev
				})
			)

			const updates = Object.fromEntries(updated.map((shape) => [shape.id, shape]))

			return { data: { snapshots, updates }, squashing }
		},
		{
			do: ({ updates }) => {
				// Iterate through array; if any shape has an onUpdate handler, call it
				// and, if the handler returns a new shape, replace the old shape with
				// the new one. This is used for example when repositioning a text shape
				// based on its new text content.
				const result = Object.values(updates)
				for (let i = 0; i < result.length; i++) {
					const shape = result[i]
					const current = this.store.get(shape.id)
					if (!current) continue
					const next = this.getShapeUtil(shape).onBeforeUpdate?.(current, shape)
					if (next) {
						result[i] = next
					}
				}
				this.store.put(result)
			},
			undo: ({ snapshots }) => {
				this.store.put(Object.values(snapshots))
			},
			squash(prevData, nextData) {
				return {
					// keep the oldest snapshots
					snapshots: { ...nextData.snapshots, ...prevData.snapshots },
					// keep the newest updates
					updates: { ...prevData.updates, ...nextData.updates },
				}
			},
		}
	)

	/** @internal */
	private _getUnlockedShapeIds(ids: TLShapeId[]): TLShapeId[] {
		return ids.filter((id) => !this.getShapeById(id)?.isLocked)
	}

	/**
	 * Delete shapes.
	 *
	 * @example
	 *
	 * ```ts
	 * app.deleteShapes()
	 * app.deleteShapes(['box1', 'box2'])
	 * ```
	 *
	 * @param ids - The ids of the shapes to delete. Defaults to the selected shapes.
	 * @public
	 */
	deleteShapes(ids: TLShapeId[] = this.selectedIds) {
		this._deleteShapes(this._getUnlockedShapeIds(ids))
		return this
	}

	/** @internal */
	private _deleteShapes = this.history.createCommand(
		'delete_shapes',
		(ids: TLShapeId[]) => {
			if (this.isReadOnly) return null
			if (ids.length === 0) return null
			const prevSelectedIds = [...this.pageState.selectedIds]

			const allIds = new Set(ids)

			for (const id of ids) {
				this.visitDescendants(id, (childId) => {
					allIds.add(childId)
				})
			}

			const deletedIds = [...allIds]
			const arrowBindings = this._arrowBindingsIndex.value
			const snapshots = compact(
				deletedIds.flatMap((id) => {
					const shape = this.getShapeById(id)

					// Add any bound arrows to the snapshots, so that we can restore the bindings on undo
					const bindings = arrowBindings[id]
					if (bindings && bindings.length > 0) {
						return bindings.map(({ arrowId }) => this.getShapeById(arrowId)).concat(shape)
					}
					return shape
				})
			)

			const postSelectedIds = prevSelectedIds.filter((id) => !allIds.has(id))

			return { data: { deletedIds, snapshots, prevSelectedIds, postSelectedIds } }
		},
		{
			do: ({ deletedIds, postSelectedIds }) => {
				this.store.remove(deletedIds)
				this.store.update(this.pageState.id, (state) => ({
					...state,
					selectedIds: postSelectedIds,
				}))
			},
			undo: ({ snapshots, prevSelectedIds }) => {
				this.store.put(snapshots)
				this.store.update(this.pageState.id, (state) => ({
					...state,
					selectedIds: prevSelectedIds,
				}))
			},
		}
	)

	/**
	 * Update user document settings
	 *
	 * @example
	 *
	 * ```ts
	 * app.updateUserDocumentSettings({ isGridMode: true })
	 * ```
	 *
	 * @public
	 */
	updateUserDocumentSettings(partial: Partial<TLUserDocument>, ephemeral = false) {
		this._updateUserDocumentSettings(partial, ephemeral)
		return this
	}

	/** @internal */
	private _updateUserDocumentSettings = this.history.createCommand(
		'updateUserDocumentSettings',
		(partial: Partial<TLUserDocument>, ephemeral = false) => {
			const prev = this.userDocumentSettings
			const next = { ...prev, ...partial }
			return { data: { prev, next }, ephemeral }
		},
		{
			do: ({ next }) => {
				this.store.put([next])
			},
			undo: ({ prev }) => {
				this.store.put([prev])
			},
		}
	)

	/**
	 * Get the editor's locale.
	 * @public
	 */
	get locale() {
		return this.user.locale
	}

	/**
	 * Update the editor's locale. This affects which translations are used when rendering UI elements.
	 *
	 * @example
	 *
	 * ```ts
	 * app.setLocale('fr')
	 * ```
	 */
	setLocale(locale: string) {
		this.user.updateUserPreferences({ locale })
	}

	/**
	 * Update a page.
	 *
	 * @example
	 *
	 * ```ts
	 * app.updatePage({ id: 'page2', name: 'Page 2' })
	 * ```
	 *
	 * @param partial - The partial of the shape to update.
	 * @public
	 */
	updatePage(partial: RequiredKeys<TLPage, 'id'>, squashing = false) {
		this._updatePage(partial, squashing)
		return this
	}

	/** @internal */
	private _updatePage = this.history.createCommand(
		'updatePage',
		(partial: RequiredKeys<TLPage, 'id'>, squashing = false) => {
			if (this.isReadOnly) return null

			const prev = this.getPageById(partial.id)

			if (!prev) return null

			return { data: { prev, partial }, squashing }
		},
		{
			do: ({ partial }) => {
				this.store.update(partial.id, (page) => ({ ...page, ...partial }))
			},
			undo: ({ prev, partial }) => {
				this.store.update(partial.id, () => prev)
			},
			squash(prevData, nextData) {
				return {
					prev: { ...prevData.prev, ...nextData.prev },
					partial: nextData.partial,
				}
			},
		}
	)

	/**
	 * Create a page.
	 *
	 * @example
	 *
	 * ```ts
	 * app.createPage('New Page')
	 * app.createPage('New Page', 'page1')
	 * ```
	 *
	 * @param id - The new page's id.
	 * @param title - The new page's title.
	 * @public
	 */
	createPage(title: string, id: TLPageId = PageRecordType.createId(), belowPageIndex?: string) {
		this._createPage(title, id, belowPageIndex)
		return this
	}

	/** @internal */
	private _createPage = this.history.createCommand(
		'createPage',
		(title: string, id: TLPageId = PageRecordType.createId(), belowPageIndex?: string) => {
			if (this.isReadOnly) return null
			if (this.pages.length >= MAX_PAGES) return null
			const pageInfo = this.pages
			const topIndex = belowPageIndex ?? pageInfo[pageInfo.length - 1]?.index ?? 'a1'
			const bottomIndex = pageInfo[pageInfo.findIndex((p) => p.index === topIndex) + 1]?.index

			const prevPageState = { ...this.pageState }
			const prevInstanceState = { ...this.instanceState }

			title = getIncrementedName(
				title,
				pageInfo.map((p) => p.name)
			)

			const newPage = PageRecordType.create({
				id,
				name: title,
				index:
					bottomIndex && topIndex !== bottomIndex
						? getIndexBetween(topIndex, bottomIndex)
						: getIndexAbove(topIndex),
			})

			const newCamera = CameraRecordType.create({})

			const newTabPageState = InstancePageStateRecordType.create({
				pageId: newPage.id,
				instanceId: this.instanceId,
				cameraId: newCamera.id,
			})

			return {
				data: {
					prevPageState,
					prevTabState: prevInstanceState,
					newPage,
					newTabPageState,
					newCamera,
				},
			}
		},
		{
			do: ({ newPage, newTabPageState, newCamera }) => {
				this.store.put([
					newPage,
					newCamera,
					newTabPageState,
					{ ...this.instanceState, currentPageId: newPage.id },
				])
				this.updateCullingBounds()
			},
			undo: ({ newPage, prevPageState, prevTabState, newTabPageState }) => {
				this.store.put([prevPageState, prevTabState])
				this.store.remove([newTabPageState.id, newPage.id, newTabPageState.cameraId])

				this.updateCullingBounds()
			},
		}
	)

	duplicatePage(id: TLPageId = this.currentPageId, createId: TLPageId = PageRecordType.createId()) {
		if (this.pages.length >= MAX_PAGES) return
		const page = this.getPageById(id)
		if (!page) return

		const camera = { ...this.camera }
		const content = this.getContent(this.getSortedChildIds(page.id))

		this.batch(() => {
			this.createPage(page.name + ' Copy', createId, page.index)
			this.setCurrentPageId(createId)
			this.setCamera(camera.x, camera.y, camera.z)

			// will change page automatically
			if (content) {
				return this.putContent(content)
			}
		})
	}

	/**
	 * Delete a page.
	 *
	 * @example
	 *
	 * ```ts
	 * app.deletePage('page1')
	 * ```
	 *
	 * @param id - The id of the page to delete.
	 * @public
	 */
	deletePage(id: TLPageId) {
		this._deletePage(id)
	}

	/** @internal */
	private _deletePage = this.history.createCommand(
		'delete_page',
		(id: TLPageId) => {
			if (this.isReadOnly) return null
			const { pages } = this
			if (pages.length === 1) return null

			const deletedPage = this.getPageById(id)
			const deletedPageStates = this._pageStates.value.filter((s) => s.pageId === id)

			if (!deletedPage) return null

			if (id === this.currentPageId) {
				const index = pages.findIndex((page) => page.id === id)
				const next = pages[index - 1] ?? pages[index + 1]
				this.setCurrentPageId(next.id)
			}

			return { data: { id, deletedPage, deletedPageStates } }
		},
		{
			do: ({ deletedPage, deletedPageStates }) => {
				const { pages } = this
				if (pages.length === 1) return

				if (deletedPage.id === this.currentPageId) {
					const index = pages.findIndex((page) => page.id === deletedPage.id)
					const next = pages[index - 1] ?? pages[index + 1]
					this.setCurrentPageId(next.id)
				}

				this.store.remove(deletedPageStates.map((s) => s.id)) // remove the page state
				this.store.remove([deletedPage.id]) // remove the page
				this.updateCullingBounds()
			},
			undo: ({ deletedPage, deletedPageStates }) => {
				this.store.put([deletedPage])
				this.store.put(deletedPageStates)
				this.updateCullingBounds()
			},
		}
	)

	/**
	 * Update a page state.
	 *
	 * @example
	 *
	 * ```ts
	 * app.setInstancePageState({ id: 'page1', editingId: 'shape:123' })
	 * app.setInstancePageState({ id: 'page1', editingId: 'shape:123' }, true)
	 * ```
	 *
	 * @param partial - The partial of the page state object containing the changes.
	 * @param ephemeral - Whether the command is ephemeral.
	 * @public
	 */
	setInstancePageState(partial: Partial<TLInstancePageState>, ephemeral = false) {
		this._setInstancePageState(partial, ephemeral)
	}

	/** @internal */
	private _setInstancePageState = this.history.createCommand(
		'setInstancePageState',
		(partial: Partial<TLInstancePageState>, ephemeral = false) => {
			const prev = this.store.get(partial.id ?? this.pageState.id)!
			return { data: { prev, partial }, ephemeral }
		},
		{
			do: ({ prev, partial }) => {
				this.store.update(prev.id, (state) => ({ ...state, ...partial }))
			},
			undo: ({ prev }) => {
				this.store.update(prev.id, () => prev)
			},
		}
	)

	/**
	 * Select one or more shapes.
	 *
	 * @example
	 *
	 * ```ts
	 * app.setSelectedIds(['id1'])
	 * app.setSelectedIds(['id1', 'id2'])
	 * ```
	 *
	 * @param ids - The ids to select.
	 * @param squashing - Whether the change should create a new history entry or combine with the
	 *   previous (if the previous is the same type).
	 * @public
	 */
	setSelectedIds(ids: TLShapeId[], squashing = false) {
		this._setSelectedIds(ids, squashing)
		return this
	}

	/** @internal */
	private _setSelectedIds = this.history.createCommand(
		'setSelectedIds',
		(ids: TLShapeId[], squashing = false) => {
			const prevSelectedIds = this.pageState.selectedIds

			const prevSet = new Set(this.pageState.selectedIds)

			if (ids.length === prevSet.size && ids.every((id) => prevSet.has(id))) return null

			return { data: { ids, prevSelectedIds }, squashing, preservesRedoStack: true }
		},
		{
			do: ({ ids }) => {
				this.store.update(this.pageState.id, (state) => ({ ...state, selectedIds: ids }))
			},
			undo: ({ prevSelectedIds }) => {
				this.store.update(this.pageState.id, () => ({
					...this.pageState,
					selectedIds: prevSelectedIds,
				}))
			},
			squash(prev, next) {
				return { ids: next.ids, prevSelectedIds: prev.prevSelectedIds }
			},
		}
	)

	/**
	 * Determine whether or not a shape is selected
	 *
	 * @example
	 *
	 * ```ts
	 * app.isSelected('id1')
	 * ```
	 *
	 * @param id - The id of the shape to check.
	 * @public
	 */
	isSelected(id: TLShapeId) {
		return this.selectedIdsSet.has(id)
	}

	/**
	 * Determine whether a not a shape is within the current selection. A shape is within the
	 * selection if it or any of its parents is selected.
	 *
	 * @param id - The id of the shape to check.
	 * @public
	 */
	isWithinSelection(id: TLShapeId) {
		const shape = this.getShapeById(id)
		if (!shape) return false

		if (this.isSelected(id)) return true

		return !!this.findAncestor(shape, (parent) => this.isSelected(parent.id))
	}

	/* --------------------- Assets --------------------- */

	/** @internal */
	@computed private get _assets() {
		return this.store.query.records('asset')
	}

	/** Get all assets in the app. */
	get assets() {
		return this._assets.value
	}

	/**
	 * Create one or more assets.
	 *
	 * @example
	 *
	 * ```ts
	 * app.createAssets([...myAssets])
	 * ```
	 *
	 * @param assets - The assets to create.
	 * @public
	 */
	createAssets(assets: TLAsset[]) {
		this._createAssets(assets)
		return this
	}

	/** @internal */
	private _createAssets = this.history.createCommand(
		'createAssets',
		(assets: TLAsset[]) => {
			if (this.isReadOnly) return null
			if (assets.length <= 0) return null

			return { data: { assets } }
		},
		{
			do: ({ assets }) => {
				this.store.put(assets)
			},
			undo: ({ assets }) => {
				// todo: should we actually remove assets here? or on cleanup elsewhere?
				this.store.remove(assets.map((a) => a.id))
			},
		}
	)

	/**
	 * Delete one or more assets.
	 *
	 * @example
	 *
	 * ```ts
	 * app.deleteAssets(['asset1', 'asset2'])
	 * ```
	 *
	 * @param ids - The assets to delete.
	 * @public
	 */
	deleteAssets(ids: TLAssetId[]) {
		this._deleteAssets(ids)
		return this
	}

	/** @internal */
	private _deleteAssets = this.history.createCommand(
		'deleteAssets',
		(ids: TLAssetId[]) => {
			if (this.isReadOnly) return
			if (ids.length <= 0) return

			const prev = compact(ids.map((id) => this.store.get(id)))

			return { data: { ids, prev } }
		},
		{
			do: ({ ids }) => {
				this.store.remove(ids)
			},
			undo: ({ prev }) => {
				this.store.put(prev)
			},
		}
	)

	/**
	 * Update one or more assets.
	 *
	 * @example
	 *
	 * ```ts
	 * app.updateAssets([{ id: 'asset1', name: 'New name' }])
	 * ```
	 *
	 * @param assets - The assets to update.
	 * @public
	 */
	updateAssets(assets: TLAssetPartial[]) {
		this._updateAssets(assets)
		return this
	}

	/** @internal */
	private _updateAssets = this.history.createCommand(
		'updateAssets',
		(assets: TLAssetPartial[]) => {
			if (this.isReadOnly) return
			if (assets.length <= 0) return

			const snapshots: Record<string, TLAsset> = {}

			return { data: { snapshots, assets } }
		},
		{
			do: ({ assets, snapshots }) => {
				this.store.put(
					assets.map((a) => {
						const asset = this.store.get(a.id)!
						snapshots[a.id] = asset

						return {
							...asset,
							...a,
						}
					})
				)
			},
			undo: ({ snapshots }) => {
				this.store.put(Object.values(snapshots))
			},
		}
	)

	/**
	 * Get an asset by its src property.
	 *
	 * @example
	 *
	 * ```ts
	 * app.getAssetBySource('https://example.com/image.png')
	 * ```
	 *
	 * @param src - The source value of the asset.
	 * @public
	 */
	getAssetBySrc(src: string) {
		return this.assets.find((a) => a.props.src === src)
	}

	/**
	 * Get an asset by its id.
	 *
	 * @example
	 *
	 * ```ts
	 * app.getAssetById('asset1')
	 * ```
	 *
	 * @param id - The id of the asset.
	 * @public
	 */
	getAssetById(id: TLAssetId): TLAsset | undefined {
		return this.store.get(id) as TLAsset | undefined
	}

	/* ------------------- SubCommands ------------------ */
	async getSvg(
		ids: TLShapeId[] = this.selectedIds.length
			? this.selectedIds
			: (Object.keys(this.shapeIds) as TLShapeId[]),
		opts = {} as Partial<{
			scale: number
			background: boolean
			padding: number
			darkMode?: boolean
			preserveAspectRatio: React.SVGAttributes<SVGSVGElement>['preserveAspectRatio']
		}>
	) {
		if (ids.length === 0) return
		if (!window.document) throw Error('No document')

		const {
			scale = 1,
			background = false,
			padding = SVG_PADDING,
			darkMode = this.isDarkMode,
			preserveAspectRatio = false,
		} = opts

		const realContainerEl = this.getContainer()
		const realContainerStyle = getComputedStyle(realContainerEl)

		// Get the styles from the container. We'll use these to pull out colors etc.
		// NOTE: We can force force a light theme here becasue we don't want export
		const fakeContainerEl = document.createElement('div')
		fakeContainerEl.className = `tl-container tl-theme__${
			darkMode ? 'dark' : 'light'
		} tl-theme__force-sRGB`
		document.body.appendChild(fakeContainerEl)

		const containerStyle = getComputedStyle(fakeContainerEl)
		const fontsUsedInExport = new Map<string, string>()

		const colors: TLExportColors = {
			fill: Object.fromEntries(
				STYLES.color.map((color) => [
					color.id,
					containerStyle.getPropertyValue(`--palette-${color.id}`),
				])
			) as Record<TLColorType, string>,
			pattern: Object.fromEntries(
				STYLES.color.map((color) => [
					color.id,
					containerStyle.getPropertyValue(`--palette-${color.id}-pattern`),
				])
			) as Record<TLColorType, string>,
			semi: Object.fromEntries(
				STYLES.color.map((color) => [
					color.id,
					containerStyle.getPropertyValue(`--palette-${color.id}-semi`),
				])
			) as Record<TLColorType, string>,
			highlight: Object.fromEntries(
				STYLES.color.map((color) => [
					color.id,
					containerStyle.getPropertyValue(`--palette-${color.id}-highlight`),
				])
			) as Record<TLColorType, string>,
			text: containerStyle.getPropertyValue(`--color-text`),
			background: containerStyle.getPropertyValue(`--color-background`),
			solid: containerStyle.getPropertyValue(`--palette-solid`),
		}

		// Remove containerEl from DOM (temp DOM node)
		document.body.removeChild(fakeContainerEl)

		// ---Figure out which shapes we need to include
		const shapeIdsToInclude = this.getShapeAndDescendantIds(ids)
		const renderingShapes = this.computeUnorderedRenderingShapes([this.currentPageId]).filter(
			({ id }) => shapeIdsToInclude.has(id)
		)

		// --- Common bounding box of all shapes
		let bbox = null
		for (const { maskedPageBounds } of renderingShapes) {
			if (!maskedPageBounds) continue
			if (bbox) {
				bbox.union(maskedPageBounds)
			} else {
				bbox = maskedPageBounds.clone()
			}
		}

		// no unmasked shapes to export
		if (!bbox) return

		const singleFrameShapeId =
			ids.length === 1 && this.getShapeById(ids[0])?.type === 'frame' ? ids[0] : null
		if (!singleFrameShapeId) {
			// Expand by an extra 32 pixels
			bbox.expandBy(padding)
		}

		// We want the svg image to be BIGGER THAN USUAL to account for image quality
		const w = bbox.width * scale
		const h = bbox.height * scale

		// --- Create the SVG

		// Embed our custom fonts
		const svg = window.document.createElementNS('http://www.w3.org/2000/svg', 'svg')

		if (preserveAspectRatio) {
			svg.setAttribute('preserveAspectRatio', preserveAspectRatio)
		}

		svg.setAttribute('direction', 'ltr')
		svg.setAttribute('width', w + '')
		svg.setAttribute('height', h + '')
		svg.setAttribute('viewBox', `${bbox.minX} ${bbox.minY} ${bbox.width} ${bbox.height}`)
		svg.setAttribute('stroke-linecap', 'round')
		svg.setAttribute('stroke-linejoin', 'round')
		// Add current background color, or else background will be transparent

		if (background) {
			if (singleFrameShapeId) {
				svg.style.setProperty('background', colors.solid)
			} else {
				svg.style.setProperty('background-color', colors.background)
			}
		} else {
			svg.style.setProperty('background-color', 'transparent')
		}

		// Add the defs to the svg
		const defs = window.document.createElementNS('http://www.w3.org/2000/svg', 'defs')

		for (const element of Array.from(exportPatternSvgDefs(colors.solid))) {
			defs.appendChild(element)
		}

		try {
			document.body.focus?.() // weird but necessary
		} catch (e) {
			// not implemented
		}

		svg.append(defs)

		const unorderedShapeElements = (
			await Promise.all(
				renderingShapes.map(async ({ id, opacity, index, backgroundIndex }) => {
					// Don't render the frame if we're only exporting a single frame
					if (id === singleFrameShapeId) return []

					const shape = this.getShapeById(id)!
					const util = this.getShapeUtil(shape)

					let font: string | undefined
					if ('font' in shape.props) {
						if (shape.props.font) {
							if (fontsUsedInExport.has(shape.props.font)) {
								font = fontsUsedInExport.get(shape.props.font)!
							} else {
								// For some reason these styles aren't present in the fake element
								// so we need to get them from the real element
								font = realContainerStyle.getPropertyValue(`--tl-font-${shape.props.font}`)
								fontsUsedInExport.set(shape.props.font, font)
							}
						}
					}

					let shapeSvgElement = await util.toSvg?.(shape, font, colors)
					let backgroundSvgElement = await util.toBackgroundSvg?.(shape, font, colors)

					// wrap the shapes in groups so we can apply properties without overwriting ones from the shape util
					if (shapeSvgElement) {
						const outerElement = document.createElementNS('http://www.w3.org/2000/svg', 'g')
						outerElement.appendChild(shapeSvgElement)
						shapeSvgElement = outerElement
					}
					if (backgroundSvgElement) {
						const outerElement = document.createElementNS('http://www.w3.org/2000/svg', 'g')
						outerElement.appendChild(backgroundSvgElement)
						backgroundSvgElement = outerElement
					}

					if (!shapeSvgElement && !backgroundSvgElement) {
						const bounds = this.getPageBounds(shape)!
						const elm = window.document.createElementNS('http://www.w3.org/2000/svg', 'rect')
						elm.setAttribute('width', bounds.width + '')
						elm.setAttribute('height', bounds.height + '')
						elm.setAttribute('fill', colors.solid)
						elm.setAttribute('stroke', colors.pattern.grey)
						elm.setAttribute('stroke-width', '1')
						shapeSvgElement = elm
					}

					let pageTransform = this.getPageTransform(shape)!.toCssString()
					if ('scale' in shape.props) {
						if (shape.props.scale !== 1) {
							pageTransform = `${pageTransform} scale(${shape.props.scale}, ${shape.props.scale})`
						}
					}

					shapeSvgElement?.setAttribute('transform', pageTransform)
					backgroundSvgElement?.setAttribute('transform', pageTransform)
					shapeSvgElement?.setAttribute('opacity', opacity + '')
					backgroundSvgElement?.setAttribute('opacity', opacity + '')

					// Create svg mask if shape has a frame as parent
					const pageMask = this.getPageMaskById(shape.id)
					if (pageMask) {
						// Create a clip path and add it to defs
						const clipPathEl = document.createElementNS('http://www.w3.org/2000/svg', 'clipPath')
						defs.appendChild(clipPathEl)
						const id = nanoid()
						clipPathEl.id = id

						// Create a polyline mask that does the clipping
						const mask = document.createElementNS('http://www.w3.org/2000/svg', 'path')
						mask.setAttribute('d', `M${pageMask.map(({ x, y }) => `${x},${y}`).join('L')}Z`)
						clipPathEl.appendChild(mask)

						// Create group that uses the clip path and wraps the shape elements
						if (shapeSvgElement) {
							const outerElement = document.createElementNS('http://www.w3.org/2000/svg', 'g')
							outerElement.setAttribute('clip-path', `url(#${id})`)
							outerElement.appendChild(shapeSvgElement)
							shapeSvgElement = outerElement
						}

						if (backgroundSvgElement) {
							const outerElement = document.createElementNS('http://www.w3.org/2000/svg', 'g')
							outerElement.setAttribute('clip-path', `url(#${id})`)
							outerElement.appendChild(backgroundSvgElement)
							backgroundSvgElement = outerElement
						}
					}

					const elements = []
					if (shapeSvgElement) {
						elements.push({ zIndex: index, element: shapeSvgElement })
					}
					if (backgroundSvgElement) {
						elements.push({ zIndex: backgroundIndex, element: backgroundSvgElement })
					}

					return elements
				})
			)
		).flat()

		for (const { element } of unorderedShapeElements.sort((a, b) => a.zIndex - b.zIndex)) {
			svg.appendChild(element)
		}

		// Add styles to the defs
		let styles = ``
		const style = window.document.createElementNS('http://www.w3.org/2000/svg', 'style')

		// Insert fonts into app
		const fontInstances: FontFace[] = []

		if ('fonts' in document) {
			document.fonts.forEach((font) => fontInstances.push(font))
		}

		await Promise.all(
			fontInstances.map(async (font) => {
				const fileReader = new FileReader()

				let isUsed = false

				fontsUsedInExport.forEach((fontName) => {
					if (fontName.includes(font.family)) {
						isUsed = true
					}
				})

				if (!isUsed) return

				const url = (font as any).$$_url

				const fontFaceRule = (font as any).$$_fontface

				if (url) {
					const fontFile = await (await fetch(url)).blob()

					const base64Font = await new Promise<string>((resolve, reject) => {
						fileReader.onload = () => resolve(fileReader.result as string)
						fileReader.onerror = () => reject(fileReader.error)
						fileReader.readAsDataURL(fontFile)
					})

					const newFontFaceRule = '\n' + fontFaceRule.replaceAll(url, base64Font)
					styles += newFontFaceRule
				}
			})
		)

		style.textContent = styles

		defs.append(style)

		return svg
	}

	/**
	 * Rename a page.
	 *
	 * @example
	 *
	 * ```ts
	 * app.renamePage('page1', 'My Page')
	 * ```
	 *
	 * @param id - The id of the page to rename.
	 * @param name - The new name.
	 * @public
	 */
	renamePage(id: TLPageId, name: string, squashing = false) {
		if (this.isReadOnly) return this
		this.updatePage({ id, name }, squashing)
		return this
	}

	/**
	 * Move shapes to page.
	 *
	 * @example
	 *
	 * ```ts
	 * app.moveShapesToPage(['box1', 'box2'], 'page1')
	 * ```
	 *
	 * @param ids - The ids of the shapes to move.
	 * @param pageId - The id of the page where the shapes will be moved.
	 * @public
	 */
	moveShapesToPage(ids: TLShapeId[], pageId: TLPageId): this {
		if (ids.length === 0) return this
		if (this.isReadOnly) return this

		const { currentPageId } = this

		if (pageId === currentPageId) return this
		if (!this.store.has(pageId)) return this

		// Basically copy the shapes
		const content = this.getContent(ids)

		// Just to be sure
		if (!content) return this

		// If there is no space on pageId, or if the selected shapes
		// would take the new page above the limit, don't move the shapes
		if (this.getShapeIdsInPage(pageId).size + content.shapes.length > MAX_SHAPES_PER_PAGE) {
			alertMaxShapes(this, pageId)
			return this
		}

		const fromPageZ = this.camera.z

		this.history.batch(() => {
			// Delete the shapes on the current page
			this.deleteShapes(ids)

			// Move to the next page
			this.setCurrentPageId(pageId)

			// Put the shape content onto the new page; parents and indices will
			// be taken care of by the putContent method; make sure to pop any focus
			// layers so that the content will be put onto the page.
			this.setFocusLayer(null)
			this.selectNone()
			this.putContent(content, { select: true, preserveIds: true, preservePosition: true })

			// Force the new page's camera to be at the same zoom level as the
			// "from" page's camera, then center the "to" page's camera on the
			// pasted shapes
			const {
				center: { x, y },
			} = this.selectionBounds!
			this.setCamera(this.camera.x, this.camera.y, fromPageZ)
			this.centerOnPoint(x, y)
		})

		return this
	}

	toggleLock(ids: TLShapeId[] = this.selectedIds): this {
		if (this.isReadOnly || ids.length === 0) return this

		let allLocked = true,
			allUnlocked = true
		const shapes: TLShape[] = []
		for (const id of ids) {
			const shape = this.getShapeById(id)
			if (shape) {
				shapes.push(shape)
				if (shape.isLocked) {
					allUnlocked = false
				} else {
					allLocked = false
				}
			}
		}
		if (allUnlocked) {
			this.updateShapes(shapes.map((shape) => ({ id: shape.id, type: shape.type, isLocked: true })))
			this.setSelectedIds([])
		} else if (allLocked) {
			this.updateShapes(
				shapes.map((shape) => ({ id: shape.id, type: shape.type, isLocked: false }))
			)
		} else {
			this.updateShapes(shapes.map((shape) => ({ id: shape.id, type: shape.type, isLocked: true })))
		}

		return this
	}

	/**
	 * Reorder shapes.
	 *
	 * @param operation - The operation to perform.
	 * @param ids - The ids to reorder.
	 * @public
	 */
	reorderShapes(operation: 'toBack' | 'toFront' | 'forward' | 'backward', ids: TLShapeId[]) {
		if (this.isReadOnly) return this
		if (ids.length === 0) return this
		// this.emit('reorder-shapes', { pageId: this.currentPageId, ids, operation })

		const parents = this.getParentsMappedToChildren(ids)

		const changes: TLShapePartial[] = []

		switch (operation) {
			case 'toBack': {
				parents.forEach((movingSet, parentId) => {
					const siblings = compact(
						this.getSortedChildIds(parentId).map((id) => this.getShapeById(id))
					)

					if (movingSet.size === siblings.length) return

					let below: string | undefined
					let above: string | undefined

					for (const shape of siblings) {
						if (!movingSet.has(shape)) {
							above = shape.index
							break
						}
						movingSet.delete(shape)
						below = shape.index
					}

					if (movingSet.size === 0) return

					const indices = getIndicesBetween(below, above, movingSet.size)

					Array.from(movingSet.values())
						.sort(sortByIndex)
						.forEach((node, i) =>
							changes.push({ id: node.id as any, type: node.type, index: indices[i] })
						)
				})

				break
			}
			case 'toFront': {
				parents.forEach((movingSet, parentId) => {
					const siblings = compact(
						this.getSortedChildIds(parentId).map((id) => this.getShapeById(id))
					)
					const len = siblings.length

					if (movingSet.size === len) return

					let below: string | undefined
					let above: string | undefined

					for (let i = len - 1; i > -1; i--) {
						const shape = siblings[i]

						if (!movingSet.has(shape)) {
							below = shape.index
							break
						}

						movingSet.delete(shape)
						above = shape.index
					}

					if (movingSet.size === 0) return

					const indices = getIndicesBetween(below, above, movingSet.size)

					Array.from(movingSet.values())
						.sort(sortByIndex)
						.forEach((node, i) =>
							changes.push({ id: node.id as any, type: node.type, index: indices[i] })
						)
				})

				break
			}
			case 'forward': {
				parents.forEach((movingSet, parentId) => {
					const siblings = compact(
						this.getSortedChildIds(parentId).map((id) => this.getShapeById(id))
					)
					const len = siblings.length

					if (movingSet.size === len) return

					const movingIndices = new Set(Array.from(movingSet).map((n) => siblings.indexOf(n)))

					let selectIndex = -1
					let isSelecting = false
					let below: string | undefined
					let above: string | undefined
					let count: number

					for (let i = 0; i < len; i++) {
						const isMoving = movingIndices.has(i)

						if (!isSelecting && isMoving) {
							isSelecting = true
							selectIndex = i
							above = undefined
						} else if (isSelecting && !isMoving) {
							isSelecting = false
							count = i - selectIndex
							below = siblings[i].index
							above = siblings[i + 1]?.index

							const indices = getIndicesBetween(below, above, count)

							for (let k = 0; k < count; k++) {
								const node = siblings[selectIndex + k]
								changes.push({ id: node.id as any, type: node.type, index: indices[k] })
							}
						}
					}
				})

				break
			}
			case 'backward': {
				parents.forEach((movingSet, parentId) => {
					const siblings = compact(
						this.getSortedChildIds(parentId).map((id) => this.getShapeById(id))
					)
					const len = siblings.length

					if (movingSet.size === len) return

					const movingIndices = new Set(Array.from(movingSet).map((n) => siblings.indexOf(n)))

					let selectIndex = -1
					let isSelecting = false
					let count: number

					for (let i = len - 1; i > -1; i--) {
						const isMoving = movingIndices.has(i)

						if (!isSelecting && isMoving) {
							isSelecting = true
							selectIndex = i
						} else if (isSelecting && !isMoving) {
							isSelecting = false
							count = selectIndex - i

							const indices = getIndicesBetween(siblings[i - 1]?.index, siblings[i].index, count)

							for (let k = 0; k < count; k++) {
								const node = siblings[i + k + 1]
								changes.push({ id: node.id as any, type: node.type, index: indices[k] })
							}
						}
					}
				})

				break
			}
		}

		this.updateShapes(changes)
		return this
	}

	/**
	 * Send shapes to the back of the page's object list.
	 *
	 * @example
	 *
	 * ```ts
	 * app.sendToBack()
	 * app.sendToBack(['id1', 'id2'])
	 * ```
	 *
	 * @param ids - The ids of the shapes to move. Defaults to the ids of the selected shapes.
	 * @public
	 */
	sendToBack(ids = this.pageState.selectedIds) {
		this.reorderShapes('toBack', ids)
		return this
	}

	/**
	 * Send shapes backward in the page's object list.
	 *
	 * @example
	 *
	 * ```ts
	 * app.sendBackward()
	 * app.sendBackward(['id1', 'id2'])
	 * ```
	 *
	 * @param ids - The ids of the shapes to move. Defaults to the ids of the selected shapes.
	 * @public
	 */
	sendBackward(ids = this.pageState.selectedIds) {
		this.reorderShapes('backward', ids)
		return this
	}

	/**
	 * Bring shapes forward in the page's object list.
	 *
	 * @example
	 *
	 * ```ts
	 * app.bringForward()
	 * app.bringForward(['id1', 'id2'])
	 * ```
	 *
	 * @param ids - The ids of the shapes to move. Defaults to the ids of the selected shapes.
	 * @public
	 */
	bringForward(ids = this.pageState.selectedIds) {
		this.reorderShapes('forward', ids)
		return this
	}

	/**
	 * Bring shapes to the front of the page's object list.
	 *
	 * @example
	 *
	 * ```ts
	 * app.bringToFront()
	 * app.bringToFront(['id1', 'id2'])
	 * ```
	 *
	 * @param ids - The ids of the shapes to move. Defaults to the ids of the selected shapes.
	 * @public
	 */
	bringToFront(ids = this.pageState.selectedIds) {
		this.reorderShapes('toFront', ids)
		return this
	}

	/**
	 * Flip shape positions.
	 *
	 * @example
	 *
	 * ```ts
	 * app.flipShapes('horizontal')
	 * app.flipShapes('horizontal', ['box1', 'box2'])
	 * ```
	 *
	 * @param operation - Whether to flip horizontally or vertically.
	 * @param ids - The ids of the shapes to flip. Defaults to selected shapes.
	 * @public
	 */
	flipShapes(operation: 'horizontal' | 'vertical', ids: TLShapeId[] = this.selectedIds) {
		if (this.isReadOnly) return this

		let shapes = compact(ids.map((id) => this.getShapeById(id)))

		if (!shapes.length) return this

		shapes = compact(
			shapes
				.map((shape) => {
					if (shape.type === 'group') {
						return this.getSortedChildIds(shape.id).map((id) => this.getShapeById(id))
					}

					return shape
				})
				.flat()
		)

		const scaleOriginPage = Box2d.Common(compact(shapes.map((id) => this.getPageBounds(id)))).center

		this.batch(() => {
			for (const shape of shapes) {
				const util = this.getShapeUtil(shape)
				const bounds = util.bounds(shape)
				const initialPageTransform = this.getPageTransformById(shape.id)
				if (!initialPageTransform) continue
				this.resizeShape(
					shape.id,
					{ x: operation === 'horizontal' ? -1 : 1, y: operation === 'vertical' ? -1 : 1 },
					{
						initialBounds: bounds,
						initialPageTransform,
						initialShape: shape,
						mode: 'scale_shape',
						scaleOrigin: scaleOriginPage,
						scaleAxisRotation: 0,
					}
				)
			}
		})

		return this
	}

	/**
	 * Stack shape.
	 *
	 * @example
	 *
	 * ```ts
	 * app.stackShapes('horizontal')
	 * app.stackShapes('horizontal', ['box1', 'box2'])
	 * app.stackShapes('horizontal', ['box1', 'box2'], 20)
	 * ```
	 *
	 * @param operation - Whether to stack horizontally or vertically.
	 * @param ids - The ids of the shapes to stack. Defaults to selected shapes.
	 * @param gap - A specific gap to use when stacking.
	 * @public
	 */
	stackShapes(
		operation: 'horizontal' | 'vertical',
		ids: TLShapeId[] = this.pageState.selectedIds,
		gap?: number
	) {
		if (this.isReadOnly) return this

		const shapes = compact(ids.map((id) => this.getShapeById(id))).filter((shape) => {
			if (!shape) return false

			if (this.isShapeOfType(shape, TLArrowUtil)) {
				if (shape.props.start.type === 'binding' || shape.props.end.type === 'binding') {
					return false
				}
			}

			return true
		})

		const len = shapes.length

		if ((gap === undefined && len < 3) || len < 2) return this

		const pageBounds = Object.fromEntries(
			shapes.map((shape) => [shape.id, this.getPageBounds(shape)!])
		)

		let val: 'x' | 'y'
		let min: 'minX' | 'minY'
		let max: 'maxX' | 'maxY'
		let dim: 'width' | 'height'

		if (operation === 'horizontal') {
			val = 'x'
			min = 'minX'
			max = 'maxX'
			dim = 'width'
		} else {
			val = 'y'
			min = 'minY'
			max = 'maxY'
			dim = 'height'
		}

		let shapeGap: number

		if (gap === undefined) {
			const gaps: { gap: number; count: number }[] = []

			shapes.sort((a, b) => pageBounds[a.id][min] - pageBounds[b.id][min])

			// Collect all of the gaps between shapes. We want to find
			// patterns (equal gaps between shapes) and use the most common
			// one as the gap for all of the shapes.
			for (let i = 0; i < len - 1; i++) {
				const shape = shapes[i]
				const nextShape = shapes[i + 1]

				const bounds = pageBounds[shape.id]
				const nextBounds = pageBounds[nextShape.id]

				const gap = nextBounds[min] - bounds[max]

				const current = gaps.find((g) => g.gap === gap)

				if (current) {
					current.count++
				} else {
					gaps.push({ gap, count: 1 })
				}
			}

			// Which gap is the most common?
			let maxCount = 0
			gaps.forEach((g) => {
				if (g.count > maxCount) {
					maxCount = g.count
					shapeGap = g.gap
				}
			})

			// If there is no most-common gap, use the average gap.
			if (maxCount === 1) {
				shapeGap = Math.max(0, gaps.reduce((a, c) => a + c.gap * c.count, 0) / (len - 1))
			}
		} else {
			// If a gap was provided, then use that instead.
			shapeGap = gap
		}

		const changes: TLShapePartial[] = []

		let v = pageBounds[shapes[0].id][max]

		shapes.forEach((shape, i) => {
			if (i === 0) return

			const delta = { x: 0, y: 0 }
			delta[val] = v + shapeGap - pageBounds[shape.id][val]

			const parent = this.getParentShape(shape)
			const localDelta = parent ? Vec2d.Rot(delta, -this.getPageRotation(parent)) : delta

			const translateStartChanges = this.getShapeUtil(shape).onTranslateStart?.(shape)

			changes.push(
				translateStartChanges
					? {
							...translateStartChanges,
							[val]: shape[val] + localDelta[val],
					  }
					: {
							id: shape.id as any,
							type: shape.type,
							[val]: shape[val] + localDelta[val],
					  }
			)

			v += pageBounds[shape.id][dim] + shapeGap
		})

		this.updateShapes(changes)
		return this
	}

	/**
	 * Pack shapes into a grid centered on their current position. Based on potpack
	 * (https://github.com/mapbox/potpack)
	 *
	 * @param ids - The ids of the shapes to pack. Defaults to selected shapes.
	 * @param padding - The padding to apply to the packed shapes.
	 */
	packShapes(ids: TLShapeId[] = this.pageState.selectedIds, padding = 16) {
		if (this.isReadOnly) return this
		if (ids.length < 2) return this

		const shapes = compact(
			ids
				.map((id) => this.getShapeById(id))
				.filter((shape) => {
					if (!shape) return false

					if (this.isShapeOfType(shape, TLArrowUtil)) {
						if (shape.props.start.type === 'binding' || shape.props.end.type === 'binding') {
							return false
						}
					}

					return true
				})
		)
		const shapePageBounds: Record<string, Box2d> = {}
		const nextShapePageBounds: Record<string, Box2d> = {}

		let shape: TLShape,
			bounds: Box2d,
			area = 0

		for (let i = 0; i < shapes.length; i++) {
			shape = shapes[i]
			bounds = this.getPageBounds(shape)!
			shapePageBounds[shape.id] = bounds
			nextShapePageBounds[shape.id] = bounds.clone()
			area += bounds.width * bounds.height
		}

		const commonBounds = Box2d.Common(compact(Object.values(shapePageBounds)))

		const maxWidth = commonBounds.width

		// sort the shapes by height, descending
		shapes.sort((a, b) => shapePageBounds[b.id].height - shapePageBounds[a.id].height)

		// Start with is (sort of) the square of the area
		const startWidth = Math.max(Math.ceil(Math.sqrt(area / 0.95)), maxWidth)

		// first shape fills the width and is infinitely tall
		const spaces: Box2d[] = [new Box2d(commonBounds.x, commonBounds.y, startWidth, Infinity)]

		let width = 0
		let height = 0
		let space: Box2d
		let last: Box2d

		for (let i = 0; i < shapes.length; i++) {
			shape = shapes[i]
			bounds = nextShapePageBounds[shape.id]

			// starting at the back (smaller shapes)
			for (let i = spaces.length - 1; i >= 0; i--) {
				space = spaces[i]

				// find a space that is big enough to contain the shape
				if (bounds.width > space.width || bounds.height > space.height) continue

				// add the shape to its top-left corner
				bounds.x = space.x
				bounds.y = space.y

				height = Math.max(height, bounds.maxY)
				width = Math.max(width, bounds.maxX)

				if (bounds.width === space.width && bounds.height === space.height) {
					// remove the space on a perfect fit
					last = spaces.pop()!
					if (i < spaces.length) spaces[i] = last
				} else if (bounds.height === space.height) {
					// fit the shape into the space (width)
					space.x += bounds.width + padding
					space.width -= bounds.width + padding
				} else if (bounds.width === space.width) {
					// fit the shape into the space (height)
					space.y += bounds.height + padding
					space.height -= bounds.height + padding
				} else {
					// split the space into two spaces
					spaces.push(
						new Box2d(
							space.x + (bounds.width + padding),
							space.y,
							space.width - (bounds.width + padding),
							bounds.height
						)
					)
					space.y += bounds.height + padding
					space.height -= bounds.height + padding
				}
				break
			}
		}

		const commonAfter = Box2d.Common(Object.values(nextShapePageBounds))
		const centerDelta = Vec2d.Sub(commonBounds.center, commonAfter.center)

		let nextBounds: Box2d

		const changes: TLShapePartial<any>[] = []

		for (let i = 0; i < shapes.length; i++) {
			shape = shapes[i]
			bounds = shapePageBounds[shape.id]
			nextBounds = nextShapePageBounds[shape.id]

			const delta = this.getDeltaInParentSpace(
				shape,
				Vec2d.Sub(nextBounds.point, bounds.point).add(centerDelta)
			)

			const change: TLShapePartial = {
				id: shape.id,
				type: shape.type,
				x: shape.x + delta.x,
				y: shape.y + delta.y,
			}

			const translateStartChange = this.getShapeUtil(shape).onTranslateStart?.({
				...shape,
				...change,
			})

			if (translateStartChange) {
				changes.push({ ...change, ...translateStartChange })
			} else {
				changes.push(change)
			}
		}

		if (changes.length) {
			this.updateShapes(changes)
		}

		return this
	}

	/**
	 * Align shape positions.
	 *
	 * @example
	 *
	 * ```ts
	 * app.alignShapes('left')
	 * app.alignShapes('left', ['box1', 'box2'])
	 * ```
	 *
	 * @param operation - The align operation to apply.
	 * @param ids - The ids of the shapes to align. Defaults to selected shapes.
	 * @public
	 */
	alignShapes(
		operation: 'left' | 'center-horizontal' | 'right' | 'top' | 'center-vertical' | 'bottom',
		ids: TLShapeId[] = this.pageState.selectedIds
	) {
		if (this.isReadOnly) return this
		if (ids.length < 2) return this

		const shapes = compact(ids.map((id) => this.getShapeById(id)))
		const shapePageBounds = Object.fromEntries(
			shapes.map((shape) => [shape.id, this.getPageBounds(shape)])
		)
		const commonBounds = Box2d.Common(compact(Object.values(shapePageBounds)))

		const changes: TLShapePartial[] = []

		shapes.forEach((shape) => {
			const pageBounds = shapePageBounds[shape.id]
			if (!pageBounds) return

			const delta = { x: 0, y: 0 }

			switch (operation) {
				case 'top': {
					delta.y = commonBounds.minY - pageBounds.minY
					break
				}
				case 'center-vertical': {
					delta.y = commonBounds.midY - pageBounds.minY - pageBounds.height / 2
					break
				}
				case 'bottom': {
					delta.y = commonBounds.maxY - pageBounds.minY - pageBounds.height
					break
				}
				case 'left': {
					delta.x = commonBounds.minX - pageBounds.minX
					break
				}
				case 'center-horizontal': {
					delta.x = commonBounds.midX - pageBounds.minX - pageBounds.width / 2
					break
				}
				case 'right': {
					delta.x = commonBounds.maxX - pageBounds.minX - pageBounds.width
					break
				}
			}

			const parent = this.getParentShape(shape)
			const localDelta = parent ? Vec2d.Rot(delta, -this.getPageRotation(parent)) : delta

			const translateChanges = this.getShapeUtil(shape).onTranslateStart?.(shape)

			changes.push(
				translateChanges
					? {
							...translateChanges,
							x: shape.x + localDelta.x,
							y: shape.y + localDelta.y,
					  }
					: {
							id: shape.id,
							type: shape.type,
							x: shape.x + localDelta.x,
							y: shape.y + localDelta.y,
					  }
			)
		})

		this.updateShapes(changes)
		return this
	}

	/**
	 * Distribute shape positions.
	 *
	 * @example
	 *
	 * ```ts
	 * app.distributeShapes('left')
	 * app.distributeShapes('left', ['box1', 'box2'])
	 * ```
	 *
	 * @param operation - Whether to distribute shapes horizontally or vertically.
	 * @param ids - The ids of the shapes to distribute. Defaults to selected shapes.
	 * @public
	 */
	distributeShapes(
		operation: 'horizontal' | 'vertical',
		ids: TLShapeId[] = this.pageState.selectedIds
	) {
		if (this.isReadOnly) return this
		if (ids.length < 3) return this

		const len = ids.length
		const shapes = compact(ids.map((id) => this.getShapeById(id)))
		const pageBounds = Object.fromEntries(
			shapes.map((shape) => [shape.id, this.getPageBounds(shape)!])
		)

		let val: 'x' | 'y'
		let min: 'minX' | 'minY'
		let max: 'maxX' | 'maxY'
		let mid: 'midX' | 'midY'
		let dim: 'width' | 'height'

		if (operation === 'horizontal') {
			val = 'x'
			min = 'minX'
			max = 'maxX'
			mid = 'midX'
			dim = 'width'
		} else {
			val = 'y'
			min = 'minY'
			max = 'maxY'
			mid = 'midY'
			dim = 'height'
		}
		const changes: TLShapePartial[] = []

		// Clustered
		const first = shapes.sort((a, b) => pageBounds[a.id][min] - pageBounds[b.id][min])[0]
		const last = shapes.sort((a, b) => pageBounds[b.id][max] - pageBounds[a.id][max])[0]

		const midFirst = pageBounds[first.id][mid]
		const step = (pageBounds[last.id][mid] - midFirst) / (len - 1)
		const v = midFirst + step

		shapes
			.filter((shape) => shape !== first && shape !== last)
			.sort((a, b) => pageBounds[a.id][mid] - pageBounds[b.id][mid])
			.forEach((shape, i) => {
				const delta = { x: 0, y: 0 }
				delta[val] = v + step * i - pageBounds[shape.id][dim] / 2 - pageBounds[shape.id][val]

				const parent = this.getParentShape(shape)
				const localDelta = parent ? Vec2d.Rot(delta, -this.getPageRotation(parent)) : delta
				const translateStartChanges = this.getShapeUtil(shape).onTranslateStart?.(shape)

				changes.push(
					translateStartChanges
						? {
								...translateStartChanges,
								[val]: shape[val] + localDelta[val],
						  }
						: {
								id: shape.id,
								type: shape.type,
								[val]: shape[val] + localDelta[val],
						  }
				)
			})

		this.updateShapes(changes)
		return this
	}

	/** @internal */
	private _resizeUnalignedShape(
		id: TLShapeId,
		scale: VecLike,
		options: {
			initialBounds: Box2d
			scaleOrigin: VecLike
			scaleAxisRotation: number
			initialShape: TLShape
			initialPageTransform: MatLike
		}
	) {
		const { type } = options.initialShape
		// If a shape is not aligned with the scale axis we need to treat it differently to avoid skewing.
		// Instead of skewing we normalise the scale aspect ratio (i.e. keep the same scale magnitude in both axes)
		// and then after applying the scale to the shape we also rotate it if required and translate it so that it's center
		// point ends up in the right place.

		const shapeScale = new Vec2d(scale.x, scale.y)

		// // make sure we are contraining aspect ratio, and using the smallest scale axis to avoid shapes getting bigger
		// // than the selection bounding box
		if (Math.abs(scale.x) > Math.abs(scale.y)) {
			shapeScale.x = Math.sign(scale.x) * Math.abs(scale.y)
		} else {
			shapeScale.y = Math.sign(scale.y) * Math.abs(scale.x)
		}

		// first we can scale the shape about its center point
		this.resizeShape(id, shapeScale, {
			initialShape: options.initialShape,
			initialBounds: options.initialBounds,
		})

		// then if the shape is flipped in one axis only, we need to apply an extra rotation
		// to make sure the shape is mirrored correctly
		if (Math.sign(scale.x) * Math.sign(scale.y) < 0) {
			let { rotation } = Matrix2d.Decompose(options.initialPageTransform)
			rotation -= 2 * rotation
			this.updateShapes([{ id, type, rotation }], true)
		}

		// Next we need to translate the shape so that it's center point ends up in the right place.
		// To do that we first need to calculate the center point of the shape in page space before the scale was applied.
		const preScaleShapePageCenter = Matrix2d.applyToPoint(
			options.initialPageTransform,
			options.initialBounds.center
		)

		// And now we scale the center point by the original scale factor
		const postScaleShapePageCenter = this._scalePagePoint(
			preScaleShapePageCenter,
			options.scaleOrigin,
			scale,
			options.scaleAxisRotation
		)

		// now caculate how far away the shape is from where it needs to be
		const currentPageCenter = this.getPageCenterById(id)
		const currentPagePoint = this.getPagePointById(id)
		if (!currentPageCenter || !currentPagePoint) return this
		const pageDelta = Vec2d.Sub(postScaleShapePageCenter, currentPageCenter)

		// and finally figure out what the shape's new position should be
		const postScaleShapePagePoint = Vec2d.Add(currentPagePoint, pageDelta)
		const { x, y } = this.getPointInParentSpace(id, postScaleShapePagePoint)

		this.updateShapes([{ id, type, x, y }], true)

		return this
	}

	/** @internal */
	private _scalePagePoint(
		point: VecLike,
		scaleOrigin: VecLike,
		scale: VecLike,
		scaleAxisRotation: number
	) {
		const relativePoint = Vec2d.RotWith(point, scaleOrigin, -scaleAxisRotation).sub(scaleOrigin)

		// calculate the new point position relative to the scale origin
		const newRelativePagePoint = Vec2d.MulV(relativePoint, scale)

		// and rotate it back to page coords to get the new page point of the resized shape
		const destination = Vec2d.Add(newRelativePagePoint, scaleOrigin).rotWith(
			scaleOrigin,
			scaleAxisRotation
		)

		return destination
	}

	resizeShape(
		id: TLShapeId,
		scale: VecLike,
		options?: {
			initialBounds?: Box2d
			scaleOrigin?: VecLike
			scaleAxisRotation?: number
			initialShape?: TLShape
			initialPageTransform?: MatLike
			dragHandle?: TLResizeHandle
			mode?: TLResizeMode
		}
	) {
		if (this.isReadOnly) return this

		if (!Number.isFinite(scale.x)) scale = new Vec2d(1, scale.y)
		if (!Number.isFinite(scale.y)) scale = new Vec2d(scale.x, 1)

		const initialShape = options?.initialShape ?? this.getShapeById(id)
		if (!initialShape) return this

		const scaleOrigin = options?.scaleOrigin ?? this.getPageBoundsById(id)?.center
		if (!scaleOrigin) return this

		const pageRotation = this.getPageRotationById(id)

		if (pageRotation == null) return this

		const scaleAxisRotation = options?.scaleAxisRotation ?? pageRotation

		const pageTransform = options?.initialPageTransform ?? this.getPageTransformById(id)
		if (!pageTransform) return this

		const initialBounds = options?.initialBounds ?? this.getBoundsById(id)

		if (!initialBounds) return this

		if (!areAnglesCompatible(pageRotation, scaleAxisRotation)) {
			// shape is awkwardly rotated, keep the aspect ratio locked and adopt the scale factor
			// from whichever axis is being scaled the least, to avoid the shape getting bigger
			// than the bounds of the selection
			// const minScale = Math.min(Math.abs(scale.x), Math.abs(scale.y))
			return this._resizeUnalignedShape(id, scale, {
				...options,
				initialBounds,
				scaleOrigin,
				scaleAxisRotation,
				initialPageTransform: pageTransform,
				initialShape,
			})
		}

		const util = this.getShapeUtil(initialShape)

		if (util.isAspectRatioLocked(initialShape)) {
			if (Math.abs(scale.x) > Math.abs(scale.y)) {
				scale = new Vec2d(scale.x, Math.sign(scale.y) * Math.abs(scale.x))
			} else {
				scale = new Vec2d(Math.sign(scale.x) * Math.abs(scale.y), scale.y)
			}
		}

		if (util.onResize && util.canResize(initialShape)) {
			// get the model changes from the shape util
			const newPagePoint = this._scalePagePoint(
				Matrix2d.applyToPoint(pageTransform, new Vec2d(0, 0)),
				scaleOrigin,
				scale,
				scaleAxisRotation
			)

			const newLocalPoint = this.getPointInParentSpace(initialShape.id, newPagePoint)

			// resize the shape's local bounding box
			const myScale = new Vec2d(scale.x, scale.y)
			// the shape is algined with the rest of the shpaes in the selection, but may be
			// 90deg offset from the main rotation of the selection, in which case
			// we need to flip the width and height scale factors
			const areWidthAndHeightAlignedWithCorrectAxis = approximately(
				(pageRotation - scaleAxisRotation) % Math.PI,
				0
			)
			myScale.x = areWidthAndHeightAlignedWithCorrectAxis ? scale.x : scale.y
			myScale.y = areWidthAndHeightAlignedWithCorrectAxis ? scale.y : scale.x

			// adjust initial model for situations where the parent has moved during the resize
			// e.g. groups
			const initialPagePoint = Matrix2d.applyToPoint(pageTransform, new Vec2d())

			// need to adjust the shape's x and y points in case the parent has moved since start of resizing
			const { x, y } = this.getPointInParentSpace(initialShape.id, initialPagePoint)

			this.updateShapes(
				[
					{
						id,
						type: initialShape.type as any,
						x: newLocalPoint.x,
						y: newLocalPoint.y,
						...util.onResize(
							{ ...initialShape, x, y },
							{
								newPoint: newLocalPoint,
								handle: options?.dragHandle ?? 'bottom_right',
								// don't set isSingle to true for children
								mode: options?.mode ?? 'scale_shape',
								scaleX: myScale.x,
								scaleY: myScale.y,
								initialBounds,
								initialShape,
							}
						),
					},
				],
				true
			)
		} else {
			const initialPageCenter = Matrix2d.applyToPoint(pageTransform, initialBounds.center)
			// get the model changes from the shape util
			const newPageCenter = this._scalePagePoint(
				initialPageCenter,
				scaleOrigin,
				scale,
				scaleAxisRotation
			)

			const initialPageCenterInParentSpace = this.getPointInParentSpace(
				initialShape.id,
				initialPageCenter
			)
			const newPageCenterInParentSpace = this.getPointInParentSpace(initialShape.id, newPageCenter)

			const delta = Vec2d.Sub(newPageCenterInParentSpace, initialPageCenterInParentSpace)
			// apply the changes to the model
			this.updateShapes(
				[
					{
						id,
						type: initialShape.type as any,
						x: initialShape.x + delta.x,
						y: initialShape.y + delta.y,
					},
				],
				true
			)
		}

		return this
	}

	/**
	 * Stretch shape sizes and positions to fill their common bounding box.
	 *
	 * @example
	 *
	 * ```ts
	 * app.stretchShapes('horizontal')
	 * app.stretchShapes('horizontal', ['box1', 'box2'])
	 * ```
	 *
	 * @param operation - Whether to stretch shapes horizontally or vertically.
	 * @param ids - The ids of the shapes to stretch. Defaults to selected shapes.
	 * @public
	 */
	stretchShapes(
		operation: 'horizontal' | 'vertical',
		ids: TLShapeId[] = this.pageState.selectedIds
	) {
		if (this.isReadOnly) return this
		if (ids.length < 2) return this

		const shapes = compact(ids.map((id) => this.getShapeById(id)))
		const shapeBounds = Object.fromEntries(shapes.map((shape) => [shape.id, this.getBounds(shape)]))
		const shapePageBounds = Object.fromEntries(
			shapes.map((shape) => [shape.id, this.getPageBounds(shape)!])
		)
		const commonBounds = Box2d.Common(compact(Object.values(shapePageBounds)))

		const changes: TLShapePartial[] = []

		switch (operation) {
			case 'vertical': {
				this.batch(() => {
					for (const shape of shapes) {
						const pageRotation = this.getPageRotation(shape)
						if (pageRotation % PI2) continue
						const bounds = shapeBounds[shape.id]
						const pageBounds = shapePageBounds[shape.id]
						const localOffset = this.getDeltaInParentSpace(
							shape,
							new Vec2d(0, commonBounds.minY - pageBounds.minY)
						)
						const { x, y } = Vec2d.Add(localOffset, shape)
						this.updateShapes([{ id: shape.id, type: shape.type, x, y }], true)
						const scale = new Vec2d(1, commonBounds.height / pageBounds.height)
						this.resizeShape(shape.id, scale, {
							initialBounds: bounds,
							scaleOrigin: new Vec2d(pageBounds.center.x, commonBounds.minY),
							scaleAxisRotation: 0,
						})
					}
				})
				break
			}
			case 'horizontal': {
				this.batch(() => {
					for (const shape of shapes) {
						const bounds = shapeBounds[shape.id]
						const pageBounds = shapePageBounds[shape.id]
						const pageRotation = this.getPageRotation(shape)
						if (pageRotation % PI2) continue
						const localOffset = this.getDeltaInParentSpace(
							shape,
							new Vec2d(commonBounds.minX - pageBounds.minX, 0)
						)
						const { x, y } = Vec2d.Add(localOffset, shape)
						this.updateShapes([{ id: shape.id, type: shape.type, x, y }], true)
						const scale = new Vec2d(commonBounds.width / pageBounds.width, 1)
						this.resizeShape(shape.id, scale, {
							initialBounds: bounds,
							scaleOrigin: new Vec2d(commonBounds.minX, pageBounds.center.y),
							scaleAxisRotation: 0,
						})
					}
				})

				break
			}
		}

		this.updateShapes(changes)
		return this
	}

	/**
	 * Reparent shapes to a new parent. This operation preserves the shape's current page positions /
	 * rotations.
	 *
	 * @example
	 *
	 * ```ts
	 * app.reparentShapesById(['box1', 'box2'], 'frame1')
	 * ```
	 *
	 * @param ids - The ids of the shapes to reparent.
	 * @param parentId - The id of the new parent shape.
	 * @param insertIndex - The index to insert the children.
	 * @public
	 */
	reparentShapesById(ids: TLShapeId[], parentId: TLParentId, insertIndex?: string) {
		const changes: TLShapePartial[] = []

		const parentTransform = isPageId(parentId)
			? Matrix2d.Identity()
			: this.getPageTransformById(parentId)!

		const parentPageRotation = parentTransform.decompose().rotation

		let indices: string[] = []

		const sibs = compact(this.getSortedChildIds(parentId).map((id) => this.getShapeById(id)))

		if (insertIndex) {
			const sibWithInsertIndex = sibs.find((s) => s.index === insertIndex)
			if (sibWithInsertIndex) {
				// If there's a sibling with the same index as the insert index...
				const sibAbove = sibs[sibs.indexOf(sibWithInsertIndex) + 1]
				if (sibAbove) {
					// If the sibling has a sibling above it, insert the shapes
					// between the sibling and its sibling above it.
					indices = getIndicesBetween(insertIndex, sibAbove.index, ids.length)
				} else {
					// Or if the sibling is the top sibling, insert the shapes
					// above the sibling
					indices = getIndicesAbove(insertIndex, ids.length)
				}
			} else {
				// If there's no collision, then we can start at the insert index
				const sibAbove = sibs.sort(sortByIndex).find((s) => s.index > insertIndex)

				if (sibAbove) {
					// If the siblings include a sibling with a higher index, insert the shapes
					// between the insert index and the sibling with the higher index.
					indices = getIndicesBetween(insertIndex, sibAbove.index, ids.length)
				} else {
					// Otherwise, we're at the top of the order, so insert the shapes above
					// the insert index.
					indices = getIndicesAbove(insertIndex, ids.length)
				}
			}
		} else {
			// If insert index is not specified, start the index at the top.
			const sib = sibs.length && sibs[sibs.length - 1]
			indices = sib ? getIndicesAbove(sib.index, ids.length) : getIndices(ids.length)
		}

		let id: TLShapeId
		for (let i = 0; i < ids.length; i++) {
			id = ids[i]
			const shape = this.getShapeById(id)
			const pagePoint = this.getPagePointById(id)

			if (!shape || !pagePoint) continue

			const newPoint = Matrix2d.applyToPoint(Matrix2d.Inverse(parentTransform), pagePoint)
			const newRotation = this.getPageRotation(shape) - parentPageRotation

			changes.push({
				id: shape.id,
				type: shape.type,
				parentId: parentId,
				x: newPoint.x,
				y: newPoint.y,
				rotation: newRotation,
				index: indices[i],
			})
		}

		this.updateShapes(changes)
		return this
	}

	/**
	 * Select one or more shapes.
	 *
	 * @example
	 *
	 * ```ts
	 * app.select('id1')
	 * app.select('id1', 'id2')
	 * ```
	 *
	 * @param ids - The ids to select.
	 * @public
	 */
	select(...ids: TLShapeId[]) {
		this.setSelectedIds(ids)
		return this
	}

	/**
	 * Remove a shpae from the existing set of selected shapes.
	 *
	 * @example
	 *
	 * ```ts
	 * app.deselect(shape.id)
	 * ```
	 *
	 * @public
	 */
	deselect(...ids: TLShapeId[]) {
		const { selectedIds } = this
		if (selectedIds.length > 0 && ids.length > 0) {
			this.setSelectedIds(selectedIds.filter((id) => !ids.includes(id)))
		}
		return this
	}

	/**
	 * Select all direct children of the current page.
	 *
	 * @example
	 *
	 * ```ts
	 * app.selectAll()
	 * ```
	 *
	 * @public
	 */
	selectAll() {
		const ids = this.getSortedChildIds(this.currentPageId)
		// page might have no shapes
		if (ids.length <= 0) return this
		this.setSelectedIds(this._getUnlockedShapeIds(ids))

		return this
	}

	getShapeAndDescendantIds(ids: TLShapeId[]): Set<TLShapeId> {
		const idsToInclude = new Set<TLShapeId>()

		const idsToCheck = [...ids]

		while (idsToCheck.length > 0) {
			const id = idsToCheck.pop()
			if (!id) break
			if (idsToInclude.has(id)) continue
			idsToInclude.add(id)
			this.getSortedChildIds(id).forEach((id) => {
				idsToCheck.push(id)
			})
		}

		return idsToInclude
	}

	/**
	 * Clear the selection.
	 *
	 * @example
	 *
	 * ```ts
	 * app.selectNone()
	 * ```
	 *
	 * @public
	 */
	selectNone(): this {
		if (this.selectedIds.length > 0) {
			this.setSelectedIds([])
		}

		return this
	}

	/**
	 * Set the current page.
	 *
	 * @example
	 *
	 * ```ts
	 * app.setCurrentPageId('page1')
	 * ```
	 *
	 * @param pageId - The id of the page to set as the current page.
	 * @param options - Options for setting the current page.
	 * @public
	 */
	setCurrentPageId(pageId: TLPageId, { stopFollowing = true }: ViewportOptions = {}): this {
		this._setCurrentPageId(pageId, { stopFollowing })
		return this
	}

	/** @internal */
	private _setCurrentPageId = this.history.createCommand(
		'setCurrentPage',
		(pageId: TLPageId, { stopFollowing = true }: ViewportOptions = {}) => {
			if (!this.store.has(pageId)) {
				console.error("Tried to set the current page id to a page that doesn't exist.")
				return
			}

			if (stopFollowing && this.instanceState.followingUserId) {
				this.stopFollowingUser()
			}

			return {
				data: { toId: pageId, fromId: this.currentPageId },
				squashing: true,
				preservesRedoStack: true,
			}
		},
		{
			do: ({ toId }) => {
				if (!this.getPageStateByPageId(toId)) {
					const camera = CameraRecordType.create({})
					this.store.put([
						camera,
						InstancePageStateRecordType.create({
							pageId: toId,
							instanceId: this.instanceId,
							cameraId: camera.id,
						}),
					])
				}

				this.store.put([{ ...this.instanceState, currentPageId: toId }])

				this.updateCullingBounds()
			},
			undo: ({ fromId }) => {
				this.store.put([{ ...this.instanceState, currentPageId: fromId }])

				this.updateCullingBounds()
			},
			squash: ({ fromId }, { toId }) => {
				return { toId, fromId }
			},
		}
	)

	/** Set the current user tab state */
	updateInstanceState(
		partial: Partial<Omit<TLInstance, 'documentId' | 'userId' | 'currentPageId'>>,
		ephemeral = false,
		squashing = false
	) {
		this._updateInstanceState(partial, ephemeral, squashing)
		return this
	}

	/** @internal */
	private _updateInstanceState = this.history.createCommand(
		'updateTabState',
		(
			partial: Partial<Omit<TLInstance, 'documentId' | 'userId' | 'currentPageId'>>,
			ephemeral = false,
			squashing = false
		) => {
			const prev = this.instanceState
			const next = { ...prev, ...partial }

			return {
				data: { prev, next },
				squashing,
				ephemeral,
			}
		},
		{
			do: ({ next }) => {
				this.store.put([next])
			},
			undo: ({ prev }) => {
				this.store.put([prev])
			},
			squash({ prev }, { next }) {
				return { prev, next }
			},
		}
	)

	@computed get hoveredId() {
		return this.pageState.hoveredId
	}

	@computed get hoveredShape() {
		if (!this.hoveredId) return null
		return this.getShapeById(this.hoveredId) ?? null
	}

	/**
	 * Set the current hovered shape.
	 *
	 * @example
	 *
	 * ```ts
	 * app.setHoveredId('box1')
	 * app.setHoveredId() // Clears the hovered shape.
	 * ```
	 *
	 * @param id - The id of the page to set as the current page
	 * @public
	 */
	setHoveredId(id: TLShapeId | null = null): this {
		if (id === this.pageState.hoveredId) return this

		this.setInstancePageState({ hoveredId: id }, true)
		return this
	}

	/**
	 * Set the current erasing shapes.
	 *
	 * @example
	 *
	 * ```ts
	 * app.setErasingIds(['box1', 'box2'])
	 * app.setErasingIds() // Clears the erasing set
	 * ```
	 *
	 * @param ids - The ids of shapes to set as erasing.
	 * @public
	 */
	setErasingIds(ids: TLShapeId[] = []): this {
		const erasingIds = this.erasingIdsSet
		if (ids.length === erasingIds.size && ids.every((id) => erasingIds.has(id))) return this

		this.setInstancePageState({ erasingIds: ids }, true)
		return this
	}

	/**
	 * Set the current cursor.
	 *
	 * @example
	 *
	 * ```ts
	 * app.setCursor({ type: 'default' })
	 * app.setCursor({ type: 'default', rotation: Math.PI / 2, color: 'red' })
	 * ```
	 *
	 * @param cursor - A partial of the cursor object.
	 * @public
	 */
	setCursor(cursor: Partial<TLCursor>): this {
		const current = this.cursor
		const next = {
			...current,
			rotation: 0,
			...cursor,
		}

		if (
			!(
				current.type === next.type &&
				current.rotation === next.rotation &&
				current.color === next.color
			)
		) {
			this.updateInstanceState({ cursor: next }, true)
		}

		return this
	}

	/**
	 * Set the current scribble.
	 *
	 * @example
	 *
	 * ```ts
	 * app.setScribble(nextScribble)
	 * app.setScribble() // clears the scribble
	 * ```
	 *
	 * @param scribble - The new scribble object.
	 * @public
	 */
	setScribble(scribble: TLScribble | null = null): this {
		this.updateInstanceState({ scribble }, true)
		return this
	}

	/**
	 * Set the current brush.
	 *
	 * @example
	 *
	 * ```ts
	 * app.setBrush({ x: 0, y: 0, w: 100, h: 100 })
	 * app.setBrush() // Clears the brush
	 * ```
	 *
	 * @param brush - The brush box model to set, or null for no brush model.
	 * @public
	 */
	setBrush(brush: Box2dModel | null = null): this {
		if (!brush && !this.brush) return this
		this.updateInstanceState({ brush }, true)
		return this
	}

	/**
	 * Set the current zoom brush.
	 *
	 * @example
	 *
	 * ```ts
	 * app.setZoomBrush({ x: 0, y: 0, w: 100, h: 100 })
	 * app.setZoomBrush() // Clears the zoom
	 * ```
	 *
	 * @param zoomBrush - The zoom box model to set, or null for no zoom model.
	 * @public
	 */
	setZoomBrush(zoomBrush: Box2dModel | null = null): this {
		if (!zoomBrush && !this.zoomBrush) return this
		this.updateInstanceState({ zoomBrush }, true)
		return this
	}

	/**
	 * Rotate shapes by a delta in radians.
	 *
	 * @example
	 *
	 * ```ts
	 * app.rotateShapesBy(['box1', 'box2'], Math.PI)
	 * app.rotateShapesBy(['box1', 'box2'], Math.PI / 2)
	 * ```
	 *
	 * @param ids - The ids of the shapes to move.
	 * @param delta - The delta in radians to apply to the selection rotation.
	 */
	rotateShapesBy(ids: TLShapeId[], delta: number): this {
		if (ids.length <= 0) return this

		const snapshot = getRotationSnapshot({ app: this })
		applyRotationToSnapshotShapes({ delta, snapshot, app: this, stage: 'one-off' })

		return this
	}

	/**
	 * Move shapes by a delta.
	 *
	 * @example
	 *
	 * ```ts
	 * app.nudgeShapes(['box1', 'box2'], { x: 0, y: 1 })
	 * app.nudgeShapes(['box1', 'box2'], { x: 0, y: 1 }, true)
	 * ```
	 *
	 * @param ids - The ids of the shapes to move.
	 * @param direction - The direction in which to move the shapes.
	 * @param major - Whether this is a major nudge, e.g. a shift + arrow nudge.
	 */
	nudgeShapes(ids: TLShapeId[], direction: Vec2dModel, major = false, ephemeral = false) {
		if (ids.length <= 0) return this

		const step = this.isGridMode
			? major
				? this.gridSize * GRID_INCREMENT
				: this.gridSize
			: major
			? MAJOR_NUDGE_FACTOR
			: MINOR_NUDGE_FACTOR

		const steppedDelta = Vec2d.Mul(direction, step)
		const changes: TLShapePartial[] = []

		for (const id of ids) {
			const shape = this.getShapeById(id)

			if (!shape) {
				throw Error(`Could not find a shape with the id ${id}.`)
			}

			const localDelta = this.getDeltaInParentSpace(shape, steppedDelta)
			const translateStartChanges = this.getShapeUtil(shape).onTranslateStart?.(shape)

			changes.push(
				translateStartChanges
					? {
							...translateStartChanges,
							x: shape.x + localDelta.x,
							y: shape.y + localDelta.y,
					  }
					: {
							id,
							x: shape.x + localDelta.x,
							y: shape.y + localDelta.y,
							type: shape.type,
					  }
			)
		}

		this.updateShapes(changes, ephemeral)

		return this
	}

	/**
	 * Duplicate shapes.
	 *
	 * @example
	 *
	 * ```ts
	 * app.duplicateShapes()
	 * app.duplicateShapes(['id1', 'id2'])
	 * app.duplicateShapes(['id1', 'id2'], { x: 8, y: 8 })
	 * ```
	 *
	 * @param ids - The ids of the shapes to duplicate. Defaults to the ids of the selected shapes.
	 * @param offset - The offset (in pixels) to apply to the duplicated shapes.
	 * @public
	 */
	duplicateShapes(ids: TLShapeId[] = this.selectedIds, offset?: VecLike) {
		if (ids.length <= 0) return this

		const initialIds = new Set(ids)
		const idsToCreate: TLShapeId[] = []
		const idsToCheck = [...ids]

		while (idsToCheck.length > 0) {
			const id = idsToCheck.pop()
			if (!id) break
			idsToCreate.push(id)
			this.getSortedChildIds(id).forEach((childId) => idsToCheck.push(childId))
		}

		idsToCreate.reverse()

		const idsMap = new Map<any, TLShapeId>(idsToCreate.map((id) => [id, this.createShapeId()]))

		const shapesToCreate = compact(
			idsToCreate.map((id) => {
				const shape = this.getShapeById(id)

				if (!shape) {
					return null
				}

				const createId = idsMap.get(id)!

				let ox = 0
				let oy = 0

				if (offset && initialIds.has(id)) {
					const parentTransform = this.getParentTransform(shape)
					const vec = new Vec2d(offset.x, offset.y).rot(
						-Matrix2d.Decompose(parentTransform).rotation
					)
					ox = vec.x
					oy = vec.y
				}

				const parentId = shape.parentId ?? this.currentPageId
				const siblings = this.getSortedChildIds(parentId)
				const currentIndex = siblings.indexOf(shape.id)
				const siblingAboveId = siblings[currentIndex + 1]
				const siblingAbove = siblingAboveId ? this.getShapeById(siblingAboveId) : null

				const index = siblingAbove
					? getIndexBetween(shape.index, siblingAbove.index)
					: getIndexAbove(shape.index)

				let newShape: TLShape = deepCopy(shape)

				if (this.isShapeOfType(shape, TLArrowUtil) && this.isShapeOfType(newShape, TLArrowUtil)) {
					const info = this.getShapeUtil(TLArrowUtil).getArrowInfo(shape)
					let newStartShapeId: TLShapeId | undefined = undefined
					let newEndShapeId: TLShapeId | undefined = undefined

					if (shape.props.start.type === 'binding') {
						newStartShapeId = idsMap.get(shape.props.start.boundShapeId)

						if (!newStartShapeId) {
							if (info?.isValid) {
								const { x, y } = info.start.point
								newShape.props.start = {
									type: 'point',
									x,
									y,
								}
							} else {
								const { start } = getArrowTerminalsInArrowSpace(this, shape)
								newShape.props.start = {
									type: 'point',
									x: start.x,
									y: start.y,
								}
							}
						}
					}

					if (shape.props.end.type === 'binding') {
						newEndShapeId = idsMap.get(shape.props.end.boundShapeId)
						if (!newEndShapeId) {
							if (info?.isValid) {
								const { x, y } = info.end.point
								newShape.props.end = {
									type: 'point',
									x,
									y,
								}
							} else {
								const { end } = getArrowTerminalsInArrowSpace(this, shape)
								newShape.props.start = {
									type: 'point',
									x: end.x,
									y: end.y,
								}
							}
						}
					}

					const infoAfter = getIsArrowStraight(newShape)
						? getStraightArrowInfo(this, newShape)
						: getCurvedArrowInfo(this, newShape)

					if (info?.isValid && infoAfter?.isValid && !getIsArrowStraight(shape)) {
						const mpA = Vec2d.Med(info.start.handle, info.end.handle)
						const distA = Vec2d.Dist(info.middle, mpA)
						const distB = Vec2d.Dist(infoAfter.middle, mpA)
						if (newShape.props.bend < 0) {
							newShape.props.bend += distB - distA
						} else {
							newShape.props.bend -= distB - distA
						}
					}

					if (newShape.props.start.type === 'binding' && newStartShapeId) {
						newShape.props.start.boundShapeId = newStartShapeId
					}

					if (newShape.props.end.type === 'binding' && newEndShapeId) {
						newShape.props.end.boundShapeId = newEndShapeId
					}
				}

				newShape = { ...newShape, id: createId, x: shape.x + ox, y: shape.y + oy, index }

				return newShape
			})
		)

		shapesToCreate.forEach((shape) => {
			if (isShapeId(shape.parentId)) {
				if (idsMap.has(shape.parentId)) {
					shape.parentId = idsMap.get(shape.parentId)!
				}
			}
		})

		this.history.batch(() => {
			const maxShapesReached = shapesToCreate.length + this.shapeIds.size > MAX_SHAPES_PER_PAGE

			if (maxShapesReached) {
				alertMaxShapes(this)
			}

			const newShapes = maxShapesReached
				? shapesToCreate.slice(0, MAX_SHAPES_PER_PAGE - this.shapeIds.size)
				: shapesToCreate

			const ids = newShapes.map((s) => s.id)

			this.createShapes(newShapes)
			this.setSelectedIds(ids)

			if (offset !== undefined) {
				// If we've offset the duplicated shapes, check to see whether their new bounds is entirely
				// contained in the current viewport. If not, then animate the camera to be centered on the
				// new shapes.
				const { viewportPageBounds, selectedPageBounds } = this
				if (selectedPageBounds && !viewportPageBounds.contains(selectedPageBounds)) {
					this.centerOnPoint(selectedPageBounds.center.x, selectedPageBounds.center.y, {
						duration: ANIMATION_MEDIUM_MS,
					})
				}
			}
		})

		return this
	}

	/**
	 * Set the current props (generally styles).
	 *
	 * @example
	 *
	 * ```ts
	 * app.setProp('color', 'red')
	 * app.setProp('color', 'red', true)
	 * ```
	 *
	 * @param key - The key to set.
	 * @param value - The value to set.
	 * @param ephemeral - Whether the style is ephemeral. Defaults to false.
	 * @public
	 */
	setProp(key: TLShapeProp, value: any, ephemeral = false, squashing = false) {
		const children: (TLShape | undefined)[] = []
		// We can have many deep levels of grouped shape
		// Making a recursive function to look through all the levels
		const getChildProp = (id: TLShape['id']) => {
			const childIds = this.getSortedChildIds(id)
			for (const childId of childIds) {
				const childShape = this.getShapeById(childId)
				if (childShape?.type === 'group') {
					getChildProp(childShape.id)
				}
				children.push(childShape)
			}
		}

		this.history.batch(() => {
			this.updateInstanceState(
				{
					propsForNextShape: setPropsForNextShape(this.instanceState.propsForNextShape, {
						[key]: value,
					}),
				},
				ephemeral,
				squashing
			)

			if (this.isIn('select')) {
				const {
					pageState: { selectedIds },
				} = this

				if (selectedIds.length > 0) {
					const shapes = compact(
						selectedIds.map((id) => {
							const shape = this.getShapeById(id)
							if (shape?.type === 'group') {
								const childIds = this.getSortedChildIds(shape.id)
								for (const childId of childIds) {
									const childShape = this.getShapeById(childId)
									if (childShape?.type === 'group') {
										getChildProp(childShape.id)
									}
									children.push(childShape)
								}
								return children
							} else {
								return shape
							}
						})
					)
						.flat()
						.filter(
							(shape) =>
								shape!.props[key as keyof TLShape['props']] !== undefined && shape?.type !== 'group'
						) as TLShape[]

					this.updateShapes(
						shapes.map((shape) => {
							const props = { ...shape.props, [key]: value }
							if (key === 'color' && 'labelColor' in props) {
								props.labelColor = 'black'
							}

							return {
								id: shape.id,
								type: shape.type,
								props,
							}
						}),
						ephemeral
					)

					if (key !== 'color' && key !== 'opacity') {
						const changes: TLShapePartial[] = []

						for (const shape of shapes) {
							const currentShape = this.getShapeById(shape.id)
							if (!currentShape) continue
							const util = this.getShapeUtil(currentShape)

							const boundsA = util.bounds(shape)
							const boundsB = util.bounds(currentShape)

							const change: TLShapePartial = { id: shape.id, type: shape.type }

							let didChange = false

							if (boundsA.width !== boundsB.width) {
								didChange = true

								if (this.isShapeOfType(shape, TLTextUtil)) {
									switch (shape.props.align) {
										case 'middle': {
											change.x = currentShape.x + (boundsA.width - boundsB.width) / 2
											break
										}
										case 'end': {
											change.x = currentShape.x + boundsA.width - boundsB.width
											break
										}
									}
								} else {
									change.x = currentShape.x + (boundsA.width - boundsB.width) / 2
								}
							}

							if (boundsA.height !== boundsB.height) {
								didChange = true
								change.y = currentShape.y + (boundsA.height - boundsB.height) / 2
							}

							if (didChange) {
								changes.push(change)
							}
						}

						if (changes.length) {
							this.updateShapes(changes, ephemeral)
						}
					}
				}
			}

			this.updateInstanceState(
				{
					propsForNextShape: setPropsForNextShape(this.instanceState.propsForNextShape, {
						[key]: value,
					}),
				},
				ephemeral,
				squashing
			)
		})

		return this
	}

	/** @internal */
	private _willSetInitialBounds = true

	/** @internal */
	private _setCamera(x: number, y: number, z = this.camera.z) {
		const currentCamera = this.camera
		if (currentCamera.x === x && currentCamera.y === y && currentCamera.z === z) return this
		const nextCamera = { ...currentCamera, x, y, z }

		this.batch(() => {
			this.store.put([nextCamera])

			const { currentScreenPoint } = this.inputs

			this.dispatch({
				type: 'pointer',
				target: 'canvas',
				name: 'pointer_move',
				point: currentScreenPoint,
				pointerId: 0,
				ctrlKey: this.inputs.ctrlKey,
				altKey: this.inputs.altKey,
				shiftKey: this.inputs.shiftKey,
				button: 0,
				isPen: this.isPenMode ?? false,
			})

			this._cameraManager.tick()
		})

		return this
	}

	/**
	 * Set the current camera.
	 *
	 * @example
	 *
	 * ```ts
	 * app.setCamera(0, 0)
	 * app.setCamera(0, 0, 1)
	 * ```
	 *
	 * @param x - The camera's x position.
	 * @param y - The camera's y position.
	 * @param z - The camera's z position. Defaults to the current zoom.
	 * @param options - Options for the camera change.
	 * @public
	 */
	setCamera(
		x: number,
		y: number,
		z = this.camera.z,
		{ stopFollowing = true }: ViewportOptions = {}
	) {
		this.stopCameraAnimation()
		if (stopFollowing && this.instanceState.followingUserId) {
			this.stopFollowingUser()
		}
		x = Number.isNaN(x) ? 0 : x
		y = Number.isNaN(y) ? 0 : y
		z = Number.isNaN(z) ? 1 : z
		this._setCamera(x, y, z)
		return this
	}

	enableAnimations = true

	/**
	 * Animate the camera.
	 *
	 * @example
	 *
	 * ```ts
	 * app.animateCamera(0, 0)
	 * app.animateCamera(0, 0, 1)
	 * app.animateCamera(0, 0, 1, { duration: 1000, easing: (t) => t * t })
	 * ```
	 *
	 * @param x - The camera's x position.
	 * @param y - The camera's y position.
	 * @param z - The camera's z position. Defaults to the current zoom.
	 * @param opts - Options for the animation.
	 * @public
	 */
	animateCamera(
		x: number,
		y: number,
		z = this.camera.z,
		opts: AnimationOptions = DEFAULT_ANIMATION_OPTIONS
	) {
		x = Number.isNaN(x) ? 0 : x
		y = Number.isNaN(y) ? 0 : y
		z = Number.isNaN(z) ? 1 : z
		const { width, height } = this.viewportScreenBounds
		const w = width / z
		const h = height / z

		const targetViewport = new Box2d(-x, -y, w, h)

		if (!this.enableAnimations) {
			return this.setCamera(x, y, this.viewportScreenBounds.width / w)
		}

		return this._animateToViewport(targetViewport, opts)
	}

	/**
	 * Center the camera on a point (in page space).
	 *
	 * @example
	 *
	 * ```ts
	 * app.centerOnPoint(100, 100)
	 * ```
	 *
	 * @param x - The x position of the point.
	 * @param y - The y position of the point.
	 * @param opts - The options for an animation.
	 * @public
	 */
	centerOnPoint(x: number, y: number, opts?: AnimationOptions): this {
		if (!this.canMoveCamera) return this

		const {
			viewportPageBounds: { width: pw, height: ph },
			camera,
		} = this

		if (opts?.duration) {
			this.animateCamera(-(x - pw / 2), -(y - ph / 2), camera.z, opts)
		} else {
			this.setCamera(-(x - pw / 2), -(y - ph / 2), camera.z)
		}
		return this
	}

	/**
	 * Move the camera to the nearest content.
	 *
	 * @public
	 */
	zoomToContent() {
		const bounds = this.selectedPageBounds ?? this.allShapesCommonBounds

		if (bounds) {
			this.zoomToBounds(
				bounds.minX,
				bounds.minY,
				bounds.width,
				bounds.height,
				Math.min(1, this.zoomLevel),
				{ duration: 220 }
			)
		}

		return this
	}

	/**
	 * Zoom the camera to fit the current page's content in the viewport.
	 *
	 * @example
	 *
	 * ```ts
	 * app.zoomToFit()
	 * ```
	 *
	 * @public
	 */
	zoomToFit(opts?: AnimationOptions): this {
		if (!this.canMoveCamera) return this

		const ids = [...this.shapeIds]
		if (ids.length <= 0) return this

		const pageBounds = Box2d.Common(compact(ids.map((id) => this.getPageBoundsById(id))))
		this.zoomToBounds(
			pageBounds.minX,
			pageBounds.minY,
			pageBounds.width,
			pageBounds.height,
			undefined,
			opts
		)
		return this
	}

	/**
	 * Set the zoom back to 100%.
	 *
	 * @example
	 *
	 * ```ts
	 * app.resetZoom()
	 * ```
	 *
	 * @param opts - The options for an animation.
	 * @public
	 */
	resetZoom(point = this.viewportScreenCenter, opts?: AnimationOptions): this {
		if (!this.canMoveCamera) return this

		const { x: cx, y: cy, z: cz } = this.camera
		const { x, y } = point
		if (opts?.duration) {
			this.animateCamera(cx + (x / 1 - x) - (x / cz - x), cy + (y / 1 - y) - (y / cz - y), 1, opts)
		} else {
			this.setCamera(cx + (x / 1 - x) - (x / cz - x), cy + (y / 1 - y) - (y / cz - y), 1)
		}

		return this
	}

	/**
	 * Zoom the camera in.
	 *
	 * @example
	 *
	 * ```ts
	 * app.zoomIn()
	 * app.zoomIn(app.viewportScreenCenter, { duration: 120 })
	 * app.zoomIn(app.inputs.currentScreenPoint, { duration: 120 })
	 * ```
	 *
	 * @param opts - The options for an animation.
	 * @public
	 */
	zoomIn(point = this.viewportScreenCenter, opts?: AnimationOptions): this {
		if (!this.canMoveCamera) return this

		const { x: cx, y: cy, z: cz } = this.camera

		let zoom = MAX_ZOOM

		for (let i = 1; i < ZOOMS.length; i++) {
			const z1 = ZOOMS[i - 1]
			const z2 = ZOOMS[i]
			if (z2 - cz <= (z2 - z1) / 2) continue
			zoom = z2
			break
		}

		const { x, y } = point
		if (opts?.duration) {
			this.animateCamera(
				cx + (x / zoom - x) - (x / cz - x),
				cy + (y / zoom - y) - (y / cz - y),
				zoom,
				opts
			)
		} else {
			this.setCamera(cx + (x / zoom - x) - (x / cz - x), cy + (y / zoom - y) - (y / cz - y), zoom)
		}

		return this
	}

	/**
	 * Zoom the camera out.
	 *
	 * @example
	 *
	 * ```ts
	 * app.zoomOut()
	 * app.zoomOut(app.viewportScreenCenter, { duration: 120 })
	 * app.zoomOut(app.inputs.currentScreenPoint, { duration: 120 })
	 * ```
	 *
	 * @param opts - The options for an animation.
	 * @public
	 */
	zoomOut(point = this.viewportScreenCenter, opts?: AnimationOptions): this {
		if (!this.canMoveCamera) return this

		const { x: cx, y: cy, z: cz } = this.camera

		let zoom = MIN_ZOOM

		for (let i = ZOOMS.length - 1; i > 0; i--) {
			const z1 = ZOOMS[i - 1]
			const z2 = ZOOMS[i]
			if (z2 - cz >= (z2 - z1) / 2) continue
			zoom = z1
			break
		}

		const { x, y } = point

		if (opts?.duration) {
			this.animateCamera(
				cx + (x / zoom - x) - (x / cz - x),
				cy + (y / zoom - y) - (y / cz - y),
				zoom,
				opts
			)
		} else {
			this.setCamera(cx + (x / zoom - x) - (x / cz - x), cy + (y / zoom - y) - (y / cz - y), zoom)
		}

		return this
	}

	/**
	 * Zoom the camera to fit the current selection in the viewport.
	 *
	 * @example
	 *
	 * ```ts
	 * app.zoomToSelection()
	 * ```
	 *
	 * @param opts - The options for an animation.
	 * @public
	 */
	zoomToSelection(opts?: AnimationOptions): this {
		if (!this.canMoveCamera) return this

		const ids = this.selectedIds
		if (ids.length <= 0) return this

		const selectedBounds = Box2d.Common(compact(ids.map((id) => this.getPageBoundsById(id))))

		this.zoomToBounds(
			selectedBounds.minX,
			selectedBounds.minY,
			selectedBounds.width,
			selectedBounds.height,
			Math.max(1, this.camera.z),
			opts
		)

		return this
	}

	/**
	 * Pan or pan/zoom the selected ids into view. This method tries to not change the zoom if
	 * possible.
	 *
	 * @param ids - The ids of the shapes to pan and zoom into view.
	 * @param opts - The options for an animation.
	 * @public
	 */
	panZoomIntoView(ids: TLShapeId[], opts?: AnimationOptions): this {
		if (!this.canMoveCamera) return this

		if (ids.length <= 0) return this
		const selectedBounds = Box2d.Common(compact(ids.map((id) => this.getPageBoundsById(id))))

		const { viewportPageBounds } = this

		if (viewportPageBounds.h < selectedBounds.h || viewportPageBounds.w < selectedBounds.w) {
			this.zoomToBounds(
				selectedBounds.minX,
				selectedBounds.minY,
				selectedBounds.width,
				selectedBounds.height,
				this.camera.z,
				opts
			)

			return this
		} else {
			// TODO: This buffer should calculate the 'active area' of the UI
			const bufferOffsets = this._activeAreaManager.offsets.value

			const pageTop = viewportPageBounds.y + bufferOffsets.top
			const pageRight = viewportPageBounds.maxY - bufferOffsets.right
			const pageBottom = viewportPageBounds.maxY - bufferOffsets.bottom
			const pageLeft = viewportPageBounds.x + bufferOffsets.left

			const selectedTop = selectedBounds.y
			const selectedRight = selectedBounds.maxX
			const selectedBottom = selectedBounds.maxY
			const selectedLeft = selectedBounds.x

			let offsetX = 0
			let offsetY = 0
			if (pageBottom < selectedBottom) {
				// off bottom
				offsetY = pageBottom - selectedBottom
			} else if (pageTop > selectedTop) {
				// off top
				offsetY = pageTop - selectedTop
			} else {
				// inside y-bounds
			}

			if (pageRight < selectedRight) {
				// off right
				offsetX = pageRight - selectedRight
			} else if (pageLeft > selectedLeft) {
				// off left
				offsetX = pageLeft - selectedLeft
			} else {
				// inside x-bounds
			}

			const { camera } = this

			if (opts?.duration) {
				this.animateCamera(camera.x + offsetX, camera.y + offsetY, camera.z, opts)
			} else {
				this.setCamera(camera.x + offsetX, camera.y + offsetY, camera.z)
			}
		}

		return this
	}

	/**
	 * Zoom the camera to fit a bounding box (in page space).
	 *
	 * @example
	 *
	 * ```ts
	 * app.zoomToBounds(0, 0, 100, 100)
	 * ```
	 *
	 * @param x - The bounding box's x position.
	 * @param y - The bounding box's y position.
	 * @param width - The bounding box's width.
	 * @param height - The bounding box's height.
	 * @param targetZoom - The desired zoom level. Defaults to 0.1.
	 * @public
	 */
	zoomToBounds(
		x: number,
		y: number,
		width: number,
		height: number,
		targetZoom?: number,
		opts?: AnimationOptions
	): this {
		if (!this.canMoveCamera) return this

		const { viewportScreenBounds } = this

		const inset = Math.min(256, viewportScreenBounds.width * 0.28)

		let zoom = clamp(
			Math.min(
				(viewportScreenBounds.width - inset) / width,
				(viewportScreenBounds.height - inset) / height
			),
			MIN_ZOOM,
			MAX_ZOOM
		)

		if (targetZoom !== undefined) {
			zoom = Math.min(targetZoom, zoom)
		}

		if (opts?.duration) {
			this.animateCamera(
				-x + (viewportScreenBounds.width - width * zoom) / 2 / zoom,
				-y + (viewportScreenBounds.height - height * zoom) / 2 / zoom,
				zoom,
				opts
			)
		} else {
			this.setCamera(
				-x + (viewportScreenBounds.width - width * zoom) / 2 / zoom,
				-y + (viewportScreenBounds.height - height * zoom) / 2 / zoom,
				zoom
			)
		}

		return this
	}

	/**
	 * Pan the camera.
	 *
	 * @example
	 *
	 * ```ts
	 * app.pan(100, 100)
	 * app.pan(100, 100, { duration: 1000 })
	 * ```
	 *
	 * @param dx - The amount to pan on the x axis.
	 * @param dy - The amount to pan on the y axis.
	 * @param opts - The animation options
	 */
	pan(dx: number, dy: number, opts?: AnimationOptions): this {
		if (!this.canMoveCamera) return this

		const { camera } = this
		const { x: cx, y: cy, z: cz } = camera
		const d = new Vec2d(dx, dy).div(cz)

		if (opts?.duration ?? 0 > 0) {
			return this.animateCamera(cx + d.x, cy + d.y, cz, opts)
		} else {
			this.setCamera(cx + d.x, cy + d.y, cz)
		}

		return this
	}

	/**
	 * Stop the current camera animation, if any.
	 *
	 * @public
	 */
	stopCameraAnimation() {
		this.emit('stop-camera-animation')

		return this
	}

	/** @internal */
	private _viewportAnimation = null as null | {
		elapsed: number
		duration: number
		easing: (t: number) => number
		start: Box2d
		end: Box2d
	}

	/** @internal */
	private _animateViewport(ms: number) {
		if (!this._viewportAnimation) return

		const cancel = () => {
			this.removeListener('tick', this._animateViewport)
			this.removeListener('stop-camera-animation', cancel)
			this._viewportAnimation = null
		}

		this.once('stop-camera-animation', cancel)

		this._viewportAnimation.elapsed += ms

		const { elapsed, easing, duration, start, end } = this._viewportAnimation

		if (elapsed > duration) {
			const z = this.viewportScreenBounds.width / end.width
			const x = -end.x
			const y = -end.y

			this._setCamera(x, y, z)
			cancel()
			return
		}

		const remaining = duration - elapsed
		const t = easing(1 - remaining / duration)

		const left = start.minX + (end.minX - start.minX) * t
		const top = start.minY + (end.minY - start.minY) * t
		const right = start.maxX + (end.maxX - start.maxX) * t
		const bottom = start.maxY + (end.maxY - start.maxY) * t

		const easedViewport = new Box2d(left, top, right - left, bottom - top)

		const z = this.viewportScreenBounds.width / easedViewport.width
		const x = -easedViewport.x
		const y = -easedViewport.y

		this._setCamera(x, y, z)
	}

	/** @internal */
	private _animateToViewport(targetViewportPage: Box2d, opts = {} as AnimationOptions) {
		const { duration = 0, easing = EASINGS.easeInOutCubic } = opts
		const { animationSpeed, viewportPageBounds } = this

		// If we have an existing animation, then stop it; also stop following any user
		this.stopCameraAnimation()
		if (this.instanceState.followingUserId) {
			this.stopFollowingUser()
		}

		if (duration === 0 || animationSpeed === 0) {
			// If we have no animation, then skip the animation and just set the camera
			return this._setCamera(
				-targetViewportPage.x,
				-targetViewportPage.y,
				this.viewportScreenBounds.width / targetViewportPage.width
			)
		}

		// Set our viewport animation
		this._viewportAnimation = {
			elapsed: 0,
			duration: duration / animationSpeed,
			easing,
			start: viewportPageBounds.clone(),
			end: targetViewportPage,
		}

		// On each tick, animate the viewport
		this.addListener('tick', this._animateViewport)

		return this
	}

	slideCamera(
		opts = {} as {
			speed: number
			direction: Vec2d
			friction: number
			speedThreshold?: number
		}
	) {
		if (!this.canMoveCamera) return this

		this.stopCameraAnimation()

		const { animationSpeed } = this

		if (animationSpeed === 0) return

		const { speed, friction, direction, speedThreshold = 0.01 } = opts
		let currentSpeed = Math.min(speed, 1)

		const cancel = () => {
			this.removeListener('tick', moveCamera)
			this.removeListener('stop-camera-animation', cancel)
		}

		this.once('stop-camera-animation', cancel)

		const moveCamera = (elapsed: number) => {
			const { x: cx, y: cy, z: cz } = this.camera
			const movementVec = direction.clone().mul((currentSpeed * elapsed) / cz)

			// Apply friction
			currentSpeed *= 1 - friction
			if (currentSpeed < speedThreshold) {
				cancel()
			} else {
				this._setCamera(cx + movementVec.x, cy + movementVec.y, cz)
			}
		}

		this.addListener('tick', moveCamera)

		return this
	}

	/**
	 * Start viewport-following a user.
	 *
	 * @param userId - The id of the user to follow.
	 * @public
	 */
	startFollowingUser = (userId: string) => {
		// Currently, we get the leader's viewport page bounds from their user presence.
		// This is a placeholder until the ephemeral PR lands.
		// After that, we'll be able to get the required data from their instance presence instead.
		const leaderPresences = this.store.query.records('instance_presence', () => ({
			userId: { eq: userId },
		}))

		const thisUserId = this.user.id

		if (!thisUserId) {
			console.warn('You should set the userId for the current instance before following a user')
		}

		// If the leader is following us, then we can't follow them
		if (leaderPresences.value.some((p) => p.followingUserId === thisUserId)) {
			return
		}

		transact(() => {
			this.stopFollowingUser()

			this.updateInstanceState({
				followingUserId: userId,
			})
		})

		const cancel = () => {
			this.removeListener('frame', moveTowardsUser)
			this.removeListener('stop-following', cancel)
		}

		let isCaughtUp = false

		const moveTowardsUser = () => {
			// Stop following if we can't find the user
			const leaderPresence = [...leaderPresences.value]
				.sort((a, b) => {
					return a.lastActivityTimestamp - b.lastActivityTimestamp
				})
				.pop()
			if (!leaderPresence) {
				this.stopFollowingUser()
				return
			}

			// Change page if leader is on a different page
			const isOnSamePage = leaderPresence.currentPageId === this.currentPageId
			const chaseProportion = isOnSamePage ? FOLLOW_CHASE_PROPORTION : 1
			if (!isOnSamePage) {
				this.setCurrentPageId(leaderPresence.currentPageId, { stopFollowing: false })
			}

			// Get the bounds of the follower (me) and the leader (them)
			const { center, width, height } = this.viewportPageBounds
			const leaderScreen = Box2d.From(leaderPresence.screenBounds)
			const leaderWidth = leaderScreen.width / leaderPresence.camera.z
			const leaderHeight = leaderScreen.height / leaderPresence.camera.z
			const leaderCenter = new Vec2d(
				leaderWidth / 2 - leaderPresence.camera.x,
				leaderHeight / 2 - leaderPresence.camera.y
			)

			// At this point, let's check if we're following someone who's following us.
			// If so, we can't try to contain their entire viewport
			// because that would become a feedback loop where we zoom, they zoom, etc.
			const isFollowingFollower = leaderPresence.followingUserId === thisUserId

			// Figure out how much to zoom
			const desiredWidth = width + (leaderWidth - width) * chaseProportion
			const desiredHeight = height + (leaderHeight - height) * chaseProportion
			const ratio = !isFollowingFollower
				? Math.min(width / desiredWidth, height / desiredHeight)
				: height / desiredHeight

			const targetZoom = clamp(this.camera.z * ratio, MIN_ZOOM, MAX_ZOOM)
			const targetWidth = this.viewportScreenBounds.w / targetZoom
			const targetHeight = this.viewportScreenBounds.h / targetZoom

			// Figure out where to move the camera
			const displacement = leaderCenter.sub(center)
			const targetCenter = Vec2d.Add(center, Vec2d.Mul(displacement, chaseProportion))

			// Now let's assess whether we've caught up to the leader or not
			const distance = Vec2d.Sub(targetCenter, center).len()
			const zoomChange = Math.abs(targetZoom - this.camera.z)

			// If we're chasing the leader...
			// Stop chasing if we're close enough
			if (distance < FOLLOW_CHASE_PAN_SNAP && zoomChange < FOLLOW_CHASE_ZOOM_SNAP) {
				isCaughtUp = true
				return
			}

			// If we're already caught up with the leader...
			// Only start moving again if we're far enough away
			if (
				isCaughtUp &&
				distance < FOLLOW_CHASE_PAN_UNSNAP &&
				zoomChange < FOLLOW_CHASE_ZOOM_UNSNAP
			) {
				return
			}

			// Update the camera!
			isCaughtUp = false
			this.stopCameraAnimation()
			this.setCamera(
				-(targetCenter.x - targetWidth / 2),
				-(targetCenter.y - targetHeight / 2),
				targetZoom,
				{ stopFollowing: false }
			)
		}

		this.once('stop-following', cancel)
		this.addListener('frame', moveTowardsUser)

		return this
	}

	/**
	 * Stop viewport-following a user.
	 *
	 * @public
	 */
	stopFollowingUser = () => {
		this.updateInstanceState({
			followingUserId: null,
		})

		this.emit('stop-following')

		return this
	}

	animateToShape(shapeId: TLShapeId, opts: AnimationOptions = DEFAULT_ANIMATION_OPTIONS): this {
		if (!this.canMoveCamera) return this

		const activeArea = getActiveAreaScreenSpace(this)
		const viewportAspectRatio = activeArea.width / activeArea.height

		const shapePageBounds = this.getPageBoundsById(shapeId)

		if (!shapePageBounds) return this

		const shapeAspectRatio = shapePageBounds.width / shapePageBounds.height

		const targetViewportPage = shapePageBounds.clone()

		const z = shapePageBounds.width / activeArea.width
		targetViewportPage.width += (activeArea.left + activeArea.right) * z
		targetViewportPage.height += (activeArea.top + activeArea.bottom) * z
		targetViewportPage.x -= activeArea.left * z
		targetViewportPage.y -= activeArea.top * z

		if (shapeAspectRatio > viewportAspectRatio) {
			targetViewportPage.height = shapePageBounds.width / viewportAspectRatio
			targetViewportPage.y -= (targetViewportPage.height - shapePageBounds.height) / 2
		} else {
			targetViewportPage.width = shapePageBounds.height * viewportAspectRatio
			targetViewportPage.x -= (targetViewportPage.width - shapePageBounds.width) / 2
		}

		return this._animateToViewport(targetViewportPage, opts)
	}

	/**
	 * Blur the app, cancelling any interaction state.
	 *
	 * @example
	 *
	 * ```ts
	 * app.blur()
	 * ```
	 *
	 * @public
	 */
	blur() {
		this.complete()
		this.getContainer().blur()
		this._isFocused.set(false)
		return this
	}

	/**
	 * Focus the app.
	 *
	 * @example
	 *
	 * ```ts
	 * app.focus()
	 * ```
	 *
	 * @public
	 */
	focus() {
		this.getContainer().focus()
		this._isFocused.set(true)
		return this
	}

	/**
	 * Dispatch a cancel event.
	 *
	 * @example
	 *
	 * ```ts
	 * app.cancel()
	 * ```
	 *
	 * @public
	 */
	cancel() {
		this.dispatch({ type: 'misc', name: 'cancel' })
		return this
	}

	/**
	 * Dispatch an interrupt event.
	 *
	 * @example
	 *
	 * ```ts
	 * app.interrupt()
	 * ```
	 *
	 * @public
	 */
	interrupt() {
		this.dispatch({ type: 'misc', name: 'interrupt' })
		return this
	}

	/**
	 * Dispatch a complete event.
	 *
	 * @example
	 *
	 * ```ts
	 * app.complete()
	 * ```
	 *
	 * @public
	 */
	complete() {
		this.dispatch({ type: 'misc', name: 'complete' })
		return this
	}

	/* -------------------- Callbacks ------------------- */

	/**
	 * A callback fired when a file is converted to an asset. This callback should return the asset
	 * partial.
	 *
	 * @example
	 *
	 * ```ts
	 * app.onCreateAssetFromFile(myFile)
	 * ```
	 *
	 * @param file - The file to upload.
	 * @public
	 */

	async onCreateAssetFromFile(file: File): Promise<TLAsset> {
		return await getMediaAssetFromFile(file)
	}

	/**
	 * A callback fired when a URL is converted to a bookmark. This callback should return the
	 * metadata for the bookmark.
	 *
	 * @example
	 *
	 * ```ts
	 * app.onCreateBookmarkFromUrl(url, id)
	 * ```
	 *
	 * @param url - The url that was created.
	 * @public
	 */
	async onCreateBookmarkFromUrl(
		url: string
	): Promise<{ image: string; title: string; description: string }> {
		try {
			const resp = await fetch(url, { method: 'GET', mode: 'no-cors' })
			const html = await resp.text()
			const doc = new DOMParser().parseFromString(html, 'text/html')

			return {
				image: doc.head.querySelector('meta[property="og:image"]')?.getAttribute('content') ?? '',
				title: doc.head.querySelector('meta[property="og:title"]')?.getAttribute('content') ?? '',
				description:
					doc.head.querySelector('meta[property="og:description"]')?.getAttribute('content') ?? '',
			}
		} catch (error) {
			console.error(error)
			return { image: '', title: '', description: '' }
		}
	}

	/* ---------------- Text Measurement ---------------- */

	/**
	 * A helper for measuring text.
	 *
	 * @public
	 */
	textMeasure: TextManager

	/* --------------------- Groups --------------------- */

	groupShapes(ids: TLShapeId[] = this.selectedIds, groupId = createShapeId()) {
		if (this.isReadOnly) return this

		if (ids.length <= 1) return this

		const shapes = compact(this._getUnlockedShapeIds(ids).map((id) => this.getShapeById(id)))
		const sortedShapeIds = shapes.sort(sortByIndex).map((s) => s.id)
		const pageBounds = Box2d.Common(compact(shapes.map((id) => this.getPageBounds(id))))

		const { x, y } = pageBounds.point

		const parentId = this.findCommonAncestor(shapes) ?? this.currentPageId

		// Only group when the select tool is active
		if (this.currentToolId !== 'select') return this

		// If not already in idle, cancel the current interaction (get back to idle)
		if (!this.isIn('select.idle')) {
			this.cancel()
		}

		// Find all the shapes that have the same parentId, and use the highest index.
		const shapesWithRootParent = shapes
			.filter((shape) => shape.parentId === parentId)
			.sort(sortByIndex)

		const highestIndex = shapesWithRootParent[shapesWithRootParent.length - 1]?.index

		this.batch(() => {
			this.createShapes([
				{
					id: groupId,
					type: 'group',
					parentId,
					index: highestIndex,
					x,
					y,
					props: {
						opacity: '1',
					},
				},
			])
			this.reparentShapesById(sortedShapeIds, groupId)
			this.select(groupId)
		})

		return this
	}

	ungroupShapes(ids: TLShapeId[] = this.selectedIds) {
		if (this.isReadOnly) return this
		if (ids.length === 0) return this

		// Only ungroup when the select tool is active
		if (this.currentToolId !== 'select') return this

		// If not already in idle, cancel the current interaction (get back to idle)
		if (!this.isIn('select.idle')) {
			this.cancel()
		}

		// The ids of the selected shapes after ungrouping;
		// these include all of the grouped shapes children,
		// plus any shapes that were selected apart from the groups.
		const idsToSelect = new Set<TLShapeId>()

		// Get all groups in the selection
		const shapes = compact(ids.map((id) => this.getShapeById(id)))

		const groups: TLGroupShape[] = []

		shapes.forEach((shape) => {
			if (this.isShapeOfType(shape, TLGroupUtil)) {
				groups.push(shape)
			} else {
				idsToSelect.add(shape.id)
			}
		})

		if (groups.length === 0) return this

		this.batch(() => {
			let group: TLGroupShape

			for (let i = 0, n = groups.length; i < n; i++) {
				group = groups[i]
				const childIds = this.getSortedChildIds(group.id)

				for (let j = 0, n = childIds.length; j < n; j++) {
					idsToSelect.add(childIds[j])
				}

				this.reparentShapesById(childIds, group.parentId, group.index)
			}

			this.deleteShapes(groups.map((group) => group.id))
			this.select(...idsToSelect)
		})

		return this
	}
}

function alertMaxShapes(app: App, pageId = app.currentPageId) {
	const name = app.getPageById(pageId)!.name
	app.emit('max-shapes', { name, pageId, count: MAX_SHAPES_PER_PAGE })
}
