import { EMPTY_ARRAY, atom, computed, transact } from '@tldraw/state' import { ComputedCache, RecordType, StoreSnapshot } from '@tldraw/store' import { CameraRecordType, InstancePageStateRecordType, PageRecordType, StyleProp, StylePropValue, TLArrowShape, TLAsset, TLAssetId, TLAssetPartial, TLCursor, TLCursorType, TLDOCUMENT_ID, TLDocument, TLFrameShape, TLGeoShape, TLGroupShape, TLHandle, TLINSTANCE_ID, TLImageAsset, TLInstance, TLInstancePageState, TLPOINTER_ID, TLPage, TLPageId, TLParentId, TLRecord, TLShape, TLShapeId, TLShapePartial, TLStore, TLUnknownShape, TLVideoAsset, createShapeId, getShapePropKeysByStyle, isPageId, isShapeId, } from '@tldraw/tlschema' import { IndexKey, JsonObject, annotateError, assert, compact, dedupe, getIndexAbove, getIndexBetween, getIndices, getIndicesAbove, getIndicesBetween, getOwnProperty, hasOwnProperty, objectMapValues, sortById, sortByIndex, structuredClone, } from '@tldraw/utils' import { EventEmitter } from 'eventemitter3' import { flushSync } from 'react-dom' import { createRoot } from 'react-dom/client' import { TLUser, createTLUser } from '../config/createTLUser' import { checkShapesAndAddCore } from '../config/defaultShapes' import { ANIMATION_MEDIUM_MS, CAMERA_MOVING_TIMEOUT, CAMERA_SLIDE_FRICTION, COARSE_DRAG_DISTANCE, COLLABORATOR_IDLE_TIMEOUT, DEFAULT_ANIMATION_OPTIONS, DRAG_DISTANCE, FOLLOW_CHASE_PAN_SNAP, FOLLOW_CHASE_PAN_UNSNAP, FOLLOW_CHASE_PROPORTION, FOLLOW_CHASE_ZOOM_SNAP, FOLLOW_CHASE_ZOOM_UNSNAP, HIT_TEST_MARGIN, INTERNAL_POINTER_IDS, LONG_PRESS_DURATION, MAX_PAGES, MAX_SHAPES_PER_PAGE, MAX_ZOOM, MIN_ZOOM, ZOOMS, } from '../constants' import { Box } from '../primitives/Box' import { Mat, MatLike, MatModel } from '../primitives/Mat' import { Vec, VecLike } from '../primitives/Vec' import { EASINGS } from '../primitives/easings' import { Geometry2d } from '../primitives/geometry/Geometry2d' import { Group2d } from '../primitives/geometry/Group2d' import { intersectPolygonPolygon } from '../primitives/intersect' import { PI2, approximately, areAnglesCompatible, clamp, pointInPolygon } from '../primitives/utils' import { ReadonlySharedStyleMap, SharedStyle, SharedStyleMap } from '../utils/SharedStylesMap' import { WeakMapCache } from '../utils/WeakMapCache' import { dataUrlToFile } from '../utils/assets' import { getIncrementedName } from '../utils/getIncrementedName' import { getReorderingShapesChanges } from '../utils/reorderShapes' import { applyRotationToSnapshotShapes, getRotationSnapshot } from '../utils/rotation' import { uniqueId } from '../utils/uniqueId' import { arrowBindingsIndex } from './derivations/arrowBindingsIndex' import { notVisibleShapes } from './derivations/notVisibleShapes' import { parentsToChildren } from './derivations/parentsToChildren' import { deriveShapeIdsInCurrentPage } from './derivations/shapeIdsInCurrentPage' import { getSvgJsx } from './getSvgJsx' import { ClickManager } from './managers/ClickManager' import { EnvironmentManager } from './managers/EnvironmentManager' import { HistoryManager } from './managers/HistoryManager' import { ScribbleManager } from './managers/ScribbleManager' import { SideEffectManager } from './managers/SideEffectManager' import { SnapManager } from './managers/SnapManager/SnapManager' import { TextManager } from './managers/TextManager' import { TickManager } from './managers/TickManager' import { UserPreferencesManager } from './managers/UserPreferencesManager' import { ShapeUtil, TLResizeMode, TLShapeUtilConstructor } from './shapes/ShapeUtil' import { TLArrowInfo } from './shapes/shared/arrow/arrow-types' import { getCurvedArrowInfo } from './shapes/shared/arrow/curved-arrow' import { getArrowTerminalsInArrowSpace, getIsArrowStraight } from './shapes/shared/arrow/shared' import { getStraightArrowInfo } from './shapes/shared/arrow/straight-arrow' import { RootState } from './tools/RootState' import { StateNode, TLStateNodeConstructor } from './tools/StateNode' import { TLContent } from './types/clipboard-types' import { TLEventMap } from './types/emit-types' import { TLEventInfo, TLPinchEventInfo, TLPointerEventInfo, TLWheelEventInfo, } from './types/event-types' import { TLExternalAssetContent, TLExternalContent } from './types/external-content' import { TLCommandHistoryOptions } from './types/history-types' import { OptionalKeys, RequiredKeys, TLSvgOptions } from './types/misc-types' import { TLResizeHandle } from './types/selection-types' /** @public */ export type TLAnimationOptions = Partial<{ duration: number easing: (t: number) => number }> /** @public */ export type TLResizeShapeOptions = Partial<{ initialBounds: Box scaleOrigin: VecLike scaleAxisRotation: number initialShape: TLShape initialPageTransform: MatLike dragHandle: TLResizeHandle mode: TLResizeMode }> /** @public */ export interface TLEditorOptions { /** * 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 editor. These will be used to create and manage shapes in the editor. */ shapeUtils: readonly TLShapeUtilConstructor[] /** * An array of tools to use in the editor. These will be used to handle events and manage user interactions in the editor. */ tools: readonly TLStateNodeConstructor[] /** * Should return a containing html element which has all the styles applied to the editor. If not * given, the body element will be used. */ getContainer: () => HTMLElement /** * A user defined externally to replace the default user. */ user?: TLUser /** * The editor's initial active tool (or other state node id). */ initialState?: string /** * Whether to infer dark mode from the user's system preferences. Defaults to false. */ inferDarkMode?: boolean } /** @public */ export class Editor extends EventEmitter { constructor({ store, user, shapeUtils, tools, getContainer, initialState, inferDarkMode, }: TLEditorOptions) { super() this.store = store this.snaps = new SnapManager(this) this.user = new UserPreferencesManager(user ?? createTLUser(), inferDarkMode ?? false) this.getContainer = getContainer ?? (() => document.body) this.textMeasure = new TextManager(this) this._tickManager = new TickManager(this) class NewRoot extends RootState { static override initial = initialState ?? '' } this.root = new NewRoot(this) this.root.children = {} const allShapeUtils = checkShapesAndAddCore(shapeUtils) const _shapeUtils = {} as Record> const _styleProps = {} as Record, string>> const allStylesById = new Map>() for (const Util of allShapeUtils) { const util = new Util(this) _shapeUtils[Util.type] = util const propKeysByStyle = getShapePropKeysByStyle(Util.props ?? {}) _styleProps[Util.type] = propKeysByStyle for (const style of propKeysByStyle.keys()) { if (!allStylesById.has(style.id)) { allStylesById.set(style.id, style) } else if (allStylesById.get(style.id) !== style) { throw Error( `Multiple style props with id "${style.id}" in use. Style prop IDs must be unique.` ) } } } this.shapeUtils = _shapeUtils this.styleProps = _styleProps // Tools. // Accept tools from constructor parameters which may not conflict with the root note's default or // "baked in" tools, select and zoom. for (const Tool of [...tools]) { if (hasOwnProperty(this.root.children!, Tool.id)) { throw Error(`Can't override tool with id "${Tool.id}"`) } this.root.children![Tool.id] = new Tool(this, this.root) } this.environment = new EnvironmentManager(this) this.scribbles = new ScribbleManager(this) // Cleanup const invalidParents = new Set() const reparentArrow = (arrowId: TLArrowShape['id']) => { const arrow = this.getShape(arrowId) if (!arrow) return const { start, end } = arrow.props const startShape = start.type === 'binding' ? this.getShape(start.boundShapeId) : undefined const endShape = end.type === 'binding' ? this.getShape(end.boundShapeId) : undefined const parentPageId = this.getAncestorPageId(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) { const bindingParentId = (startShape || endShape)?.parentId // If the arrow and the shape that it is bound to have the same parent, then keep that parent if (bindingParentId && bindingParentId === arrow.parentId) { nextParentId = arrow.parentId } else { // if arrow has one binding, keep arrow on its own page nextParentId = parentPageId } } else { return } if (nextParentId && nextParentId !== arrow.parentId) { this.reparentShapes([arrowId], nextParentId) } const reparentedArrow = this.getShape(arrowId) if (!reparentedArrow) throw Error('no reparented arrow') const startSibling = this.getShapeNearestSibling(reparentedArrow, startShape) const endSibling = this.getShapeNearestSibling(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: IndexKey const higherSiblings = this.getSortedChildIdsForParent(highestSibling.parentId) .map((id) => this.getShape(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 }]) } } const 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 } } }]) } const 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.getShape(terminal.boundShapeId) const isShapeInSamePageAsArrow = this.getAncestorPageId(arrow) === this.getAncestorPageId(boundShape) if (!boundShape || !isShapeInSamePageAsArrow) { unbindArrowTerminal(arrow, handle) } } // always check the arrow parents reparentArrow(arrow.id) } const cleanupInstancePageState = ( prevPageState: TLInstancePageState, shapesNoLongerInPage: Set ) => { let nextPageState = null as null | TLInstancePageState const selectedShapeIds = prevPageState.selectedShapeIds.filter( (id) => !shapesNoLongerInPage.has(id) ) if (selectedShapeIds.length !== prevPageState.selectedShapeIds.length) { if (!nextPageState) nextPageState = { ...prevPageState } nextPageState.selectedShapeIds = selectedShapeIds } const erasingShapeIds = prevPageState.erasingShapeIds.filter( (id) => !shapesNoLongerInPage.has(id) ) if (erasingShapeIds.length !== prevPageState.erasingShapeIds.length) { if (!nextPageState) nextPageState = { ...prevPageState } nextPageState.erasingShapeIds = erasingShapeIds } if (prevPageState.hoveredShapeId && shapesNoLongerInPage.has(prevPageState.hoveredShapeId)) { if (!nextPageState) nextPageState = { ...prevPageState } nextPageState.hoveredShapeId = null } if (prevPageState.editingShapeId && shapesNoLongerInPage.has(prevPageState.editingShapeId)) { if (!nextPageState) nextPageState = { ...prevPageState } nextPageState.editingShapeId = null } const hintingShapeIds = prevPageState.hintingShapeIds.filter( (id) => !shapesNoLongerInPage.has(id) ) if (hintingShapeIds.length !== prevPageState.hintingShapeIds.length) { if (!nextPageState) nextPageState = { ...prevPageState } nextPageState.hintingShapeIds = hintingShapeIds } if (prevPageState.focusedGroupId && shapesNoLongerInPage.has(prevPageState.focusedGroupId)) { if (!nextPageState) nextPageState = { ...prevPageState } nextPageState.focusedGroupId = null } return nextPageState } this.sideEffects = new SideEffectManager(this) this.sideEffects.registerBatchCompleteHandler(() => { for (const parentId of invalidParents) { invalidParents.delete(parentId) const parent = this.getShape(parentId) if (!parent) continue const util = this.getShapeUtil(parent) const changes = util.onChildrenChange?.(parent) if (changes?.length) { this.updateShapes(changes, { squashing: true }) } } this.emit('update') }) this.sideEffects.registerBeforeDeleteHandler('shape', (record) => { // if the deleted shape has a parent shape make sure we call it's onChildrenChange callback if (record.parentId && isShapeId(record.parentId)) { invalidParents.add(record.parentId) } // clean up any arrows bound to this shape const bindings = this._getArrowBindingsIndex().get()[record.id] if (bindings?.length) { for (const { arrowId, handleId } of bindings) { const arrow = this.getShape(arrowId) if (!arrow) continue unbindArrowTerminal(arrow, handleId) } } const deletedIds = new Set([record.id]) const updates = compact( this.getPageStates().map((pageState) => { return cleanupInstancePageState(pageState, deletedIds) }) ) if (updates.length) { this.store.put(updates) } }) this.sideEffects.registerBeforeDeleteHandler('page', (record) => { // page was deleted, need to check whether it's the current page and select another one if so if (this.getInstanceState().currentPageId !== record.id) return const backupPageId = this.getPages().find((p) => p.id !== record.id)?.id if (!backupPageId) return this.store.put([{ ...this.getInstanceState(), currentPageId: backupPageId }]) // delete the camera and state for the page if necessary const cameraId = CameraRecordType.createId(record.id) const instance_PageStateId = InstancePageStateRecordType.createId(record.id) this.store.remove([cameraId, instance_PageStateId]) }) this.sideEffects.registerAfterChangeHandler('shape', (prev, next) => { if (this.isShapeOfType(next, 'arrow')) { 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._getArrowBindingsIndex().get()[id] if (boundArrows?.length) { for (const arrow of boundArrows) { 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.getPageStates()) { if (instancePageState.pageId === next.parentId) continue const nextPageState = cleanupInstancePageState(instancePageState, allMovingIds) if (nextPageState) { this.store.put([nextPageState]) } } } if (prev.parentId && isShapeId(prev.parentId)) { invalidParents.add(prev.parentId) } if (next.parentId !== prev.parentId && isShapeId(next.parentId)) { invalidParents.add(next.parentId) } }) this.sideEffects.registerAfterChangeHandler('instance_page_state', (prev, next) => { if (prev?.selectedShapeIds !== next?.selectedShapeIds) { // ensure that descendants and ancestors are not selected at the same time const filtered = next.selectedShapeIds.filter((id) => { let parentId = this.getShape(id)?.parentId while (isShapeId(parentId)) { if (next.selectedShapeIds.includes(parentId)) { return false } parentId = this.getShape(parentId)?.parentId } return true }) let nextFocusedGroupId: null | TLShapeId = null if (filtered.length > 0) { const commonGroupAncestor = this.findCommonAncestor( compact(filtered.map((id) => this.getShape(id))), (shape) => this.isShapeOfType(shape, 'group') ) if (commonGroupAncestor) { nextFocusedGroupId = commonGroupAncestor } } else { if (next?.focusedGroupId) { nextFocusedGroupId = next.focusedGroupId } } if ( filtered.length !== next.selectedShapeIds.length || nextFocusedGroupId !== next.focusedGroupId ) { this.store.put([ { ...next, selectedShapeIds: filtered, focusedGroupId: nextFocusedGroupId ?? null }, ]) } } }) this.sideEffects.registerAfterCreateHandler('shape', (record) => { if (this.isShapeOfType(record, 'arrow')) { arrowDidUpdate(record) } }) this.sideEffects.registerAfterCreateHandler('page', (record) => { const cameraId = CameraRecordType.createId(record.id) const _pageStateId = InstancePageStateRecordType.createId(record.id) if (!this.store.has(cameraId)) { this.store.put([CameraRecordType.create({ id: cameraId })]) } if (!this.store.has(_pageStateId)) { this.store.put([ InstancePageStateRecordType.create({ id: _pageStateId, pageId: record.id }), ]) } }) this._currentPageShapeIds = deriveShapeIdsInCurrentPage(this.store, () => this.getCurrentPageId() ) this._parentIdsToChildIds = parentsToChildren(this.store) this.disposables.add( this.store.listen((changes) => { this.emit('change', changes) }) ) this.store.ensureStoreIsUsable() // clear ephemeral state this._setInstancePageState( { editingShapeId: null, hoveredShapeId: null, erasingShapeIds: [], }, { ephemeral: true } ) if (initialState && this.root.children[initialState] === undefined) { throw Error(`No state found for initialState "${initialState}".`) } this.root.enter(undefined, 'initial') if (this.getInstanceState().followingUserId) { this.stopFollowingUser() } this.updateRenderingBounds() this.on('tick', this._flushEventsForTick) requestAnimationFrame(() => { this._tickManager.start() }) } /** * The editor's store * * @public */ readonly store: TLStore /** * The root state of the statechart. * * @public */ readonly root: RootState /** * A set of functions to call when the app is disposed. * * @public */ readonly disposables = new Set<() => void>() /** @internal */ private readonly _tickManager /** * A manager for the app's snapping feature. * * @public */ readonly snaps: SnapManager /** * A manager for the user and their preferences. * * @public */ readonly user: UserPreferencesManager /** * A helper for measuring text. * * @public */ readonly textMeasure: TextManager /** * A manager for the editor's environment. * * @public */ readonly environment: EnvironmentManager /** * A manager for the editor's scribbles. * * @public */ readonly scribbles: ScribbleManager /** * The current HTML element containing the editor. * * @example * ```ts * const container = editor.getContainer() * ``` * * @public */ getContainer: () => HTMLElement /** * A manager for side effects and correct state enforcement. See {@link SideEffectManager} for details. * * @public */ readonly sideEffects: SideEffectManager /** * Dispose the editor. * * @public */ dispose() { this.disposables.forEach((dispose) => dispose()) this.disposables.clear() } /* ------------------- Shape Utils ------------------ */ /** * A map of shape utility classes (TLShapeUtils) by shape type. * * @public */ shapeUtils: { readonly [K in string]?: ShapeUtil } styleProps: { [key: string]: Map, string> } /** * Get a shape util from a shape itself. * * @example * ```ts * const util = editor.getShapeUtil(myArrowShape) * const util = editor.getShapeUtil('arrow') * const util = editor.getShapeUtil(myArrowShape) * const util = editor.getShapeUtil(TLArrowShape)('arrow') * ``` * * @param shape - A shape, shape partial, or shape type. * * @public */ getShapeUtil(shape: S | TLShapePartial): ShapeUtil getShapeUtil(type: S['type']): ShapeUtil getShapeUtil(type: T extends ShapeUtil ? R['type'] : string): T getShapeUtil(arg: string | { type: string }) { const type = typeof arg === 'string' ? arg : arg.type const shapeUtil = getOwnProperty(this.shapeUtils, type) assert(shapeUtil, `No shape util found for type "${type}"`) return shapeUtil } /* --------------------- History -------------------- */ /** * 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 * editor.undo() * ``` * * @public */ undo(): this { this._flushEventsForTick(0) this.history.undo() return this } /** * Whether the app can undo. * * @public */ @computed getCanUndo(): boolean { return this.history.getNumUndos() > 0 } /** * Redo to the next mark. * * @example * ```ts * editor.redo() * ``` * * @public */ redo(): this { this._flushEventsForTick(0) this.history.redo() return this } /** * Whether the app can redo. * * @public */ @computed getCanRedo(): boolean { return this.history.getNumRedos() > 0 } /** * Create a new "mark", or stopping point, in the undo redo history. Creating a mark will clear * any redos. * * @example * ```ts * editor.mark() * editor.mark('flip shapes') * ``` * * @param markId - The mark's id, usually the reason for adding the mark. * @param onUndo - Whether to stop at the mark when undoing. * @param onRedo - Whether to stop at the mark when redoing. * * @public */ mark(markId?: string, onUndo?: boolean, onRedo?: boolean): this { this.history.mark(markId, onUndo, onRedo) return this } /** * Clear all marks in the undo stack back to the next mark. * * @example * ```ts * editor.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 * editor.bailToMark('dragging') * ``` * * @public */ bailToMark(id: string): this { this.history.bailToMark(id) return this } /** * Run a function in a batch. * * @public */ batch(fn: () => void): this { this.history.batch(fn) return this } /* --------------------- Arrows --------------------- */ // todo: move these to tldraw or replace with a bindings API /** @internal */ @computed private _getArrowBindingsIndex() { return arrowBindingsIndex(this) } /** * Get all arrows bound to a shape. * * @param shapeId - The id of the shape. * * @public */ getArrowsBoundTo(shapeId: TLShapeId) { return this._getArrowBindingsIndex().get()[shapeId] || EMPTY_ARRAY } @computed private getArrowInfoCache() { return this.store.createComputedCache('arrow infoCache', (shape) => { return getIsArrowStraight(shape) ? getStraightArrowInfo(this, shape) : getCurvedArrowInfo(this, shape) }) } /** * Get cached info about an arrow. * * @example * ```ts * const arrowInfo = editor.getArrowInfo(myArrow) * ``` * * @param shape - The shape (or shape id) of the arrow to get the info for. * * @public */ getArrowInfo(shape: TLArrowShape | TLShapeId): TLArrowInfo | undefined { const id = typeof shape === 'string' ? shape : shape.id return this.getArrowInfoCache().get(id) } /* --------------------- Errors --------------------- */ /** @internal */ annotateError( error: unknown, { origin, willCrashApp, tags, extras, }: { origin: string willCrashApp: boolean tags?: Record extras?: Record } ): this { const defaultAnnotations = this.createErrorAnnotations(origin, willCrashApp) annotateError(error, { tags: { ...defaultAnnotations.tags, ...tags }, extras: { ...defaultAnnotations.extras, ...extras }, }) if (willCrashApp) { this.store.markAsPossiblyCorrupted() } return this } /** @internal */ createErrorAnnotations( origin: string, willCrashApp: boolean | 'unknown' ): { tags: { origin: string; willCrashApp: boolean | 'unknown' } extras: { activeStateNode?: string selectedShapes?: TLUnknownShape[] editingShape?: TLUnknownShape inputs?: Record } } { try { const editingShapeId = this.getEditingShapeId() return { tags: { origin: origin, willCrashApp, }, extras: { activeStateNode: this.root.getPath(), selectedShapes: this.getSelectedShapes(), editingShape: editingShapeId ? this.getShape(editingShapeId) : 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 */ getCrashingError() { return this._crashingError } /** @internal */ crash(error: unknown): this { this._crashingError = error this.store.markAsPossiblyCorrupted() this.emit('crash', { error }) return this } /* ------------------- Statechart ------------------- */ /** * The editor's current path of active states. * * @example * ```ts * editor.getPath() // "select.idle" * ``` * * @public */ @computed getPath() { return this.root.getPath().split('root.')[1] } /** * Get whether a certain tool (or other state node) is currently active. * * @example * ```ts * editor.isIn('select') * editor.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.getCurrent() 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)) } /** * Set the selected tool. * * @example * ```ts * editor.setCurrentTool('hand') * editor.setCurrentTool('hand', { date: Date.now() }) * ``` * * @param id - The id of the tool to select. * @param info - Arbitrary data to pass along into the transition. * * @public */ setCurrentTool(id: string, info = {}): this { this.root.transition(id, info) return this } /** * The current selected tool. * * @public */ @computed getCurrentTool(): StateNode { return this.root.getCurrent()! } /** * The id of the current selected tool. * * @public */ @computed getCurrentToolId(): string { const currentTool = this.getCurrentTool() if (!currentTool) return '' return currentTool.getCurrentToolIdMask() ?? currentTool.id } /** * 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): T | undefined { const ids = path.split('.').reverse() let state = this.root as StateNode while (ids.length > 0) { const id = ids.pop() if (!id) return state as T const childState = state.children?.[id] if (!childState) return undefined state = childState } return state as T } /* ---------------- Document Settings --------------- */ /** * The global document settings that apply to all users. * * @public **/ @computed getDocumentSettings() { return this.store.get(TLDOCUMENT_ID)! } /** * Update the global document settings that apply to all users. * * @public **/ updateDocumentSettings(settings: Partial): this { this.store.put([{ ...this.getDocumentSettings(), ...settings }]) return this } /* ----------------- Instance State ----------------- */ /** * The current instance's state. * * @public */ @computed getInstanceState(): TLInstance { return this.store.get(TLINSTANCE_ID)! } /** * Update the instance's state. * * @param partial - A partial object to update the instance state with. * @param historyOptions - The history options for the change. * * @public */ updateInstanceState( partial: Partial>, historyOptions?: TLCommandHistoryOptions ): this { this._updateInstanceState(partial, { ephemeral: true, squashing: true, ...historyOptions }) if (partial.isChangingStyle !== undefined) { clearTimeout(this._isChangingStyleTimeout) if (partial.isChangingStyle === true) { // If we've set to true, set a new reset timeout to change the value back to false after 2 seconds this._isChangingStyleTimeout = setTimeout(() => { this.updateInstanceState({ isChangingStyle: false }, { ephemeral: true }) }, 2000) } } return this } /** @internal */ private _updateInstanceState = this.history.createCommand( 'updateInstanceState', ( partial: Partial>, historyOptions?: TLCommandHistoryOptions ) => { const prev = this.store.get(this.getInstanceState().id)! const next = { ...prev, ...partial } return { data: { prev, next }, ephemeral: false, squashing: false, ...historyOptions, } }, { do: ({ next }) => { this.store.put([next]) }, undo: ({ prev }) => { this.store.put([prev]) }, squash({ prev }, { next }) { return { prev, next } }, } ) /** @internal */ private _isChangingStyleTimeout = -1 as any // Menus /** * 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 getOpenMenus(): string[] { return this.getInstanceState().openMenus } /** * Add an open menu. * * @example * ```ts * editor.addOpenMenu('menu-id') * ``` * * @public */ addOpenMenu(id: string): this { const menus = new Set(this.getOpenMenus()) if (!menus.has(id)) { menus.add(id) this.updateInstanceState({ openMenus: [...menus] }) } return this } /** * Delete an open menu. * * @example * ```ts * editor.deleteOpenMenu('menu-id') * ``` * * @public */ deleteOpenMenu(id: string): this { const menus = new Set(this.getOpenMenus()) if (menus.has(id)) { menus.delete(id) this.updateInstanceState({ openMenus: [...menus] }) } return this } /** * Clear all open menus. * * @example * ```ts * editor.clearOpenMenus() * ``` * * @public */ clearOpenMenus(): this { if (this.getOpenMenus().length) { this.updateInstanceState({ openMenus: [] }) } return this } /** * Get whether any menus are open. * * @example * ```ts * editor.isMenuOpen() * ``` * * @public */ @computed getIsMenuOpen(): boolean { return this.getOpenMenus().length > 0 } /* --------------------- Cursor --------------------- */ /** * Set the cursor. * * @param type - The cursor type. * @param rotation - The cursor rotation. * * @public */ setCursor = (cursor: Partial): this => { this.updateInstanceState( { cursor: { ...this.getInstanceState().cursor, ...cursor } }, { ephemeral: true } ) return this } /* ------------------- Page State ------------------- */ /** * Page states. * * @public */ @computed getPageStates(): TLInstancePageState[] { return this._getPageStatesQuery().get() } /** @internal */ @computed private _getPageStatesQuery() { return this.store.query.records('instance_page_state') } /** * The current page state. * * @public */ @computed getCurrentPageState(): TLInstancePageState { return this.store.get(this._getCurrentPageStateId())! } /** @internal */ @computed private _getCurrentPageStateId() { return InstancePageStateRecordType.createId(this.getCurrentPageId()) } /** * Update this instance's page state. * * @example * ```ts * editor.updateCurrentPageState({ id: 'page1', editingShapeId: 'shape:123' }) * editor.updateCurrentPageState({ id: 'page1', editingShapeId: 'shape:123' }, { ephemeral: true }) * ``` * * @param partial - The partial of the page state object containing the changes. * @param historyOptions - The history options for the change. * * @public */ updateCurrentPageState( partial: Partial< Omit >, historyOptions?: TLCommandHistoryOptions ): this { this._setInstancePageState(partial, historyOptions) return this } /** @internal */ private _setInstancePageState = this.history.createCommand( 'setInstancePageState', ( partial: Partial>, historyOptions?: TLCommandHistoryOptions ) => { const prev = this.store.get(partial.id ?? this.getCurrentPageState().id)! return { data: { prev, partial }, ...historyOptions } }, { do: ({ prev, partial }) => { this.store.update(prev.id, (state) => ({ ...state, ...partial })) }, undo: ({ prev }) => { this.store.update(prev.id, () => prev) }, } ) /** * The current selected ids. * * @public */ @computed getSelectedShapeIds() { return this.getCurrentPageState().selectedShapeIds } /** * An array containing all of the currently selected shapes. * * @public * @readonly */ @computed getSelectedShapes(): TLShape[] { const { selectedShapeIds } = this.getCurrentPageState() return compact(selectedShapeIds.map((id) => this.store.get(id))) } /** * Select one or more shapes. * * @example * ```ts * editor.setSelectedShapes(['id1']) * editor.setSelectedShapes(['id1', 'id2']) * ``` * * @param ids - The ids to select. * @param historyOptions - The history options for the change. * * @public */ setSelectedShapes( shapes: TLShapeId[] | TLShape[], historyOptions?: TLCommandHistoryOptions ): this { const ids = shapes.map((shape) => (typeof shape === 'string' ? shape : shape.id)) this._setSelectedShapes(ids, historyOptions) return this } /** @internal */ private _setSelectedShapes = this.history.createCommand( 'setSelectedShapes', (ids: TLShapeId[], historyOptions?: TLCommandHistoryOptions) => { const { selectedShapeIds: prevSelectedShapeIds } = this.getCurrentPageState() const prevSet = new Set(prevSelectedShapeIds) if (ids.length === prevSet.size && ids.every((id) => prevSet.has(id))) return null return { data: { selectedShapeIds: ids, prevSelectedShapeIds }, preservesRedoStack: true, ...historyOptions, } }, { do: ({ selectedShapeIds }) => { this.store.put([{ ...this.getCurrentPageState(), selectedShapeIds }]) }, undo: ({ prevSelectedShapeIds }) => { this.store.put([ { ...this.getCurrentPageState(), selectedShapeIds: prevSelectedShapeIds, }, ]) }, squash({ prevSelectedShapeIds }, { selectedShapeIds }) { return { selectedShapeIds, prevSelectedShapeIds, } }, } ) /** * Select one or more shapes. * * @example * ```ts * editor.select('id1') * editor.select('id1', 'id2') * ``` * * @param ids - The ids to select. * * @public */ select(...shapes: TLShapeId[] | TLShape[]): this { const ids = typeof shapes[0] === 'string' ? (shapes as TLShapeId[]) : (shapes as TLShape[]).map((shape) => shape.id) this.setSelectedShapes(ids) return this } /** * Remove a shape from the existing set of selected shapes. * * @example * ```ts * editor.deselect(shape.id) * ``` * * @public */ deselect(...shapes: TLShapeId[] | TLShape[]): this { const ids = typeof shapes[0] === 'string' ? (shapes as TLShapeId[]) : (shapes as TLShape[]).map((shape) => shape.id) const selectedShapeIds = this.getSelectedShapeIds() if (selectedShapeIds.length > 0 && ids.length > 0) { this.setSelectedShapes(selectedShapeIds.filter((id) => !ids.includes(id))) } return this } /** * Select all direct children of the current page. * * @example * ```ts * editor.selectAll() * ``` * * @public */ selectAll(): this { const ids = this.getSortedChildIdsForParent(this.getCurrentPageId()) // page might have no shapes if (ids.length <= 0) return this this.setSelectedShapes(this._getUnlockedShapeIds(ids)) return this } /** * Clear the selection. * * @example * ```ts * editor.selectNone() * ``` * * @public */ selectNone(): this { if (this.getSelectedShapeIds().length > 0) { this.setSelectedShapes([]) } return this } /** * The id of the app's only selected shape. * * @returns Null if there is no shape or more than one selected shape, otherwise the selected shape's id. * * @public * @readonly */ @computed getOnlySelectedShapeId(): TLShapeId | null { return this.getOnlySelectedShape()?.id ?? null } /** * The app's only selected shape. * * @returns Null if there is no shape or more than one selected shape, otherwise the selected shape. * * @public * @readonly */ @computed getOnlySelectedShape(): TLShape | null { const selectedShapes = this.getSelectedShapes() return selectedShapes.length === 1 ? selectedShapes[0] : null } /** * The current page bounds of all the selected shapes. If the * selection is rotated, then these bounds are the axis-aligned * box that the rotated bounds would fit inside of. * * @readonly * * @public */ @computed getSelectionPageBounds(): Box | null { const selectedShapeIds = this.getCurrentPageState().selectedShapeIds if (selectedShapeIds.length === 0) return null return Box.Common(compact(selectedShapeIds.map((id) => this.getShapePageBounds(id)))) } /** * The rotation of the selection bounding box in the current page space. * * @readonly * @public */ @computed getSelectionRotation(): number { const selectedShapeIds = this.getSelectedShapeIds() let foundFirst = false // annoying but we can't use an i===0 check because we need to skip over undefineds let rotation = 0 for (let i = 0, n = selectedShapeIds.length; i < n; i++) { const pageTransform = this.getShapePageTransform(selectedShapeIds[i]) if (!pageTransform) continue if (foundFirst) { if (pageTransform.rotation() !== rotation) { // There are at least 2 different rotations, so the common rotation is zero return 0 } } else { // First rotation found foundFirst = true rotation = pageTransform.rotation() } } return rotation } /** * The bounds of the selection bounding box in the current page space. * * @readonly * @public */ @computed getSelectionRotatedPageBounds(): Box | undefined { const selectedShapeIds = this.getSelectedShapeIds() if (selectedShapeIds.length === 0) { return undefined } const selectionRotation = this.getSelectionRotation() if (selectionRotation === 0) { return this.getSelectionPageBounds()! } if (selectedShapeIds.length === 1) { const bounds = this.getShapeGeometry(selectedShapeIds[0]).bounds.clone() const pageTransform = this.getShapePageTransform(selectedShapeIds[0])! bounds.point = pageTransform.applyToPoint(bounds.point) return bounds } // need to 'un-rotate' all the outlines of the existing nodes so we can fit them inside a box const boxFromRotatedVertices = Box.FromPoints( this.getSelectedShapeIds() .flatMap((id) => { const pageTransform = this.getShapePageTransform(id) if (!pageTransform) return [] return pageTransform.applyToPoints(this.getShapeGeometry(id).bounds.corners) }) .map((p) => p.rot(-selectionRotation)) ) // now position box so that it's top-left corner is in the right place boxFromRotatedVertices.point = boxFromRotatedVertices.point.rot(selectionRotation) return boxFromRotatedVertices } /** * The bounds of the selection bounding box in the current page space. * * @readonly * @public */ @computed getSelectionRotatedScreenBounds(): Box | undefined { const bounds = this.getSelectionRotatedPageBounds() if (!bounds) return undefined const { x, y } = this.pageToScreen(bounds.point) const zoom = this.getZoomLevel() return new Box(x, y, bounds.width * zoom, bounds.height * zoom) } // Focus Group /** * The current focused group id. * * @public */ @computed getFocusedGroupId(): TLShapeId | TLPageId { return this.getCurrentPageState().focusedGroupId ?? this.getCurrentPageId() } /** * The current focused group. * * @public */ @computed getFocusedGroup(): TLShape | undefined { const focusedGroupId = this.getFocusedGroupId() return focusedGroupId ? this.getShape(focusedGroupId) : undefined } /** * Set the current focused group shape. * * @param shape - The group shape id (or group shape's id) to set as the focused group shape. * * @public */ setFocusedGroup(shape: TLShapeId | TLGroupShape | null): this { const id = typeof shape === 'string' ? shape : shape?.id ?? null if (id !== null) { const shape = this.getShape(id) if (!shape) { throw Error(`Editor.setFocusedGroup: Shape with id ${id} does not exist`) } if (!this.isShapeOfType(shape, 'group')) { throw Error( `Editor.setFocusedGroup: Cannot set focused group to shape of type ${shape.type}` ) } } if (id === this.getFocusedGroupId()) return this this._setFocusedGroupId(id) return this } /** @internal */ private _setFocusedGroupId = this.history.createCommand( 'setFocusedGroupId', (next: TLShapeId | null) => { const prev = this.getCurrentPageState().focusedGroupId if (prev === next) return return { data: { prev, next, }, preservesRedoStack: true, squashing: true, } }, { do: ({ next }) => { this.store.update(this.getCurrentPageState().id, (s) => ({ ...s, focusedGroupId: next })) }, undo: ({ prev }) => { this.store.update(this.getCurrentPageState().id, (s) => ({ ...s, focusedGroupId: prev })) }, squash({ prev }, { next }) { return { prev, next } }, } ) /** * Exit the current focused group, moving up to the next parent group if there is one. * * @public */ popFocusedGroupId(): this { const focusedGroup = this.getFocusedGroup() if (focusedGroup) { // If we have a focused layer, look for an ancestor of the focused shape that is a group const match = this.findShapeAncestor(focusedGroup, (shape) => this.isShapeOfType(shape, 'group') ) // If we have an ancestor that can become a focused layer, set it as the focused layer this.setFocusedGroup(match?.id ?? null) this.select(focusedGroup.id) } else { // If there's no parent focused group, then clear the focus layer and clear selection this.setFocusedGroup(null) this.selectNone() } return this } /** * The current editing shape's id. * * @public */ @computed getEditingShapeId(): TLShapeId | null { return this.getCurrentPageState().editingShapeId } /** * The current editing shape. * * @public */ @computed getEditingShape(): TLShape | undefined { const editingShapeId = this.getEditingShapeId() return editingShapeId ? this.getShape(editingShapeId) : undefined } /** * Set the current editing shape. * * @example * ```ts * editor.setEditingShape(myShape) * editor.setEditingShape(myShape.id) * ``` * * @param shape - The shape (or shape id) to set as editing. * * @public */ setEditingShape(shape: TLShapeId | TLShape | null): this { const id = typeof shape === 'string' ? shape : shape?.id ?? null if (id !== this.getEditingShapeId()) { if (id) { const shape = this.getShape(id) if (shape && this.getShapeUtil(shape).canEdit(shape)) { this._setInstancePageState({ editingShapeId: id }) return this } } // Either we just set the editing id to null, or the shape was missing or not editable this._setInstancePageState({ editingShapeId: null }) } return this } // Hovered /** * The current hovered shape id. * * @readonly * @public */ @computed getHoveredShapeId(): TLShapeId | null { return this.getCurrentPageState().hoveredShapeId } /** * The current hovered shape. * * @public */ @computed getHoveredShape(): TLShape | undefined { const hoveredShapeId = this.getHoveredShapeId() return hoveredShapeId ? this.getShape(hoveredShapeId) : undefined } /** * Set the editor's current hovered shape. * * @example * ```ts * editor.setHoveredShape(myShape) * editor.setHoveredShape(myShape.id) * ``` * * @param shapes - The shape (or shape id) to set as hovered. * * @public */ setHoveredShape(shape: TLShapeId | TLShape | null): this { const id = typeof shape === 'string' ? shape : shape?.id ?? null if (id === this.getHoveredShapeId()) return this this.updateCurrentPageState({ hoveredShapeId: id }, { ephemeral: true }) return this } // Hinting /** * The editor's current hinting shape ids. * * @public */ @computed getHintingShapeIds() { return this.getCurrentPageState().hintingShapeIds } /** * The editor's current hinting shapes. * * @public */ @computed getHintingShape() { const hintingShapeIds = this.getHintingShapeIds() return compact(hintingShapeIds.map((id) => this.getShape(id))) } /** * Set the editor's current hinting shapes. * * @example * ```ts * editor.setHintingShapes([myShape]) * editor.setHintingShapes([myShape.id]) * ``` * * @param shapes - The shapes (or shape ids) to set as hinting. * * @public */ setHintingShapes(shapes: TLShapeId[] | TLShape[]): this { const ids = typeof shapes[0] === 'string' ? (shapes as TLShapeId[]) : (shapes as TLShape[]).map((shape) => shape.id) // always ephemeral this.updateCurrentPageState({ hintingShapeIds: dedupe(ids) }, { ephemeral: true }) return this } // Erasing /** * The editor's current erasing ids. * * @public */ @computed getErasingShapeIds() { return this.getCurrentPageState().erasingShapeIds } /** * The editor's current erasing shapes. * * @public */ @computed getErasingShapes() { const erasingShapeIds = this.getErasingShapeIds() return compact(erasingShapeIds.map((id) => this.getShape(id))) } /** * Set the editor's current erasing shapes. * * @example * ```ts * editor.setErasingShapes([myShape]) * editor.setErasingShapes([myShape.id]) * ``` * * @param shapes - The shapes (or shape ids) to set as hinting. * * @public */ setErasingShapes(shapes: TLShapeId[] | TLShape[]): this { const ids = typeof shapes[0] === 'string' ? (shapes as TLShapeId[]) : (shapes as TLShape[]).map((shape) => shape.id) ids.sort() // sort the incoming ids const erasingShapeIds = this.getErasingShapeIds() if (ids.length === erasingShapeIds.length) { // if the new ids are the same length as the current ids, they might be the same. // presuming the current ids are also sorted, check each item to see if it's the same; // if we find any unequal, then we know the new ids are different. for (let i = 0; i < ids.length; i++) { if (ids[i] !== erasingShapeIds[i]) { this._setInstancePageState({ erasingShapeIds: ids }, { ephemeral: true }) break } } } else { // if the ids are a different length, then we know they're different. this._setInstancePageState({ erasingShapeIds: ids }, { ephemeral: true }) } return this } // Cropping /** * The current cropping shape's id. * * @public */ getCroppingShapeId() { return this.getCurrentPageState().croppingShapeId } /** * Set the current cropping shape. * * @example * ```ts * editor.setCroppingShape(myShape) * editor.setCroppingShape(myShape.id) * ``` * * * @param shape - The shape (or shape id) to set as cropping. * * @public */ setCroppingShape(shape: TLShapeId | TLShape | null): this { const id = typeof shape === 'string' ? shape : shape?.id ?? null if (id !== this.getCroppingShapeId()) { if (!id) { this.updateCurrentPageState({ croppingShapeId: null }) } else { const shape = this.getShape(id)! const util = this.getShapeUtil(shape) if (shape && util.canCrop(shape)) { this.updateCurrentPageState({ croppingShapeId: id }) } } } return this } /* --------------------- Camera --------------------- */ /** @internal */ @computed private getCameraId() { return CameraRecordType.createId(this.getCurrentPageId()) } /** * The current camera. * * @public */ @computed getCamera() { return this.store.get(this.getCameraId())! } /** * The current camera zoom level. * * @public */ @computed getZoomLevel() { return this.getCamera().z } /** @internal */ private _setCamera(point: VecLike, immediate = false): this { const currentCamera = this.getCamera() if (currentCamera.x === point.x && currentCamera.y === point.y && currentCamera.z === point.z) { return this } this.batch(() => { const camera = { ...currentCamera, ...point } this.store.put([camera]) // include id and meta here // Dispatch a new pointer move because the pointer's page will have changed // (its screen position will compute to a new page position given the new camera position) const { currentScreenPoint, currentPagePoint } = this.inputs const { screenBounds } = this.store.unsafeGetWithoutCapture(TLINSTANCE_ID)! // compare the next page point (derived from the curent camera) to the current page point if ( currentScreenPoint.x / camera.z - camera.x !== currentPagePoint.x || currentScreenPoint.y / camera.z - camera.y !== currentPagePoint.y ) { // If it's changed, dispatch a pointer event const event: TLPointerEventInfo = { type: 'pointer', target: 'canvas', name: 'pointer_move', // weird but true: we need to put the screen point back into client space point: Vec.AddXY(currentScreenPoint, screenBounds.x, screenBounds.y), pointerId: INTERNAL_POINTER_IDS.CAMERA_MOVE, ctrlKey: this.inputs.ctrlKey, altKey: this.inputs.altKey, shiftKey: this.inputs.shiftKey, button: 0, isPen: this.getInstanceState().isPenMode ?? false, } if (immediate) { this._flushEventForTick(event) } else { this.dispatch(event) } } this._tickCameraState() }) return this } /** * Set the current camera. * * @example * ```ts * editor.setCamera({ x: 0, y: 0}) * editor.setCamera({ x: 0, y: 0, z: 1.5}) * editor.setCamera({ x: 0, y: 0, z: 1.5}, { duration: 1000, easing: (t) => t * t }) * ``` * * @param point - The new camera position. * @param animation - Options for an animation. * * @public */ setCamera(point: VecLike, animation?: TLAnimationOptions): this { const x = Number.isFinite(point.x) ? point.x : 0 const y = Number.isFinite(point.y) ? point.y : 0 const z = Number.isFinite(point.z) ? point.z! : this.getZoomLevel() // Stop any camera animations this.stopCameraAnimation() // Stop following any user if (this.getInstanceState().followingUserId) { this.stopFollowingUser() } if (animation) { const { width, height } = this.getViewportScreenBounds() return this._animateToViewport(new Box(-x, -y, width / z, height / z), animation) } else { this._setCamera({ x, y, z }) } return this } /** * Center the camera on a point (in the current page space). * * @example * ```ts * editor.centerOnPoint({ x: 100, y: 100 }) * editor.centerOnPoint({ x: 100, y: 100 }, { duration: 200 }) * ``` * * @param point - The point in the current page space to center on. * @param animation - The options for an animation. * * @public */ centerOnPoint(point: VecLike, animation?: TLAnimationOptions): this { if (!this.getInstanceState().canMoveCamera) return this const { width: pw, height: ph } = this.getViewportPageBounds() this.setCamera( { x: -(point.x - pw / 2), y: -(point.y - ph / 2), z: this.getCamera().z }, animation ) return this } /** * Move the camera to the nearest content. * * @example * ```ts * editor.zoomToContent() * editor.zoomToContent({ duration: 200 }) * ``` * * @param opts - The options for an animation. * * @public */ zoomToContent(opts: TLAnimationOptions = { duration: 220 }): this { const bounds = this.getSelectionPageBounds() ?? this.getCurrentPageBounds() if (bounds) { this.zoomToBounds(bounds, { targetZoom: Math.min(1, this.getZoomLevel()), ...opts }) } return this } /** * Zoom the camera to fit the current page's content in the viewport. * * @example * ```ts * editor.zoomToFit() * editor.zoomToFit({ duration: 200 }) * ``` * * @param animation - The options for an animation. * * @public */ zoomToFit(animation?: TLAnimationOptions): this { if (!this.getInstanceState().canMoveCamera) return this const ids = [...this.getCurrentPageShapeIds()] if (ids.length <= 0) return this const pageBounds = Box.Common(compact(ids.map((id) => this.getShapePageBounds(id)))) this.zoomToBounds(pageBounds, animation) return this } /** * Set the zoom back to 100%. * * @example * ```ts * editor.resetZoom() * editor.resetZoom(editor.getViewportScreenCenter(), { duration: 200 }) * editor.resetZoom(editor.getViewportScreenCenter(), { duration: 200 }) * ``` * * @param point - The screen point to zoom out on. Defaults to the viewport screen center. * @param animation - The options for an animation. * * @public */ resetZoom(point = this.getViewportScreenCenter(), animation?: TLAnimationOptions): this { if (!this.getInstanceState().canMoveCamera) return this const { x: cx, y: cy, z: cz } = this.getCamera() const { x, y } = point this.setCamera( { x: cx + (x / 1 - x) - (x / cz - x), y: cy + (y / 1 - y) - (y / cz - y), z: 1 }, animation ) return this } /** * Zoom the camera in. * * @example * ```ts * editor.zoomIn() * editor.zoomIn(editor.getViewportScreenCenter(), { duration: 120 }) * editor.zoomIn(editor.inputs.currentScreenPoint, { duration: 120 }) * ``` * * @param animation - The options for an animation. * * @public */ zoomIn(point = this.getViewportScreenCenter(), animation?: TLAnimationOptions): this { if (!this.getInstanceState().canMoveCamera) return this const { x: cx, y: cy, z: cz } = this.getCamera() 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 this.setCamera( { x: cx + (x / zoom - x) - (x / cz - x), y: cy + (y / zoom - y) - (y / cz - y), z: zoom }, animation ) return this } /** * Zoom the camera out. * * @example * ```ts * editor.zoomOut() * editor.zoomOut(editor.getViewportScreenCenter(), { duration: 120 }) * editor.zoomOut(editor.inputs.currentScreenPoint, { duration: 120 }) * ``` * * @param animation - The options for an animation. * * @public */ zoomOut(point = this.getViewportScreenCenter(), animation?: TLAnimationOptions): this { if (!this.getInstanceState().canMoveCamera) return this const { x: cx, y: cy, z: cz } = this.getCamera() 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 this.setCamera( { x: cx + (x / zoom - x) - (x / cz - x), y: cy + (y / zoom - y) - (y / cz - y), z: zoom, }, animation ) return this } /** * Zoom the camera to fit the current selection in the viewport. * * @example * ```ts * editor.zoomToSelection() * ``` * * @param animation - The options for an animation. * * @public */ zoomToSelection(animation?: TLAnimationOptions): this { if (!this.getInstanceState().canMoveCamera) return this const selectionPageBounds = this.getSelectionPageBounds() if (!selectionPageBounds) return this this.zoomToBounds(selectionPageBounds, { targetZoom: Math.max(1, this.getZoomLevel()), ...animation, }) 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 animation - The options for an animation. * * @public */ panZoomIntoView(ids: TLShapeId[], animation?: TLAnimationOptions): this { if (!this.getInstanceState().canMoveCamera) return this if (ids.length <= 0) return this const selectionBounds = Box.Common(compact(ids.map((id) => this.getShapePageBounds(id)))) const viewportPageBounds = this.getViewportPageBounds() if (viewportPageBounds.h < selectionBounds.h || viewportPageBounds.w < selectionBounds.w) { this.zoomToBounds(selectionBounds, { targetZoom: this.getCamera().z, ...animation }) return this } else { const insetViewport = this.getViewportPageBounds() .clone() .expandBy(-32 / this.getZoomLevel()) let offsetX = 0 let offsetY = 0 if (insetViewport.maxY < selectionBounds.maxY) { // off bottom offsetY = insetViewport.maxY - selectionBounds.maxY } else if (insetViewport.minY > selectionBounds.minY) { // off top offsetY = insetViewport.minY - selectionBounds.minY } else { // inside y-bounds } if (insetViewport.maxX < selectionBounds.maxX) { // off right offsetX = insetViewport.maxX - selectionBounds.maxX } else if (insetViewport.minX > selectionBounds.minX) { // off left offsetX = insetViewport.minX - selectionBounds.minX } else { // inside x-bounds } const camera = this.getCamera() this.setCamera({ x: camera.x + offsetX, y: camera.y + offsetY, z: camera.z }, animation) } return this } /** * Zoom the camera to fit a bounding box (in the current page space). * * @example * ```ts * editor.zoomToBounds(myBounds) * editor.zoomToBounds(myBounds) * editor.zoomToBounds(myBounds, { duration: 100 }) * editor.zoomToBounds(myBounds, { inset: 0, targetZoom: 1 }) * ``` * * @param bounds - The bounding box. * @param options - The options for an animation, target zoom, or custom inset amount. * * @public */ zoomToBounds( bounds: Box, opts?: { targetZoom?: number; inset?: number } & TLAnimationOptions ): this { if (!this.getInstanceState().canMoveCamera) return this const viewportScreenBounds = this.getViewportScreenBounds() const inset = opts?.inset ?? Math.min(256, viewportScreenBounds.width * 0.28) let zoom = clamp( Math.min( (viewportScreenBounds.width - inset) / bounds.width, (viewportScreenBounds.height - inset) / bounds.height ), MIN_ZOOM, MAX_ZOOM ) if (opts?.targetZoom !== undefined) { zoom = Math.min(opts.targetZoom, zoom) } this.setCamera( { x: -bounds.minX + (viewportScreenBounds.width - bounds.width * zoom) / 2 / zoom, y: -bounds.minY + (viewportScreenBounds.height - bounds.height * zoom) / 2 / zoom, z: zoom, }, opts ) return this } /** * Pan the camera. * * @example * ```ts * editor.pan({ x: 100, y: 100 }) * editor.pan({ x: 100, y: 100 }, { duration: 1000 }) * ``` * * @param offset - The offset in the current page space. * @param animation - The animation options. */ pan(offset: VecLike, animation?: TLAnimationOptions): this { if (!this.getInstanceState().canMoveCamera) return this const { x: cx, y: cy, z: cz } = this.getCamera() this.setCamera({ x: cx + offset.x / cz, y: cy + offset.y / cz, z: cz }, animation) this._flushEventsForTick(0) return this } /** * Stop the current camera animation, if any. * * @public */ stopCameraAnimation(): this { this.emit('stop-camera-animation') return this } /** @internal */ private _viewportAnimation = null as null | { elapsed: number duration: number easing: (t: number) => number start: Box end: Box } /** @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) { this._setCamera({ x: -end.x, y: -end.y, z: this.getViewportScreenBounds().width / end.width }) 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 this._setCamera({ x: -left, y: -top, z: this.getViewportScreenBounds().width / (right - left) }) } /** @internal */ private _animateToViewport(targetViewportPage: Box, opts = {} as TLAnimationOptions) { const { duration = 0, easing = EASINGS.easeInOutCubic } = opts const animationSpeed = this.user.getAnimationSpeed() const viewportPageBounds = this.getViewportPageBounds() // If we have an existing animation, then stop it this.stopCameraAnimation() // also stop following any user if (this.getInstanceState().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({ x: -targetViewportPage.x, y: -targetViewportPage.y, z: this.getViewportScreenBounds().width / targetViewportPage.width, }) } // Set our viewport animation this._viewportAnimation = { elapsed: 0, duration: duration / animationSpeed, easing, start: viewportPageBounds.clone(), end: targetViewportPage.clone(), } // On each tick, animate the viewport this.addListener('tick', this._animateViewport) return this } /** * Slide the camera in a certain direction. * * @param opts - Options for the slide * @public */ slideCamera( opts = {} as { speed: number direction: VecLike friction: number speedThreshold?: number } ): this { if (!this.getInstanceState().canMoveCamera) return this this.stopCameraAnimation() const animationSpeed = this.user.getAnimationSpeed() if (animationSpeed === 0) return this 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.getCamera() const movementVec = Vec.Mul(direction, (currentSpeed * elapsed) / cz) // Apply friction currentSpeed *= 1 - friction if (currentSpeed < speedThreshold) { cancel() } else { this._setCamera({ x: cx + movementVec.x, y: cy + movementVec.y, z: cz }) } } this.addListener('tick', moveCamera) return this } /** * Animate the camera to a user's cursor position. * This also briefly show the user's cursor if it's not currently visible. * * @param userId - The id of the user to aniamte to. * @public */ animateToUser(userId: string): this { const presences = this.store.query.records('instance_presence', () => ({ userId: { eq: userId }, })) const presence = [...presences.get()] .sort((a, b) => { return a.lastActivityTimestamp - b.lastActivityTimestamp }) .pop() if (!presence) return this this.batch(() => { // If we're following someone, stop following them if (this.getInstanceState().followingUserId !== null) { this.stopFollowingUser() } // If we're not on the same page, move to the page they're on const isOnSamePage = presence.currentPageId === this.getCurrentPageId() if (!isOnSamePage) { this.setCurrentPage(presence.currentPageId) } // Only animate the camera if the user is on the same page as us const options = isOnSamePage ? { duration: 500 } : undefined this.centerOnPoint(presence.cursor, options) // Highlight the user's cursor const { highlightedUserIds } = this.getInstanceState() this.updateInstanceState({ highlightedUserIds: [...highlightedUserIds, userId] }) // Unhighlight the user's cursor after a few seconds setTimeout(() => { const highlightedUserIds = [...this.getInstanceState().highlightedUserIds] const index = highlightedUserIds.indexOf(userId) if (index < 0) return highlightedUserIds.splice(index, 1) this.updateInstanceState({ highlightedUserIds }) }, COLLABORATOR_IDLE_TIMEOUT) }) return this } /** * Animate the camera to a shape. * * @public */ animateToShape(shapeId: TLShapeId, opts: TLAnimationOptions = DEFAULT_ANIMATION_OPTIONS): this { if (!this.getInstanceState().canMoveCamera) return this const activeArea = this.getViewportScreenBounds().clone().expandBy(-32) const viewportAspectRatio = activeArea.width / activeArea.height const shapePageBounds = this.getShapePageBounds(shapeId) if (!shapePageBounds) return this const shapeAspectRatio = shapePageBounds.width / shapePageBounds.height const targetViewportPage = shapePageBounds.clone() const z = shapePageBounds.width / activeArea.width targetViewportPage.width += (activeArea.minX + activeArea.maxX) * z targetViewportPage.height += (activeArea.minY + activeArea.maxY) * z targetViewportPage.x -= activeArea.minX * z targetViewportPage.y -= activeArea.minY * 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) } // Viewport /** @internal */ private _willSetInitialBounds = true private _wasInset = false /** * 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 * editor.updateViewportScreenBounds() * editor.updateViewportScreenBounds(true) * ``` * * @param center - Whether to preserve the viewport page center as the viewport changes. * * @public */ updateViewportScreenBounds(screenBounds: Box, center = false): this { screenBounds.width = Math.max(screenBounds.width, 1) screenBounds.height = Math.max(screenBounds.height, 1) const insets = [ // top screenBounds.minY !== 0, // right document.body.scrollWidth !== screenBounds.maxX, // bottom document.body.scrollHeight !== screenBounds.maxY, // left screenBounds.minX !== 0, ] const boundsAreEqual = screenBounds.equals(this.getViewportScreenBounds()) 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(), insets }, { squashing: true, ephemeral: true } ) } else { if (center && !this.getInstanceState().followingUserId) { // Get the page center before the change, make the change, and restore it const before = this.getViewportPageCenter() this.updateInstanceState( { screenBounds: screenBounds.toJson(), insets }, { squashing: true, ephemeral: true } ) this.centerOnPoint(before) } else { // Otherwise, this.updateInstanceState( { screenBounds: screenBounds.toJson(), insets }, { squashing: true, ephemeral: true } ) } } } this._tickCameraState() this.updateRenderingBounds() return this } /** * The bounds of the editor's viewport in screen space. * * @public */ @computed getViewportScreenBounds() { const { x, y, w, h } = this.getInstanceState().screenBounds return new Box(x, y, w, h) } /** * The center of the editor's viewport in screen space. * * @public */ @computed getViewportScreenCenter() { const viewportScreenBounds = this.getViewportScreenBounds() return new Vec( viewportScreenBounds.midX - viewportScreenBounds.minX, viewportScreenBounds.midY - viewportScreenBounds.minY ) } /** * The current viewport in the current page space. * * @public */ @computed getViewportPageBounds() { const { w, h } = this.getViewportScreenBounds() const { x: cx, y: cy, z: cz } = this.getCamera() return new Box(-cx, -cy, w / cz, h / cz) } /** * The center of the viewport in the current page space. * * @public */ @computed getViewportPageCenter() { return this.getViewportPageBounds().center } /** * Convert a point in screen space to a point in the current page space. * * @example * ```ts * editor.screenToPage({ x: 100, y: 100 }) * ``` * * @param point - The point in screen space. * * @public */ screenToPage(point: VecLike) { const { screenBounds } = this.store.unsafeGetWithoutCapture(TLINSTANCE_ID)! const { x: cx, y: cy, z: cz = 1 } = this.getCamera() return { x: (point.x - screenBounds.x) / cz - cx, y: (point.y - screenBounds.y) / cz - cy, z: point.z ?? 0.5, } } /** * Convert a point in the current page space to a point in current screen space. * * @example * ```ts * editor.pageToScreen({ x: 100, y: 100 }) * ``` * * @param point - The point in page space. * * @public */ pageToScreen(point: VecLike) { const screenBounds = this.getViewportScreenBounds() const { x: cx, y: cy, z: cz = 1 } = this.getCamera() return { x: (point.x + cx) * cz + screenBounds.x, y: (point.y + cy) * cz + screenBounds.y, z: point.z ?? 0.5, } } /** * Convert a point in the current page space to a point in current viewport space. * * @example * ```ts * editor.pageToViewport({ x: 100, y: 100 }) * ``` * * @param point - The point in page space. * * @public */ pageToViewport(point: VecLike) { const { x: cx, y: cy, z: cz = 1 } = this.getCamera() return { x: (point.x + cx) * cz, y: (point.y + cy) * cz, z: point.z ?? 0.5, } } // Following /** * Start viewport-following a user. * * @param userId - The id of the user to follow. * * @public */ startFollowingUser(userId: string): this { const leaderPresences = this.store.query.records('instance_presence', () => ({ userId: { eq: userId }, })) const thisUserId = this.user.getId() 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.get().some((p) => p.followingUserId === thisUserId)) { return this } transact(() => { this.stopFollowingUser() this.updateInstanceState({ followingUserId: userId }, { ephemeral: true }) }) 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.get()] .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.getCurrentPageId() const chaseProportion = isOnSamePage ? FOLLOW_CHASE_PROPORTION : 1 if (!isOnSamePage) { this.stopFollowingUser() this.setCurrentPage(leaderPresence.currentPageId) this.startFollowingUser(userId) return } // Get the bounds of the follower (me) and the leader (them) const { center, width, height } = this.getViewportPageBounds() const leaderScreen = Box.From(leaderPresence.screenBounds) const leaderWidth = leaderScreen.width / leaderPresence.camera.z const leaderHeight = leaderScreen.height / leaderPresence.camera.z const leaderCenter = new Vec( 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.getCamera().z * ratio, MIN_ZOOM, MAX_ZOOM) const targetWidth = this.getViewportScreenBounds().w / targetZoom const targetHeight = this.getViewportScreenBounds().h / targetZoom // Figure out where to move the camera const displacement = leaderCenter.sub(center) const targetCenter = Vec.Add(center, Vec.Mul(displacement, chaseProportion)) // Now let's assess whether we've caught up to the leader or not const distance = Vec.Sub(targetCenter, center).len() const zoomChange = Math.abs(targetZoom - this.getCamera().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({ x: -(targetCenter.x - targetWidth / 2), y: -(targetCenter.y - targetHeight / 2), z: targetZoom, }) } this.once('stop-following', cancel) this.addListener('frame', moveTowardsUser) return this } /** * Stop viewport-following a user. * * @public */ stopFollowingUser(): this { this.updateInstanceState({ followingUserId: null }, { ephemeral: true }) this.emit('stop-following') return this } // Camera state private _cameraState = atom('camera state', 'idle' as 'idle' | 'moving') /** * Whether the camera is moving or idle. * * @public */ getCameraState() { return this._cameraState.get() } // Camera state does two things: first, it allows us to subscribe to whether // the camera is moving or not; and second, it allows us to update the rendering // shapes on the canvas. Changing the rendering shapes may cause shapes to // unmount / remount in the DOM, which is expensive; and computing visibility is // also expensive in large projects. For this reason, we use a second bounding // box just for rendering, and we only update after the camera stops moving. private _cameraStateTimeoutRemaining = 0 private _lastUpdateRenderingBoundsTimestamp = Date.now() private _decayCameraStateTimeout = (elapsed: number) => { this._cameraStateTimeoutRemaining -= elapsed if (this._cameraStateTimeoutRemaining <= 0) { this.off('tick', this._decayCameraStateTimeout) this._cameraState.set('idle') this.updateRenderingBounds() } } private _tickCameraState = () => { // always reset the timeout this._cameraStateTimeoutRemaining = CAMERA_MOVING_TIMEOUT const now = Date.now() // If the state is idle, then start the tick if (this._cameraState.__unsafe__getWithoutCapture() === 'idle') { this._lastUpdateRenderingBoundsTimestamp = now // don't render right away this._cameraState.set('moving') this.on('tick', this._decayCameraStateTimeout) } } /** @internal */ getUnorderedRenderingShapes( // The rendering state. We use this method both for rendering, which // is based on other state, and for computing order for SVG export, // which should work even when things are for example off-screen. useEditorState: boolean ) { // 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 shape: TLShape util: ShapeUtil index: number backgroundIndex: number opacity: number }[] = [] let nextIndex = MAX_SHAPES_PER_PAGE * 2 let nextBackgroundIndex = MAX_SHAPES_PER_PAGE const erasingShapeIds = this.getErasingShapeIds() const addShapeById = (id: TLShapeId, opacity: number, isAncestorErasing: boolean) => { const shape = this.getShape(id) if (!shape) return opacity *= shape.opacity let isShapeErasing = false const util = this.getShapeUtil(shape) if (useEditorState) { isShapeErasing = !isAncestorErasing && erasingShapeIds.includes(id) if (isShapeErasing) { opacity *= 0.32 } } renderingShapes.push({ id, shape, util, index: nextIndex, backgroundIndex: nextBackgroundIndex, opacity, }) nextIndex += 1 nextBackgroundIndex += 1 const childIds = this.getSortedChildIdsForParent(id) if (!childIds.length) return let backgroundIndexToRestore = null if (util.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 } } // If we're using editor state, then we're only interested in on-screen shapes. // If we're not using the editor state, then we're interested in ALL shapes, even those from other pages. const pages = useEditorState ? [this.getCurrentPage()] : this.getPages() for (const page of pages) { for (const childId of this.getSortedChildIdsForParent(page.id)) { addShapeById(childId, 1, false) } } return renderingShapes } /** * Get the shapes that should be displayed in the current viewport. * * @public */ @computed getRenderingShapes() { const renderingShapes = this.getUnorderedRenderingShapes(true) // 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 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 current rendering bounds in the current page space, used for checking which shapes are "on screen". * * @public */ getRenderingBounds() { return this._renderingBounds.get() } /** @internal */ private readonly _renderingBounds = atom('rendering viewport', new Box()) /** * Update the rendering bounds. This should be called when the viewport has stopped changing, such * as at the end of a pan, zoom, or animation. * * @example * ```ts * editor.updateRenderingBounds() * ``` * * * @internal */ updateRenderingBounds(): this { const viewportPageBounds = this.getViewportPageBounds() if (viewportPageBounds.equals(this._renderingBounds.__unsafe__getWithoutCapture())) return this this._renderingBounds.set(viewportPageBounds.clone()) return this } /** * The distance to expand the viewport when measuring culling. A larger distance will * mean that shapes near to the viewport (but still outside of it) will not be culled. * * @public */ renderingBoundsMargin = 100 /* --------------------- Pages ---------------------- */ @computed private _getAllPagesQuery() { return this.store.query.records('page') } /** * Info about the project's current pages. * * @public */ @computed getPages(): TLPage[] { return this._getAllPagesQuery().get().sort(sortByIndex) } /** * The current page. * * @public */ getCurrentPage(): TLPage { return this.getPage(this.getCurrentPageId())! } /** * The current page id. * * @public */ @computed getCurrentPageId(): TLPageId { return this.getInstanceState().currentPageId } /** * Get a page. * * @example * ```ts * editor.getPage(myPage.id) * editor.getPage(myPage) * ``` * * @param page - The page (or page id) to get. * * @public */ getPage(page: TLPageId | TLPage): TLPage | undefined { return this.store.get(typeof page === 'string' ? page : page.id) } /* @internal */ private readonly _currentPageShapeIds: ReturnType /** * An array of all of the shapes on the current page. * * @public */ getCurrentPageShapeIds() { return this._currentPageShapeIds.get() } /** * Get the ids of shapes on a page. * * @example * ```ts * const idsOnPage1 = editor.getPageShapeIds('page1') * const idsOnPage2 = editor.getPageShapeIds(myPage2) * ``` * * @param page - The page (or page id) to get. * * @public **/ getPageShapeIds(page: TLPageId | TLPage): Set { const pageId = typeof page === 'string' ? page : page.id const result = this.store.query.exec('shape', { parentId: { eq: pageId } }) return this.getShapeAndDescendantIds(result.map((s) => s.id)) } /** * Set the current page. * * @example * ```ts * editor.setCurrentPage('page1') * editor.setCurrentPage(myPage1) * ``` * * @param page - The page (or page id) to set as the current page. * @param historyOptions - The history options for the change. * * @public */ setCurrentPage(page: TLPageId | TLPage, historyOptions?: TLCommandHistoryOptions): this { const pageId = typeof page === 'string' ? page : page.id this._setCurrentPageId(pageId, historyOptions) return this } /** @internal */ private _setCurrentPageId = this.history.createCommand( 'setCurrentPage', (pageId: TLPageId, historyOptions?: TLCommandHistoryOptions) => { if (!this.store.has(pageId)) { console.error("Tried to set the current page id to a page that doesn't exist.") return } this.stopFollowingUser() return { data: { toId: pageId, fromId: this.getCurrentPageId() }, squashing: true, preservesRedoStack: true, ...historyOptions, } }, { do: ({ toId }) => { if (!this.store.has(toId)) { // in multiplayer contexts this page might have been deleted return } if (!this.getPageStates().find((p) => p.pageId === toId)) { const camera = CameraRecordType.create({ id: CameraRecordType.createId(toId), }) this.store.put([ camera, InstancePageStateRecordType.create({ id: InstancePageStateRecordType.createId(toId), pageId: toId, }), ]) } this.store.put([{ ...this.getInstanceState(), currentPageId: toId }]) this.updateRenderingBounds() }, undo: ({ fromId }) => { if (!this.store.has(fromId)) { // in multiplayer contexts this page might have been deleted return } this.store.put([{ ...this.getInstanceState(), currentPageId: fromId }]) this.updateRenderingBounds() }, squash: ({ fromId }, { toId }) => { return { toId, fromId } }, } ) /** * Update a page. * * @example * ```ts * editor.updatePage({ id: 'page2', name: 'Page 2' }) * editor.updatePage({ id: 'page2', name: 'Page 2' }, { squashing: true }) * ``` * * @param partial - The partial of the shape to update. * @param historyOptions - The history options for the change. * * @public */ updatePage(partial: RequiredKeys, historyOptions?: TLCommandHistoryOptions): this { this._updatePage(partial, historyOptions) return this } /** @internal */ private _updatePage = this.history.createCommand( 'updatePage', (partial: RequiredKeys, historyOptions?: TLCommandHistoryOptions) => { if (this.getInstanceState().isReadonly) return null const prev = this.getPage(partial.id) if (!prev) return null return { data: { prev, partial }, ...historyOptions } }, { 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 * editor.createPage(myPage) * editor.createPage({ name: 'Page 2' }) * ``` * * @param page - The page (or page partial) to create. * * @public */ createPage(page: Partial): this { this._createPage(page) return this } /** @internal */ private _createPage = this.history.createCommand( 'createPage', (page: Partial) => { if (this.getInstanceState().isReadonly) return null if (this.getPages().length >= MAX_PAGES) return null const pages = this.getPages() const name = getIncrementedName( page.name ?? 'Page 1', pages.map((p) => p.name) ) let index = page.index if (!index || pages.some((p) => p.index === index)) { index = getIndexAbove(pages[pages.length - 1].index) } const newPage = PageRecordType.create({ meta: {}, ...page, name, index, }) const newCamera = CameraRecordType.create({ id: CameraRecordType.createId(newPage.id), }) const newTabPageState = InstancePageStateRecordType.create({ id: InstancePageStateRecordType.createId(newPage.id), pageId: newPage.id, }) return { data: { newPage, newTabPageState, newCamera, }, } }, { do: ({ newPage, newTabPageState, newCamera }) => { this.store.put([newPage, newCamera, newTabPageState]) }, undo: ({ newPage, newTabPageState, newCamera }) => { if (this.getPages().length === 1) return this.store.remove([newTabPageState.id, newPage.id, newCamera.id]) }, } ) /** * Delete a page. * * @example * ```ts * editor.deletePage('page1') * ``` * * @param id - The id of the page to delete. * * @public */ deletePage(page: TLPageId | TLPage): this { const id = typeof page === 'string' ? page : page.id this._deletePage(id) return this } /** @internal */ private _deletePage = this.history.createCommand( 'delete_page', (id: TLPageId) => { if (this.getInstanceState().isReadonly) return null const pages = this.getPages() if (pages.length === 1) return null const deletedPage = this.getPage(id) const deletedPageStates = this.getPageStates().filter((s) => s.pageId === id) if (!deletedPage) return null if (id === this.getCurrentPageId()) { const index = pages.findIndex((page) => page.id === id) const next = pages[index - 1] ?? pages[index + 1] this.setCurrentPage(next.id) } return { data: { id, deletedPage, deletedPageStates } } }, { do: ({ deletedPage, deletedPageStates }) => { const pages = this.getPages() if (pages.length === 1) return if (deletedPage.id === this.getCurrentPageId()) { const index = pages.findIndex((page) => page.id === deletedPage.id) const next = pages[index - 1] ?? pages[index + 1] this.setCurrentPage(next.id) } this.store.remove(deletedPageStates.map((s) => s.id)) // remove the page state this.store.remove([deletedPage.id]) // remove the page this.updateRenderingBounds() }, undo: ({ deletedPage, deletedPageStates }) => { this.store.put([deletedPage]) this.store.put(deletedPageStates) this.updateRenderingBounds() }, } ) /** * Duplicate a page. * * @param id - The id of the page to duplicate. Defaults to the current page. * @param createId - The id of the new page. Defaults to a new id. * * @public */ duplicatePage(page: TLPageId | TLPage, createId: TLPageId = PageRecordType.createId()): this { if (this.getPages().length >= MAX_PAGES) return this const id = typeof page === 'string' ? page : page.id const freshPage = this.getPage(id) // get the most recent version of the page anyway if (!freshPage) return this const prevCamera = { ...this.getCamera() } const content = this.getContentFromCurrentPage(this.getSortedChildIdsForParent(freshPage.id)) this.batch(() => { const pages = this.getPages() const index = getIndexBetween(freshPage.index, pages[pages.indexOf(freshPage) + 1]?.index) // create the page (also creates the pagestate and camera for the new page) this.createPage({ name: freshPage.name + ' Copy', id: createId, index }) // set the new page as the current page this.setCurrentPage(createId) // update the new page's camera to the previous page's camera this.setCamera(prevCamera) if (content) { // If we had content on the previous page, put it on the new page return this.putContentOntoCurrentPage(content) } }) return this } /** * Rename a page. * * @example * ```ts * editor.renamePage('page1', 'My Page') * ``` * * @param id - The id of the page to rename. * @param name - The new name. * * @public */ renamePage(page: TLPageId | TLPage, name: string, historyOptions?: TLCommandHistoryOptions) { const id = typeof page === 'string' ? page : page.id if (this.getInstanceState().isReadonly) return this this.updatePage({ id, name }, historyOptions) return this } /* --------------------- Assets --------------------- */ /** @internal */ @computed private _getAllAssetsQuery() { return this.store.query.records('asset') } /** * Get all assets in the editor. * * @public */ getAssets() { return this._getAllAssetsQuery().get() } /** * Create one or more assets. * * @example * ```ts * editor.createAssets([...myAssets]) * ``` * * @param assets - The assets to create. * * @public */ createAssets(assets: TLAsset[]): this { this._createAssets(assets) return this } /** @internal */ private _createAssets = this.history.createCommand( 'createAssets', (assets: TLAsset[]) => { if (this.getInstanceState().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)) }, } ) /** * Update one or more assets. * * @example * ```ts * editor.updateAssets([{ id: 'asset1', name: 'New name' }]) * ``` * * @param assets - The assets to update. * * @public */ updateAssets(assets: TLAssetPartial[]): this { this._updateAssets(assets) return this } /** @internal */ private _updateAssets = this.history.createCommand( 'updateAssets', (assets: TLAssetPartial[]) => { if (this.getInstanceState().isReadonly) return if (assets.length <= 0) return const snapshots: Record = {} 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)) }, } ) /** * Delete one or more assets. * * @example * ```ts * editor.deleteAssets(['asset1', 'asset2']) * ``` * * @param ids - The assets to delete. * * @public */ deleteAssets(assets: TLAssetId[] | TLAsset[]): this { const ids = typeof assets[0] === 'string' ? (assets as TLAssetId[]) : (assets as TLAsset[]).map((a) => a.id) this._deleteAssets(ids) return this } /** @internal */ private _deleteAssets = this.history.createCommand( 'deleteAssets', (ids: TLAssetId[]) => { if (this.getInstanceState().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) }, } ) /** * Get an asset by its id. * * @example * ```ts * editor.getAsset('asset1') * ``` * * @param asset - The asset (or asset id) to get. * * @public */ getAsset(asset: TLAssetId | TLAsset): TLAsset | undefined { return this.store.get(typeof asset === 'string' ? asset : asset.id) as TLAsset | undefined } /* --------------------- Shapes --------------------- */ @computed private _getShapeGeometryCache(): ComputedCache { return this.store.createComputedCache( 'bounds', (shape) => this.getShapeUtil(shape).getGeometry(shape), (a, b) => a.props === b.props ) } /** * Get the geometry of a shape. * * @example * ```ts * editor.getShapeGeometry(myShape) * editor.getShapeGeometry(myShapeId) * ``` * * @param shape - The shape (or shape id) to get the geometry for. * * @public */ getShapeGeometry(shape: TLShape | TLShapeId): T { return this._getShapeGeometryCache().get(typeof shape === 'string' ? shape : shape.id)! as T } /** @internal */ @computed private _getShapeHandlesCache(): ComputedCache { return this.store.createComputedCache('handles', (shape) => { return this.getShapeUtil(shape).getHandles?.(shape) }) } /** * Get the handles (if any) for a shape. * * @example * ```ts * editor.getShapeHandles(myShape) * editor.getShapeHandles(myShapeId) * ``` * * @param shape - The shape (or shape id) to get the handles for. * @public */ getShapeHandles(shape: T | T['id']): TLHandle[] | undefined { return this._getShapeHandlesCache().get(typeof shape === 'string' ? shape : shape.id) } /** * 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 * editor.getShapeLocalTransform(myShape) * ``` * * @param shape - The shape to get the local transform for. * * @public */ getShapeLocalTransform(shape: TLShape | TLShapeId): Mat { const id = typeof shape === 'string' ? shape : shape.id const freshShape = this.getShape(id) if (!freshShape) throw Error('Editor.getTransform: shape not found') return Mat.Identity().translate(freshShape.x, freshShape.y).rotate(freshShape.rotation) } /** * A cache of page transforms. * * @internal */ @computed private _getShapePageTransformCache(): ComputedCache { return this.store.createComputedCache('pageTransformCache', (shape) => { if (isPageId(shape.parentId)) { return this.getShapeLocalTransform(shape) } // If the shape's parent doesn't exist yet (e.g. when merging in changes from remote in the wrong order) // then we can't compute the transform yet, so just return the identity matrix. // In the future we should look at creating a store update mechanism that understands and preserves // ordering. const parentTransform = this._getShapePageTransformCache().get(shape.parentId) ?? Mat.Identity() return Mat.Compose(parentTransform, this.getShapeLocalTransform(shape)!) }) } /** * Get the local transform of a shape's parent as a matrix model. * * @example * ```ts * editor.getShapeParentTransform(myShape) * ``` * * @param shape - The shape (or shape id) to get the parent transform for. * * @public */ getShapeParentTransform(shape: TLShape | TLShapeId): Mat { const id = typeof shape === 'string' ? shape : shape.id const freshShape = this.getShape(id) if (!freshShape || isPageId(freshShape.parentId)) return Mat.Identity() return this._getShapePageTransformCache().get(freshShape.parentId) ?? Mat.Identity() } /** * Get the transform of a shape in the current page space. * * @example * ```ts * editor.getShapePageTransform(myShape) * editor.getShapePageTransform(myShapeId) * ``` * * @param shape - The shape (or shape id) to get the page transform for. * * @public */ getShapePageTransform(shape: TLShape | TLShapeId): Mat { const id = typeof shape === 'string' ? shape : this.getShape(shape)!.id return this._getShapePageTransformCache().get(id) ?? Mat.Identity() } /** @internal */ @computed private _getShapePageBoundsCache(): ComputedCache { return this.store.createComputedCache('pageBoundsCache', (shape) => { const pageTransform = this._getShapePageTransformCache().get(shape.id) if (!pageTransform) return new Box() const result = Box.FromPoints( Mat.applyToPoints(pageTransform, this.getShapeGeometry(shape).vertices) ) return result }) } /** * Get the bounds of a shape in the current page space. * * @example * ```ts * editor.getShapePageBounds(myShape) * editor.getShapePageBounds(myShapeId) * ``` * * @param shape - The shape (or shape id) to get the bounds for. * * @public */ getShapePageBounds(shape: TLShape | TLShapeId): Box | undefined { return this._getShapePageBoundsCache().get(typeof shape === 'string' ? shape : shape.id) } /** * A cache of clip paths used for clipping. * * @internal */ @computed private _getShapeClipPathCache(): ComputedCache { return this.store.createComputedCache('clipPathCache', (shape) => { const pageMask = this._getShapeMaskCache().get(shape.id) if (!pageMask) return undefined if (pageMask.length === 0) { return `polygon(0px 0px, 0px 0px, 0px 0px)` } const pageTransform = this._getShapePageTransformCache().get(shape.id) if (!pageTransform) return undefined const localMask = Mat.applyToPoints(Mat.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 = editor.getShapeClipPath(shape) * const clipPath = editor.getShapeClipPath(shape.id) * ``` * * @param shape - The shape (or shape id) to get the clip path for. * * @returns The clip path or undefined. * * @public */ getShapeClipPath(shape: TLShape | TLShapeId): string | undefined { return this._getShapeClipPathCache().get(typeof shape === 'string' ? shape : shape.id) } /** @internal */ @computed private _getShapeMaskCache(): ComputedCache { return this.store.createComputedCache('pageMaskCache', (shape) => { // todo: Consider adding a flag for this hardcoded behaviour if ( isPageId(shape.parentId) || shape.type === 'note' || this.findShapeAncestor(shape, (v) => v.type === 'note') ) return undefined const frameAncestors = this.getShapeAncestors(shape.id).filter((shape) => this.isShapeOfType(shape, 'frame') ) if (frameAncestors.length === 0) return undefined const pageMask = frameAncestors .map((s) => // Apply the frame transform to the frame outline to get the frame outline in the current page space this._getShapePageTransformCache() .get(s.id)! .applyToPoints(this.getShapeGeometry(s).vertices) ) .reduce((acc, b) => { if (!(b && acc)) return undefined const intersection = intersectPolygonPolygon(acc, b) if (intersection) { return intersection.map(Vec.Cast) } return [] }) return pageMask }) } /** * Get the mask (in the current page space) for a shape. * * @example * ```ts * const pageMask = editor.getShapeMask(shape.id) * ``` * * @param id - The id of the shape to get the mask for. * * @returns The mask for the shape. * * @public */ getShapeMask(shape: TLShapeId | TLShape): VecLike[] | undefined { return this._getShapeMaskCache().get(typeof shape === 'string' ? shape : shape.id) } /** * Get the bounds of a shape in the current page space, 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 * editor.getShapeMaskedPageBounds(myShape) * editor.getShapeMaskedPageBounds(myShapeId) * ``` * * @param shape - The shape to get the masked bounds for. * * @public */ getShapeMaskedPageBounds(shape: TLShapeId | TLShape): Box | undefined { if (typeof shape !== 'string') shape = shape.id return this._getShapeMaskedPageBoundsCache().get(shape) } /** @internal */ @computed private _getShapeMaskedPageBoundsCache(): ComputedCache { return this.store.createComputedCache('shapeMaskedPageBoundsCache', (shape) => { const pageBounds = this._getShapePageBoundsCache().get(shape.id) if (!pageBounds) return const pageMask = this._getShapeMaskCache().get(shape.id) if (pageMask) { if (pageMask.length === 0) return undefined const { corners } = pageBounds if (corners.every((p, i) => p && Vec.Equals(p, pageMask[i]))) return pageBounds.clone() const intersection = intersectPolygonPolygon(pageMask, corners) if (!intersection) return return Box.FromPoints(intersection) } return pageBounds }) } /** * Get the ancestors of a shape. * * @example * ```ts * const ancestors = editor.getShapeAncestors(myShape) * const ancestors = editor.getShapeAncestors(myShapeId) * ``` * * @param shape - The shape (or shape id) to get the ancestors for. * * @public */ getShapeAncestors(shape: TLShapeId | TLShape, acc: TLShape[] = []): TLShape[] { const id = typeof shape === 'string' ? shape : shape.id const freshShape = this.getShape(id) if (!freshShape) return acc const parentId = freshShape.parentId if (isPageId(parentId)) { acc.reverse() return acc } const parent = this.store.get(parentId) if (!parent) return acc acc.push(parent) return this.getShapeAncestors(parent, acc) } /** * Find the first ancestor matching the given predicate * * @example * ```ts * const ancestor = editor.findShapeAncestor(myShape) * const ancestor = editor.findShapeAncestor(myShape.id) * const ancestor = editor.findShapeAncestor(myShape.id, (shape) => shape.type === 'frame') * ``` * * @param shape - The shape to check the ancestors for. * * @public */ findShapeAncestor( shape: TLShape | TLShapeId, predicate: (parent: TLShape) => boolean ): TLShape | undefined { const id = typeof shape === 'string' ? shape : shape.id const freshShape = this.getShape(id) if (!freshShape) return const parentId = freshShape.parentId if (isPageId(parentId)) return const parent = this.getShape(parentId) if (!parent) return return predicate(parent) ? parent : this.findShapeAncestor(parent, predicate) } /** * Returns true if the the given shape has the given ancestor. * * @param shape - The shape. * @param ancestorId - The id of the ancestor. * * @public */ hasAncestor(shape: TLShape | TLShapeId | undefined, ancestorId: TLShapeId): boolean { const id = typeof shape === 'string' ? shape : shape?.id const freshShape = id && this.getShape(id) if (!freshShape) return false if (freshShape.parentId === ancestorId) return true return this.hasAncestor(this.getShapeParent(freshShape), ancestorId) } /** * Get the common ancestor of two or more shapes that matches a predicate. * * @param shapes - The shapes (or shape ids) to check. * @param predicate - The predicate to match. */ findCommonAncestor( shapes: TLShape[] | TLShapeId[], predicate?: (shape: TLShape) => boolean ): TLShapeId | undefined { if (shapes.length === 0) { return } const ids = typeof shapes[0] === 'string' ? (shapes as TLShapeId[]) : (shapes as TLShape[]).map((s) => s.id) const freshShapes = compact(ids.map((id) => this.getShape(id))) if (freshShapes.length === 1) { const parentId = freshShapes[0].parentId if (isPageId(parentId)) { return } return predicate ? this.findShapeAncestor(freshShapes[0], predicate)?.id : parentId } const [nodeA, ...others] = freshShapes let ancestor = this.getShapeParent(nodeA) while (ancestor) { // TODO: this is not ideal, optimize if (predicate && !predicate(ancestor)) { ancestor = this.getShapeParent(ancestor) continue } if (others.every((shape) => this.hasAncestor(shape, ancestor!.id))) { return ancestor!.id } ancestor = this.getShapeParent(ancestor) } return undefined } /** * Check whether a shape or its parent is locked. * * @param shape - The shape (or shape id) to check. * * @public */ isShapeOrAncestorLocked(shape?: TLShape): boolean isShapeOrAncestorLocked(id?: TLShapeId): boolean isShapeOrAncestorLocked(arg?: TLShape | TLShapeId): boolean { const shape = typeof arg === 'string' ? this.getShape(arg) : arg if (shape === undefined) return false if (shape.isLocked) return true return this.isShapeOrAncestorLocked(this.getShapeParent(shape)) } @computed private _notVisibleShapes() { return notVisibleShapes(this) } /** * Get culled shapes. * * @public */ @computed getCulledShapes() { const notVisibleShapes = this._notVisibleShapes().get() const selectedShapeIds = this.getSelectedShapeIds() const editingId = this.getEditingShapeId() const culledShapes = new Set(notVisibleShapes) // we don't cull the shape we are editing if (editingId) { culledShapes.delete(editingId) } // we also don't cull selected shapes selectedShapeIds.forEach((id) => { culledShapes.delete(id) }) return culledShapes } /** * The bounds of the current page (the common bounds of all of the shapes on the page). * * @public */ @computed getCurrentPageBounds(): Box | undefined { let commonBounds: Box | undefined this.getCurrentPageShapeIds().forEach((shapeId) => { const bounds = this.getShapeMaskedPageBounds(shapeId) if (!bounds) return if (!commonBounds) { commonBounds = bounds.clone() } else { commonBounds = commonBounds.expand(bounds) } }) return commonBounds } /** * Get the top-most selected shape at the given point, ignoring groups. * * @param point - The point to check. * * @returns The top-most selected shape at the given point, or undefined if there is no shape at the point. */ getSelectedShapeAtPoint(point: VecLike): TLShape | undefined { const selectedShapeIds = this.getSelectedShapeIds() return this.getCurrentPageShapesSorted() .filter((shape) => shape.type !== 'group' && selectedShapeIds.includes(shape.id)) .reverse() // findlast .find((shape) => this.isPointInShape(shape, point, { hitInside: true, margin: 0 })) } /** * Get the shape at the current point. * * @param point - The point to check. * @param opts - Options for the check: `hitInside` to check if the point is inside the shape, `margin` to check if the point is within a margin of the shape, `hitFrameInside` to check if the point is inside the frame, and `filter` to filter the shapes to check. * * @returns The shape at the given point, or undefined if there is no shape at the point. */ getShapeAtPoint( point: VecLike, opts = {} as { renderingOnly?: boolean margin?: number hitInside?: boolean hitLabels?: boolean hitFrameInside?: boolean filter?: (shape: TLShape) => boolean } ): TLShape | undefined { const zoomLevel = this.getZoomLevel() const viewportPageBounds = this.getViewportPageBounds() const { filter, margin = 0, hitLabels = false, hitInside = false, hitFrameInside = false, } = opts let inHollowSmallestArea = Infinity let inHollowSmallestAreaHit: TLShape | null = null let inMarginClosestToEdgeDistance = Infinity let inMarginClosestToEdgeHit: TLShape | null = null const shapesToCheck = ( opts.renderingOnly ? this.getCurrentPageRenderingShapesSorted() : this.getCurrentPageShapesSorted() ).filter((shape) => { if (this.isShapeOfType(shape, 'group')) return false const pageMask = this.getShapeMask(shape) if (pageMask && !pointInPolygon(point, pageMask)) return false if (filter) return filter(shape) return true }) for (let i = shapesToCheck.length - 1; i >= 0; i--) { const shape = shapesToCheck[i] const geometry = this.getShapeGeometry(shape) const isGroup = geometry instanceof Group2d const pointInShapeSpace = this.getPointInShapeSpace(shape, point) // Check labels first if ( this.isShapeOfType(shape, 'arrow') || (this.isShapeOfType(shape, 'geo') && shape.props.fill === 'none') ) { if (shape.props.text.trim()) { // let's check whether the shape has a label and check that for (const childGeometry of (geometry as Group2d).children) { if (childGeometry.isLabel && childGeometry.isPointInBounds(pointInShapeSpace)) { return shape } } } } if (this.isShapeOfType(shape, 'frame')) { // On the rare case that we've hit a frame, test again hitInside to be forced true; // this prevents clicks from passing through the body of a frame to shapes behhind it. // If the hit is within the frame's outer margin, then select the frame const distance = geometry.distanceToPoint(pointInShapeSpace, hitInside) if (Math.abs(distance) <= margin) { return inMarginClosestToEdgeHit || shape } if (geometry.hitTestPoint(pointInShapeSpace, 0, true)) { // Once we've hit a frame, we want to end the search. If we have hit a shape // already, then this would either be above the frame or a child of the frame, // so we want to return that. Otherwise, the point is in the empty space of the // frame. If `hitFrameInside` is true (e.g. used drawing an arrow into the // frame) we the frame itself; other wise, (e.g. when hovering or pointing) // we would want to return null. return ( inMarginClosestToEdgeHit || inHollowSmallestAreaHit || (hitFrameInside ? shape : undefined) ) } continue } let distance: number if (isGroup) { let minDistance = Infinity for (const childGeometry of geometry.children) { if (childGeometry.isLabel && !hitLabels) continue // hit test the all of the child geometries that aren't labels const tDistance = childGeometry.distanceToPoint(pointInShapeSpace, hitInside) if (tDistance < minDistance) { minDistance = tDistance } } distance = minDistance } else { // If the margin is zero and the geometry has a very small width or height, // then check the actual distance. This is to prevent a bug where straight // lines would never pass the broad phase (point-in-bounds) check. if (margin === 0 && (geometry.bounds.w < 1 || geometry.bounds.h < 1)) { distance = geometry.distanceToPoint(pointInShapeSpace, hitInside) } else { // Broad phase if (geometry.bounds.containsPoint(pointInShapeSpace, margin)) { // Narrow phase (actual distance) distance = geometry.distanceToPoint(pointInShapeSpace, hitInside) } else { // Failed the broad phase, geddafugaotta'ere! distance = Infinity } } } if (geometry.isClosed) { // For closed shapes, the distance will be positive if outside of // the shape or negative if inside of the shape. If the distance // is greater than the margin, then it's a miss. Otherwise... if (distance <= margin) { if (geometry.isFilled || (isGroup && geometry.children[0].isFilled)) { // If the shape is filled, then it's a hit. Remember, we're // starting from the TOP-MOST shape in z-index order, so any // other hits would be occluded by the shape. return inMarginClosestToEdgeHit || shape } else { // If the shape is bigger than the viewport, then skip it. if (this.getShapePageBounds(shape)!.contains(viewportPageBounds)) continue // For hollow shapes... if (Math.abs(distance) < margin) { // We want to preference shapes where we're inside of the // shape margin; and we would want to hit the shape with the // edge closest to the point. if (Math.abs(distance) < inMarginClosestToEdgeDistance) { inMarginClosestToEdgeDistance = Math.abs(distance) inMarginClosestToEdgeHit = shape } } else if (!inMarginClosestToEdgeHit) { // If we're not within margin distnce to any edge, and if the // shape is hollow, then we want to hit the shape with the // smallest area. (There's a bug here with self-intersecting // shapes, like a closed drawing of an "8", but that's a bigger // problem to solve.) const { area } = geometry if (area < inHollowSmallestArea) { inHollowSmallestArea = area inHollowSmallestAreaHit = shape } } } } } else { // For open shapes (e.g. lines or draw shapes) always use the margin. // If the distance is less than the margin, return the shape as the hit. if (distance < HIT_TEST_MARGIN / zoomLevel) { return shape } } } // If we haven't hit any filled shapes or frames, then return either // the shape who we hit within the margin (and of those, the one that // had the shortest distance between the point and the shape edge), // or else the hollow shape with the smallest area—or if we didn't hit // any margins or any hollow shapes, then null. return inMarginClosestToEdgeHit || inHollowSmallestAreaHit || undefined } /** * Get the shapes, if any, at a given page point. * * @example * ```ts * editor.getShapesAtPoint({ x: 100, y: 100 }) * editor.getShapesAtPoint({ x: 100, y: 100 }, { hitInside: true, exact: true }) * ``` * * @param point - The page point to test. * * @public */ getShapesAtPoint( point: VecLike, opts = {} as { margin?: number; hitInside?: boolean } ): TLShape[] { return this.getCurrentPageShapes().filter((shape) => this.isPointInShape(shape, point, opts)) } /** * Test whether a point (in the current 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 * editor.isPointInShape({ x: 100, y: 100 }, myShape) * ``` * * @param shape - The shape to test against. * @param point - The page point to test (in the current page space). * @param hitInside - Whether to count as a hit if the point is inside of a closed shape. * * @public */ isPointInShape( shape: TLShape | TLShapeId, point: VecLike, opts = {} as { margin?: number hitInside?: boolean } ): boolean { const { hitInside = false, margin = 0 } = opts const id = typeof shape === 'string' ? shape : shape.id // If the shape is masked, and if the point falls outside of that // mask, then it's defintely a miss—we don't need to test further. const pageMask = this.getShapeMask(id) if (pageMask && !pointInPolygon(point, pageMask)) return false return this.getShapeGeometry(id).hitTestPoint( this.getPointInShapeSpace(shape, point), margin, hitInside ) } /** * Convert a point in the current 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 * editor.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 | TLShapeId, point: VecLike): Vec { const id = typeof shape === 'string' ? shape : shape.id return this._getShapePageTransformCache().get(id)!.clone().invert().applyToPoint(point) } /** * Convert a delta in the current page space to a point in the local space of a shape's parent. * * @example * ```ts * editor.getPointInParentSpace(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(shape: TLShapeId | TLShape, point: VecLike): Vec { const id = typeof shape === 'string' ? shape : shape.id const freshShape = this.getShape(id) if (!freshShape) return new Vec(0, 0) if (isPageId(freshShape.parentId)) return Vec.From(point) const parentTransform = this.getShapePageTransform(freshShape.parentId) if (!parentTransform) return Vec.From(point) return parentTransform.clone().invert().applyToPoint(point) } /** * An array containing all of the shapes in the current page. * * @public */ @computed getCurrentPageShapes(): TLShape[] { return Array.from(this.getCurrentPageShapeIds(), (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. * * @public */ @computed getCurrentPageShapesSorted(): TLShape[] { const shapes = this.getCurrentPageShapes().sort(sortByIndex) const parentChildMap = new Map() const result: TLShape[] = [] const topLevelShapes: TLShape[] = [] let shape: TLShape, parent: TLShape | undefined for (let i = 0, n = shapes.length; i < n; i++) { shape = shapes[i] parent = this.getShape(shape.parentId) if (parent) { if (!parentChildMap.has(parent.id)) { parentChildMap.set(parent.id, []) } parentChildMap.get(parent.id)!.push(shape) } else { // undefined if parent is a shape topLevelShapes.push(shape) } } for (let i = 0, n = topLevelShapes.length; i < n; i++) { pushShapeWithDescendants(topLevelShapes[i], parentChildMap, result) } return result } /** * An array containing all of the rendering shapes in the current page, sorted in z-index order (accounting * for nested shapes): e.g. A, B, BA, BB, C. * * @public */ @computed getCurrentPageRenderingShapesSorted(): TLShape[] { const culledShapes = this.getCulledShapes() return this.getCurrentPageShapesSorted().filter(({ id }) => !culledShapes.has(id)) } /** * Get whether a shape matches the type of a TLShapeUtil. * * @example * ```ts * const isArrowShape = isShapeOfType(someShape, 'arrow') * ``` * * @param util - the TLShapeUtil constructor to test against * @param shape - the shape to test * * @public */ isShapeOfType(shape: TLUnknownShape, type: T['type']): shape is T isShapeOfType( shapeId: TLUnknownShape['id'], type: T['type'] ): shapeId is T['id'] isShapeOfType( arg: TLUnknownShape | TLUnknownShape['id'], type: T['type'] ) { const shape = typeof arg === 'string' ? this.getShape(arg) : arg if (!shape) return false return shape.type === type } /** * Get a shape by its id. * * @example * ```ts * editor.getShape('box1') * ``` * * @param id - The id of the shape to get. * * @public */ getShape(shape: TLShape | TLParentId): T | undefined { const id = typeof shape === 'string' ? shape : shape.id 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 * editor.getShapeParent(myShape) * ``` * * @public */ getShapeParent(shape?: TLShape | TLShapeId): TLShape | undefined { const id = typeof shape === 'string' ? shape : shape?.id if (!id) return undefined const freshShape = this.getShape(id) if (freshShape === undefined || !isShapeId(freshShape.parentId)) return undefined return this.store.get(freshShape.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. * * @internal */ private getShapeNearestSibling( siblingShape: TLShape, targetShape: TLShape | undefined ): TLShape | undefined { if (!targetShape) { return undefined } if (targetShape.parentId === siblingShape.parentId) { return targetShape } const ancestor = this.findShapeAncestor( targetShape, (ancestor) => ancestor.parentId === siblingShape.parentId ) return ancestor } /** * Get whether the given shape is the descendant of the given page. * * @example * ```ts * editor.isShapeInPage(myShape) * editor.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 | TLShapeId, pageId = this.getCurrentPageId()): boolean { const id = typeof shape === 'string' ? shape : shape.id const shapeToCheck = this.getShape(id) if (!shapeToCheck) return false let shapeIsInPage = false if (shapeToCheck.parentId === pageId) { shapeIsInPage = true } else { let parent = this.getShape(shapeToCheck.parentId) isInPageSearch: while (parent) { if (parent.parentId === pageId) { shapeIsInPage = true break isInPageSearch } parent = this.getShape(parent.parentId) } } return shapeIsInPage } /** * Get the id of the containing page for a given shape. * * @param shape - The shape to get the page id for. * * @returns The id of the page that contains the shape, or undefined if the shape is undefined. * * @public */ getAncestorPageId(shape?: TLShape | TLShapeId): TLPageId | undefined { const id = typeof shape === 'string' ? shape : shape?.id const _shape = id && this.getShape(id) if (!_shape) return undefined if (isPageId(_shape.parentId)) { return _shape.parentId } else { return this.getAncestorPageId(this.getShape(_shape.parentId)) } } // Parents and children /** * A cache of parents to children. * * @internal */ private readonly _parentIdsToChildIds: ReturnType /** * Reparent shapes to a new parent. This operation preserves the shape's current page positions / * rotations. * * @example * ```ts * editor.reparentShapes([box1, box2], 'frame1') * editor.reparentShapes([box1.id, box2.id], 'frame1') * editor.reparentShapes([box1.id, box2.id], 'frame1', 4) * ``` * * @param shapes - The shapes (or shape ids) of the shapes to reparent. * @param parentId - The id of the new parent shape. * @param insertIndex - The index to insert the children. * * @public */ reparentShapes(shapes: TLShapeId[] | TLShape[], parentId: TLParentId, insertIndex?: IndexKey) { const ids = typeof shapes[0] === 'string' ? (shapes as TLShapeId[]) : shapes.map((s) => (s as TLShape).id) if (ids.length === 0) return this const changes: TLShapePartial[] = [] const parentTransform = isPageId(parentId) ? Mat.Identity() : this.getShapePageTransform(parentId)! const parentPageRotation = parentTransform.rotation() let indices: IndexKey[] = [] const sibs = compact(this.getSortedChildIdsForParent(parentId).map((id) => this.getShape(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) } const invertedParentTransform = parentTransform.clone().invert() const shapesToReparent = compact(ids.map((id) => this.getShape(id))) // The user is allowed to re-parent locked shapes. Unintutive? Yeah! But there are plenty of // times when a locked shape's parent is deleted... and we need to put that shape somewhere! const lockedShapes = shapesToReparent.filter((shape) => shape.isLocked) if (lockedShapes.length) { // If we have locked shapes, unlock them before we update them this.updateShapes(lockedShapes.map(({ id, type }) => ({ id, type, isLocked: false }))) } for (let i = 0; i < shapesToReparent.length; i++) { const shape = shapesToReparent[i] const pageTransform = this.getShapePageTransform(shape)! if (!pageTransform) continue const pagePoint = pageTransform.point() if (!pagePoint) continue const newPoint = invertedParentTransform.applyToPoint(pagePoint) const newRotation = pageTransform.rotation() - parentPageRotation changes.push({ id: shape.id, type: shape.type, parentId: parentId, x: newPoint.x, y: newPoint.y, rotation: newRotation, index: indices[i], isLocked: shape.isLocked, // this will re-lock locked shapes }) } this.updateShapes(changes) return this } /** * Get the index above the highest child of a given parent. * * @param parentId - The id of the parent. * * @returns The index. * * @public */ getHighestIndexForParent(parent: TLParentId | TLPage | TLShape): IndexKey { const parentId = typeof parent === 'string' ? parent : parent.id const children = this._parentIdsToChildIds.get()[parentId] if (!children || children.length === 0) { return 'a1' as IndexKey } const shape = this.getShape(children[children.length - 1])! return getIndexAbove(shape.index) } /** * A cache of children for each parent. * * @internal */ private _childIdsCache = new WeakMapCache() /** * Get an array of all the children of a shape. * * @example * ```ts * editor.getSortedChildIdsForParent('frame1') * ``` * * @param parentId - The id of the parent shape. * * @public */ getSortedChildIdsForParent(parent: TLParentId | TLPage | TLShape): TLShapeId[] { const parentId = typeof parent === 'string' ? parent : parent.id const ids = this._parentIdsToChildIds.get()[parentId] if (!ids) return EMPTY_ARRAY return this._childIdsCache.get(ids, () => ids) } /** * Run a visitor function for all descendants of a shape. * * @example * ```ts * editor.visitDescendants('frame1', myCallback) * ``` * * @param parentId - The id of the parent shape. * @param visitor - The visitor function. * * @public */ visitDescendants( parent: TLParentId | TLPage | TLShape, visitor: (id: TLShapeId) => void | false ): this { const parentId = typeof parent === 'string' ? parent : parent.id const children = this.getSortedChildIdsForParent(parentId) for (const id of children) { if (visitor(id) === false) continue this.visitDescendants(id, visitor) } return this } /** * Get the shape ids of all descendants of the given shapes (including the shapes themselves). * * @param ids - The ids of the shapes to get descendants of. * * @returns The decscendant ids. * * @public */ getShapeAndDescendantIds(ids: TLShapeId[]): Set { const idsToInclude = new Set() const idsToCheck = [...ids] while (idsToCheck.length > 0) { const id = idsToCheck.pop() if (!id) break if (idsToInclude.has(id)) continue idsToInclude.add(id) for (const childId of this.getSortedChildIdsForParent(id)) { idsToCheck.push(childId) } } return idsToInclude } /** * Get the shape that some shapes should be dropped on at a given point. * * @param point - The point to find the parent for. * @param droppingShapes - The shapes that are being dropped. * * @returns The shape to drop on. * * @public */ getDroppingOverShape(point: VecLike, droppingShapes: TLShape[] = []) { // starting from the top... const currentPageShapesSorted = this.getCurrentPageShapesSorted() for (let i = currentPageShapesSorted.length - 1; i >= 0; i--) { const shape = currentPageShapesSorted[i] if ( // don't allow dropping on selected shapes this.getSelectedShapeIds().includes(shape.id) || // only allow shapes that can receive children !this.getShapeUtil(shape).canDropShapes(shape, droppingShapes) || // don't allow dropping a shape on itself or one of it's children droppingShapes.find((s) => s.id === shape.id || this.hasAncestor(shape, s.id)) ) { continue } // Only allow dropping into the masked page bounds of the shape, e.g. when a frame is // partially clipped by its own parent frame const maskedPageBounds = this.getShapeMaskedPageBounds(shape.id) if ( maskedPageBounds && maskedPageBounds.containsPoint(point) && this.getShapeGeometry(shape).hitTestPoint(this.getPointInShapeSpace(shape, point), 0, true) ) { return shape } } } /** * Get the shape that should be selected when you click on a given shape, assuming there is * nothing already selected. It will not return anything higher than or including the current * focus layer. * * @param shape - The shape to get the outermost selectable shape for. * @param filter - A function to filter the selectable shapes. * * @returns The outermost selectable shape. * * @public */ getOutermostSelectableShape( shape: TLShape | TLShapeId, filter?: (shape: TLShape) => boolean ): TLShape { const id = typeof shape === 'string' ? shape : shape.id const freshShape = this.getShape(id)! let match = freshShape let node = freshShape as TLShape | undefined const focusedGroup = this.getFocusedGroup() while (node) { if ( this.isShapeOfType(node, 'group') && focusedGroup?.id !== node.id && !this.hasAncestor(focusedGroup, node.id) && (filter?.(node) ?? true) ) { match = node } else if (focusedGroup?.id === node.id) { break } node = this.getShapeParent(node) } return match } /* -------------------- Commands -------------------- */ /** * Rotate shapes by a delta in radians. * Note: Currently, this assumes that the shapes are your currently selected shapes. * * @example * ```ts * editor.rotateShapesBy(editor.getSelectedShapeIds(), Math.PI) * editor.rotateShapesBy(editor.getSelectedShapeIds(), Math.PI / 2) * ``` * * @param shapes - The shapes (or shape ids) of the shapes to move. * @param delta - The delta in radians to apply to the selection rotation. */ rotateShapesBy(shapes: TLShapeId[] | TLShape[], delta: number): this { const ids = typeof shapes[0] === 'string' ? (shapes as TLShapeId[]) : (shapes as TLShape[]).map((s) => s.id) if (ids.length <= 0) return this const snapshot = getRotationSnapshot({ editor: this }) if (!snapshot) return this applyRotationToSnapshotShapes({ delta, snapshot, editor: this, stage: 'one-off' }) return this } private getChangesToTranslateShape(initialShape: TLShape, newShapeCoords: VecLike): TLShape { let workingShape = initialShape const util = this.getShapeUtil(initialShape) workingShape = applyPartialToShape( workingShape, util.onTranslateStart?.(workingShape) ?? undefined ) workingShape = applyPartialToShape(workingShape, { id: initialShape.id, type: initialShape.type, x: newShapeCoords.x, y: newShapeCoords.y, }) workingShape = applyPartialToShape( workingShape, util.onTranslate?.(initialShape, workingShape) ?? undefined ) workingShape = applyPartialToShape( workingShape, util.onTranslateEnd?.(initialShape, workingShape) ?? undefined ) return workingShape } /** * Move shapes by a delta. * * @example * ```ts * editor.nudgeShapes(['box1', 'box2'], { x: 8, y: 8 }) * editor.nudgeShapes(editor.getSelectedShapes(), { x: 8, y: 8 }, { squashing: true }) * ``` * * @param shapes - The shapes (or shape ids) to move. * @param direction - The direction in which to move the shapes. * @param historyOptions - The history options for the change. */ nudgeShapes( shapes: TLShapeId[] | TLShape[], offset: VecLike, historyOptions?: TLCommandHistoryOptions ): this { const ids = typeof shapes[0] === 'string' ? (shapes as TLShapeId[]) : (shapes as TLShape[]).map((s) => s.id) if (ids.length <= 0) return this const changes: TLShapePartial[] = [] for (const id of ids) { const shape = this.getShape(id)! const localDelta = Vec.From(offset) const parentTransform = this.getShapeParentTransform(shape) if (parentTransform) localDelta.rot(-parentTransform.rotation()) changes.push(this.getChangesToTranslateShape(shape, localDelta.add(shape))) } this.updateShapes(changes, { squashing: true, ...historyOptions, }) return this } /** * Duplicate shapes. * * @example * ```ts * editor.duplicateShapes(['box1', 'box2'], { x: 8, y: 8 }) * editor.duplicateShapes(editor.getSelectedShapes(), { x: 8, y: 8 }) * ``` * * @param shapes - The shapes (or shape ids) to duplicate. * @param offset - The offset (in pixels) to apply to the duplicated shapes. * * @public */ duplicateShapes(shapes: TLShapeId[] | TLShape[], offset?: VecLike): this { const ids = typeof shapes[0] === 'string' ? (shapes as TLShapeId[]) : (shapes as TLShape[]).map((s) => s.id) 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.getSortedChildIdsForParent(id).forEach((childId) => idsToCheck.push(childId)) } idsToCreate.reverse() const idsMap = new Map(idsToCreate.map((id) => [id, createShapeId()])) const shapesToCreate = compact( idsToCreate.map((id) => { const shape = this.getShape(id) if (!shape) { return null } const createId = idsMap.get(id)! let ox = 0 let oy = 0 if (offset && initialIds.has(id)) { const parentTransform = this.getShapeParentTransform(shape) const vec = new Vec(offset.x, offset.y).rot(-parentTransform!.rotation()) ox = vec.x oy = vec.y } const parentId = shape.parentId ?? this.getCurrentPageId() const siblings = this.getSortedChildIdsForParent(parentId) const currentIndex = siblings.indexOf(shape.id) const siblingAboveId = siblings[currentIndex + 1] const siblingAbove = siblingAboveId ? this.getShape(siblingAboveId) : null const index = siblingAbove ? getIndexBetween(shape.index, siblingAbove.index) : getIndexAbove(shape.index) let newShape: TLShape = structuredClone(shape) if ( this.isShapeOfType(shape, 'arrow') && this.isShapeOfType(newShape, 'arrow') ) { const info = this.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 = Vec.Med(info.start.handle, info.end.handle) const distA = Vec.Dist(info.middle, mpA) const distB = Vec.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.getCurrentPageShapeIds().size > MAX_SHAPES_PER_PAGE if (maxShapesReached) { alertMaxShapes(this) } const newShapes = maxShapesReached ? shapesToCreate.slice(0, MAX_SHAPES_PER_PAGE - this.getCurrentPageShapeIds().size) : shapesToCreate const ids = newShapes.map((s) => s.id) this.createShapes(newShapes) this.setSelectedShapes(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 selectionPageBounds = this.getSelectionPageBounds() const viewportPageBounds = this.getViewportPageBounds() if (selectionPageBounds && !viewportPageBounds.contains(selectionPageBounds)) { this.centerOnPoint(selectionPageBounds.center, { duration: ANIMATION_MEDIUM_MS, }) } } }) return this } /** * Move shapes to page. * * @example * ```ts * editor.moveShapesToPage(['box1', 'box2'], 'page1') * ``` * * @param shapes - The shapes (or shape ids) of the shapes to move. * @param pageId - The id of the page where the shapes will be moved. * * @public */ moveShapesToPage(shapes: TLShapeId[] | TLShape[], pageId: TLPageId): this { const ids = typeof shapes[0] === 'string' ? (shapes as TLShapeId[]) : (shapes as TLShape[]).map((s) => s.id) if (ids.length === 0) return this if (this.getInstanceState().isReadonly) return this const currentPageId = this.getCurrentPageId() if (pageId === currentPageId) return this if (!this.store.has(pageId)) return this // Basically copy the shapes const content = this.getContentFromCurrentPage(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.getPageShapeIds(pageId).size + content.shapes.length > MAX_SHAPES_PER_PAGE) { alertMaxShapes(this, pageId) return this } const fromPageZ = this.getCamera().z this.history.batch(() => { // Delete the shapes on the current page this.deleteShapes(ids) // Move to the next page this.setCurrentPage(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.setFocusedGroup(null) this.selectNone() this.putContentOntoCurrentPage(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 this.setCamera({ ...this.getCamera(), z: fromPageZ }) this.centerOnPoint(this.getSelectionRotatedPageBounds()!.center) }) return this } /** * Toggle the lock state of one or more shapes. If there is a mix of locked and unlocked shapes, all shapes will be locked. * * @param shapes - The shapes (or shape ids) to toggle. * * @public */ toggleLock(shapes: TLShapeId[] | TLShape[]): this { const ids = typeof shapes[0] === 'string' ? (shapes as TLShapeId[]) : (shapes as TLShape[]).map((s) => s.id) if (this.getInstanceState().isReadonly || ids.length === 0) return this let allLocked = true, allUnlocked = true const shapesToToggle: TLShape[] = [] for (const id of ids) { const shape = this.getShape(id) if (shape) { shapesToToggle.push(shape) if (shape.isLocked) { allUnlocked = false } else { allLocked = false } } } this.batch(() => { if (allUnlocked) { this.updateShapes( shapesToToggle.map((shape) => ({ id: shape.id, type: shape.type, isLocked: true })) ) this.setSelectedShapes([]) } else if (allLocked) { this.updateShapes( shapesToToggle.map((shape) => ({ id: shape.id, type: shape.type, isLocked: false })) ) } else { this.updateShapes( shapesToToggle.map((shape) => ({ id: shape.id, type: shape.type, isLocked: true })) ) } }) return this } /** * Send shapes to the back of the page's object list. * * @example * ```ts * editor.sendToBack(['id1', 'id2']) * editor.sendToBack(box1, box2) * ``` * * @param shapes - The shapes (or shape ids) to move. * * @public */ sendToBack(shapes: TLShapeId[] | TLShape[]): this { const ids = typeof shapes[0] === 'string' ? (shapes as TLShapeId[]) : (shapes as TLShape[]).map((s) => s.id) const changes = getReorderingShapesChanges(this, 'toBack', ids as TLShapeId[]) if (changes) this.updateShapes(changes) return this } /** * Send shapes backward in the page's object list. * * @example * ```ts * editor.sendBackward(['id1', 'id2']) * editor.sendBackward([box1, box2]) * ``` * * @param shapes - The shapes (or shape ids) to move. * * @public */ sendBackward(shapes: TLShapeId[] | TLShape[]): this { const ids = typeof shapes[0] === 'string' ? (shapes as TLShapeId[]) : (shapes as TLShape[]).map((s) => s.id) const changes = getReorderingShapesChanges(this, 'backward', ids as TLShapeId[]) if (changes) this.updateShapes(changes) return this } /** * Bring shapes forward in the page's object list. * * @example * ```ts * editor.bringForward(['id1', 'id2']) * editor.bringForward(box1, box2) * ``` * * @param shapes - The shapes (or shape ids) to move. * * @public */ bringForward(shapes: TLShapeId[] | TLShape[]): this { const ids = typeof shapes[0] === 'string' ? (shapes as TLShapeId[]) : (shapes as TLShape[]).map((s) => s.id) const changes = getReorderingShapesChanges(this, 'forward', ids as TLShapeId[]) if (changes) this.updateShapes(changes) return this } /** * Bring shapes to the front of the page's object list. * * @example * ```ts * editor.bringToFront(['id1', 'id2']) * editor.bringToFront([box1, box2]) * ``` * * @param shapes - The shapes (or shape ids) to move. * * @public */ bringToFront(shapes: TLShapeId[] | TLShape[]): this { const ids = typeof shapes[0] === 'string' ? (shapes as TLShapeId[]) : (shapes as TLShape[]).map((s) => s.id) const changes = getReorderingShapesChanges(this, 'toFront', ids as TLShapeId[]) if (changes) this.updateShapes(changes) return this } /** * Flip shape positions. * * @example * ```ts * editor.flipShapes([box1, box2], 'horizontal', 32) * editor.flipShapes(editor.getSelectedShapeIds(), 'horizontal', 32) * ``` * * @param shapes - The ids of the shapes to flip. * @param operation - Whether to flip horizontally or vertically. * * @public */ flipShapes(shapes: TLShapeId[] | TLShape[], operation: 'horizontal' | 'vertical'): this { const ids = typeof shapes[0] === 'string' ? (shapes as TLShapeId[]) : (shapes as TLShape[]).map((s) => s.id) if (this.getInstanceState().isReadonly) return this let shapesToFlip = compact(ids.map((id) => this.getShape(id))) if (!shapesToFlip.length) return this shapesToFlip = compact( shapesToFlip .map((shape) => { if (this.isShapeOfType(shape, 'group')) { return this.getSortedChildIdsForParent(shape.id).map((id) => this.getShape(id)) } return shape }) .flat() ) const scaleOriginPage = Box.Common( compact(shapesToFlip.map((id) => this.getShapePageBounds(id))) ).center this.batch(() => { for (const shape of shapesToFlip) { const bounds = this.getShapeGeometry(shape).bounds const initialPageTransform = this.getShapePageTransform(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 * editor.stackShapes([box1, box2], 'horizontal', 32) * editor.stackShapes(editor.getSelectedShapeIds(), 'horizontal', 32) * ``` * * @param shapes - The shapes (or shape ids) to stack. * @param operation - Whether to stack horizontally or vertically. * @param gap - The gap to leave between shapes. * * @public */ stackShapes( shapes: TLShapeId[] | TLShape[], operation: 'horizontal' | 'vertical', gap: number ): this { const ids = typeof shapes[0] === 'string' ? (shapes as TLShapeId[]) : (shapes as TLShape[]).map((s) => s.id) if (this.getInstanceState().isReadonly) return this const shapesToStack = compact( ids .map((id) => this.getShape(id)) // always fresh shapes .filter((shape) => { if (!shape) return false if (this.isShapeOfType(shape, 'arrow')) { if (shape.props.start.type === 'binding' || shape.props.end.type === 'binding') { return false } } return true }) ) const len = shapesToStack.length if ((gap === 0 && len < 3) || len < 2) return this const pageBounds = Object.fromEntries( shapesToStack.map((shape) => [shape.id, this.getShapePageBounds(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 === 0) { const gaps: { gap: number; count: number }[] = [] shapesToStack.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 = shapesToStack[i] const nextShape = shapesToStack[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[shapesToStack[0].id][max] shapesToStack.forEach((shape, i) => { if (i === 0) return const delta = { x: 0, y: 0 } delta[val] = v + shapeGap - pageBounds[shape.id][val] const parent = this.getShapeParent(shape) const localDelta = parent ? Vec.Rot(delta, -this.getShapePageTransform(parent)!.decompose().rotation) : 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). * * @example * ```ts * editor.packShapes([box1, box2], 32) * editor.packShapes(editor.getSelectedShapeIds(), 32) * ``` * * * @param shapes - The shapes (or shape ids) to pack. * @param gap - The padding to apply to the packed shapes. Defaults to 16. */ packShapes(shapes: TLShapeId[] | TLShape[], gap: number): this { const ids = typeof shapes[0] === 'string' ? (shapes as TLShapeId[]) : (shapes as TLShape[]).map((s) => s.id) if (this.getInstanceState().isReadonly) return this if (ids.length < 2) return this const shapesToPack = compact( ids .map((id) => this.getShape(id)) // always fresh shapes .filter((shape) => { if (!shape) return false if (this.isShapeOfType(shape, 'arrow')) { if (shape.props.start.type === 'binding' || shape.props.end.type === 'binding') { return false } } return true }) ) const shapePageBounds: Record = {} const nextShapePageBounds: Record = {} let shape: TLShape, bounds: Box, area = 0 for (let i = 0; i < shapesToPack.length; i++) { shape = shapesToPack[i] bounds = this.getShapePageBounds(shape)! shapePageBounds[shape.id] = bounds nextShapePageBounds[shape.id] = bounds.clone() area += bounds.width * bounds.height } const commonBounds = Box.Common(compact(Object.values(shapePageBounds))) const maxWidth = commonBounds.width // sort the shapes by height, descending shapesToPack.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: Box[] = [new Box(commonBounds.x, commonBounds.y, startWidth, Infinity)] let width = 0 let height = 0 let space: Box let last: Box for (let i = 0; i < shapesToPack.length; i++) { shape = shapesToPack[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 + gap space.width -= bounds.width + gap } else if (bounds.width === space.width) { // fit the shape into the space (height) space.y += bounds.height + gap space.height -= bounds.height + gap } else { // split the space into two spaces spaces.push( new Box( space.x + (bounds.width + gap), space.y, space.width - (bounds.width + gap), bounds.height ) ) space.y += bounds.height + gap space.height -= bounds.height + gap } break } } const commonAfter = Box.Common(Object.values(nextShapePageBounds)) const centerDelta = Vec.Sub(commonBounds.center, commonAfter.center) let nextBounds: Box const changes: TLShapePartial[] = [] for (let i = 0; i < shapesToPack.length; i++) { shape = shapesToPack[i] bounds = shapePageBounds[shape.id] nextBounds = nextShapePageBounds[shape.id] const delta = Vec.Sub(nextBounds.point, bounds.point).add(centerDelta) const parentTransform = this.getShapeParentTransform(shape) if (parentTransform) delta.rot(-parentTransform.rotation()) 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 * editor.alignShapes([box1, box2], 'left') * editor.alignShapes(editor.getSelectedShapeIds(), 'left') * ``` * * @param shapes - The shapes (or shape ids) to align. * @param operation - The align operation to apply. * * @public */ alignShapes( shapes: TLShapeId[] | TLShape[], operation: 'left' | 'center-horizontal' | 'right' | 'top' | 'center-vertical' | 'bottom' ): this { const ids = typeof shapes[0] === 'string' ? (shapes as TLShapeId[]) : (shapes as TLShape[]).map((s) => s.id) if (this.getInstanceState().isReadonly) return this if (ids.length < 2) return this const shapesToAlign = compact(ids.map((id) => this.getShape(id))) // always fresh shapes const shapePageBounds = Object.fromEntries( shapesToAlign.map((shape) => [shape.id, this.getShapePageBounds(shape)]) ) const commonBounds = Box.Common(compact(Object.values(shapePageBounds))) const changes: TLShapePartial[] = [] shapesToAlign.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.getShapeParent(shape) const localDelta = parent ? Vec.Rot(delta, -this.getShapePageTransform(parent)!.decompose().rotation) : delta changes.push(this.getChangesToTranslateShape(shape, Vec.Add(shape, localDelta))) }) this.updateShapes(changes) return this } /** * Distribute shape positions. * * @example * ```ts * editor.distributeShapes([box1, box2], 'horizontal') * editor.distributeShapes(editor.getSelectedShapeIds(), 'horizontal') * ``` * * @param shapes - The shapes (or shape ids) to distribute. * @param operation - Whether to distribute shapes horizontally or vertically. * * @public */ distributeShapes(shapes: TLShapeId[] | TLShape[], operation: 'horizontal' | 'vertical'): this { const ids = typeof shapes[0] === 'string' ? (shapes as TLShapeId[]) : (shapes as TLShape[]).map((s) => s.id) if (this.getInstanceState().isReadonly) return this if (ids.length < 3) return this const len = ids.length const shapesToDistribute = compact(ids.map((id) => this.getShape(id))) // always fresh shapes const pageBounds = Object.fromEntries( shapesToDistribute.map((shape) => [shape.id, this.getShapePageBounds(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 = shapesToDistribute.sort( (a, b) => pageBounds[a.id][min] - pageBounds[b.id][min] )[0] const last = shapesToDistribute.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 shapesToDistribute .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.getShapeParent(shape) const localDelta = parent ? Vec.Rot(delta, -this.getShapePageTransform(parent)!.rotation()) : delta changes.push(this.getChangesToTranslateShape(shape, Vec.Add(shape, localDelta))) }) this.updateShapes(changes) return this } /** * Stretch shape sizes and positions to fill their common bounding box. * * @example * ```ts * editor.stretchShapes([box1, box2], 'horizontal') * editor.stretchShapes(editor.getSelectedShapeIds(), 'horizontal') * ``` * * @param shapes - The shapes (or shape ids) to stretch. * @param operation - Whether to stretch shapes horizontally or vertically. * * @public */ stretchShapes(shapes: TLShapeId[] | TLShape[], operation: 'horizontal' | 'vertical'): this { const ids = typeof shapes[0] === 'string' ? (shapes as TLShapeId[]) : (shapes as TLShape[]).map((s) => s.id) if (this.getInstanceState().isReadonly) return this if (ids.length < 2) return this const shapesToStretch = compact(ids.map((id) => this.getShape(id))) // always fresh shapes const shapeBounds = Object.fromEntries(ids.map((id) => [id, this.getShapeGeometry(id).bounds])) const shapePageBounds = Object.fromEntries(ids.map((id) => [id, this.getShapePageBounds(id)!])) const commonBounds = Box.Common(compact(Object.values(shapePageBounds))) switch (operation) { case 'vertical': { this.batch(() => { for (const shape of shapesToStretch) { const pageRotation = this.getShapePageTransform(shape)!.rotation() if (pageRotation % PI2) continue const bounds = shapeBounds[shape.id] const pageBounds = shapePageBounds[shape.id] const localOffset = new Vec(0, commonBounds.minY - pageBounds.minY) const parentTransform = this.getShapeParentTransform(shape) if (parentTransform) localOffset.rot(-parentTransform.rotation()) const { x, y } = Vec.Add(localOffset, shape) this.updateShapes([{ id: shape.id, type: shape.type, x, y }], { squashing: true }) const scale = new Vec(1, commonBounds.height / pageBounds.height) this.resizeShape(shape.id, scale, { initialBounds: bounds, scaleOrigin: new Vec(pageBounds.center.x, commonBounds.minY), scaleAxisRotation: 0, }) } }) break } case 'horizontal': { this.batch(() => { for (const shape of shapesToStretch) { const bounds = shapeBounds[shape.id] const pageBounds = shapePageBounds[shape.id] const pageRotation = this.getShapePageTransform(shape)!.rotation() if (pageRotation % PI2) continue const localOffset = new Vec(commonBounds.minX - pageBounds.minX, 0) const parentTransform = this.getShapeParentTransform(shape) if (parentTransform) localOffset.rot(-parentTransform.rotation()) const { x, y } = Vec.Add(localOffset, shape) this.updateShapes([{ id: shape.id, type: shape.type, x, y }], { squashing: true }) const scale = new Vec(commonBounds.width / pageBounds.width, 1) this.resizeShape(shape.id, scale, { initialBounds: bounds, scaleOrigin: new Vec(commonBounds.minX, pageBounds.center.y), scaleAxisRotation: 0, }) } }) break } } return this } /** * Resize a shape. * * @param id - The id of the shape to resize. * @param scale - The scale factor to apply to the shape. * @param options - Additional options. * * @public */ resizeShape( shape: TLShapeId | TLShape, scale: VecLike, options: TLResizeShapeOptions = {} ): this { const id = typeof shape === 'string' ? shape : shape.id if (this.getInstanceState().isReadonly) return this if (!Number.isFinite(scale.x)) scale = new Vec(1, scale.y) if (!Number.isFinite(scale.y)) scale = new Vec(scale.x, 1) const initialShape = options.initialShape ?? this.getShape(id) if (!initialShape) return this const scaleOrigin = options.scaleOrigin ?? this.getShapePageBounds(id)?.center if (!scaleOrigin) return this const pageTransform = options.initialPageTransform ? Mat.Cast(options.initialPageTransform) : this.getShapePageTransform(id) if (!pageTransform) return this const pageRotation = pageTransform.rotation() if (pageRotation == null) return this const scaleAxisRotation = options.scaleAxisRotation ?? pageRotation const initialBounds = options.initialBounds ?? this.getShapeGeometry(id).bounds 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 Vec(scale.x, Math.sign(scale.y) * Math.abs(scale.x)) } else { scale = new Vec(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( Mat.applyToPoint(pageTransform, new Vec(0, 0)), scaleOrigin, scale, scaleAxisRotation ) const newLocalPoint = this.getPointInParentSpace(initialShape.id, newPagePoint) // resize the shape's local bounding box const myScale = new Vec(scale.x, scale.y) // the shape is aligned with the rest of the shapes 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 = Mat.applyToPoint(pageTransform, new Vec()) // 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, } ), }, ], { squashing: true } ) } else { const initialPageCenter = Mat.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 = Vec.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, }, ], { squashing: true } ) } return this } /** @internal */ private _scalePagePoint( point: VecLike, scaleOrigin: VecLike, scale: VecLike, scaleAxisRotation: number ) { const relativePoint = Vec.RotWith(point, scaleOrigin, -scaleAxisRotation).sub(scaleOrigin) // calculate the new point position relative to the scale origin const newRelativePagePoint = Vec.MulV(relativePoint, scale) // and rotate it back to page coords to get the new page point of the resized shape const destination = Vec.Add(newRelativePagePoint, scaleOrigin).rotWith( scaleOrigin, scaleAxisRotation ) return destination } /** @internal */ private _resizeUnalignedShape( id: TLShapeId, scale: VecLike, options: { initialBounds: Box 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 normalize 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 Vec(scale.x, scale.y) // // make sure we are constraining 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 } = Mat.Decompose(options.initialPageTransform) rotation -= 2 * rotation this.updateShapes([{ id, type, rotation }], { squashing: 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 the current page space before the scale was applied. const preScaleShapePageCenter = Mat.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 calculate how far away the shape is from where it needs to be const pageBounds = this.getShapePageBounds(id)! const pageTransform = this.getShapePageTransform(id)! const currentPageCenter = pageBounds.center const shapePageTransformOrigin = pageTransform.point() if (!currentPageCenter || !shapePageTransformOrigin) return this const pageDelta = Vec.Sub(postScaleShapePageCenter, currentPageCenter) // and finally figure out what the shape's new position should be const postScaleShapePagePoint = Vec.Add(shapePageTransformOrigin, pageDelta) const { x, y } = this.getPointInParentSpace(id, postScaleShapePagePoint) this.updateShapes([{ id, type, x, y }], { squashing: true }) return this } /** * Get the initial meta value for a shape. * * @example * ```ts * editor.getInitialMetaForShape = (shape) => { * if (shape.type === 'note') { * return { createdBy: myCurrentUser.id } * } * } * ``` * * @param shape - The shape to get the initial meta for. * * @public */ getInitialMetaForShape(_shape: TLShape): JsonObject { return {} } /** * Create a single shape. * * @example * ```ts * editor.createShape(myShape) * editor.createShape({ id: 'box1', type: 'text', props: { text: "ok" } }) * ``` * * @param shape - The shape (or shape partial) to create. * * @public */ createShape(shape: OptionalKeys, 'id'>): this { this._createShapes([shape]) return this } /** * Create shapes. * * @example * ```ts * editor.createShapes([myShape]) * editor.createShapes([{ id: 'box1', type: 'text', props: { text: "ok" } }]) * ``` * * @param shapes - The shapes (or shape partials) to create. * @param select - Whether to select the created shapes. Defaults to false. * * @public */ createShapes(shapes: OptionalKeys, 'id'>[]) { if (!Array.isArray(shapes)) { throw Error('Editor.createShapes: must provide an array of shapes or shape partials') } this._createShapes(shapes) return this } /** @internal */ private _createShapes = this.history.createCommand( 'createShapes', (partials: OptionalKeys[]) => { if (this.getInstanceState().isReadonly) return null if (partials.length <= 0) return null const currentPageShapeIds = this.getCurrentPageShapeIds() const maxShapesReached = partials.length + currentPageShapeIds.size > MAX_SHAPES_PER_PAGE if (maxShapesReached) { // can't create more shapes than fit on the page alertMaxShapes(this) return } if (partials.length === 0) return null return { data: { currentPageId: this.getCurrentPageId(), partials: partials.map((p) => p.id ? p : { ...p, id: createShapeId() } ) as TLShapePartial[], }, } }, { do: ({ partials }) => { const focusedGroupId = this.getFocusedGroupId() // 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. // find last parent id const currentPageShapesSorted = this.getCurrentPageShapesSorted() partials = partials.map((partial) => { // If the partial does not provide the parentId OR if the provided // parentId is NOT in the store AND NOT among the other shapes being // created, then we need to find a parent for the shape. This can be // another shape that exists under that point and which can receive // children of the creating shape's type, or else the page itself. if ( !partial.parentId || !(this.store.has(partial.parentId) || partials.some((p) => p.id === partial.parentId)) ) { let parentId: TLParentId = this.getFocusedGroupId() for (let i = currentPageShapesSorted.length - 1; i >= 0; i--) { const parent = currentPageShapesSorted[i] if ( // parent.type === 'frame' this.getShapeUtil(parent).canReceiveNewChildrenOfType(parent, partial.type) && this.isPointInShape( parent, // If no parent is provided, then we can treat the // shape's provided x/y as being in the page's space. { x: partial.x ?? 0, y: partial.y ?? 0 }, { margin: 0, hitInside: true, } ) ) { parentId = parent.id break } } const prevParentId = partial.parentId // a shape cannot be it's own parent. This was a rare issue with frames/groups in the syncFuzz tests. if (parentId === partial.id) { parentId = focusedGroupId } // If the parentid has changed... if (parentId !== prevParentId) { partial = { ...partial } partial.parentId = parentId // If the parent is a shape (rather than a page) then insert the // shapes into the shape's children. Adjust the point and page rotation to be // preserved relative to the parent. if (isShapeId(parentId)) { const point = this.getPointInShapeSpace(this.getShape(parentId)!, { x: partial.x ?? 0, y: partial.y ?? 0, }) partial.x = point.x partial.y = point.y partial.rotation = -this.getShapePageTransform(parentId)!.rotation() + (partial.rotation ?? 0) } } } 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() 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) { // Hello bug-seeker: have you just created a frame and then a shape // and found that the shape is automatically the child of the frame? // this is the reason why! It would be harder to have each shape specify // the frame as the parent when creating a shape inside of a frame, so // we do it here. const parentId = partial.parentId ?? focusedGroupId 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.getDefaultProps() // We then look up each key in the tab state's styles; and if it's there, // we use the value from the tab state's styles instead of the default. for (const [style, propKey] of this.styleProps[partial.type]) { ;(initialProps as any)[propKey] = this.getStyleForNextShape(style) } // 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, opacity: partial.opacity ?? this.getInstanceState().opacityForNextShape, parentId: partial.parentId ?? focusedGroupId, 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) } // Add meta properties, if any, to the shapes shapeRecordsToCreate.forEach((shape) => { shape.meta = { ...this.getInitialMetaForShape(shape), ...shape.meta, } }) this.store.put(shapeRecordsToCreate) }, undo: ({ partials }) => { this.store.remove(partials.map((p) => p.id)) }, } ) private animatingShapes = new Map() /** * Animate a shape. * * @example * ```ts * editor.animateShape({ id: 'box1', type: 'box', x: 100, y: 100 }) * editor.animateShape({ id: 'box1', type: 'box', x: 100, y: 100 }, { duration: 100, ease: t => t*t }) * ``` * * @param partial - The shape partial to update. * @param options - The animation's options. * * @public */ animateShape( partial: TLShapePartial | null | undefined, animationOptions?: TLAnimationOptions ): this { return this.animateShapes([partial], animationOptions) } /** * Animate shapes. * * @example * ```ts * editor.animateShapes([{ id: 'box1', type: 'box', x: 100, y: 100 }]) * editor.animateShapes([{ id: 'box1', type: 'box', x: 100, y: 100 }], { duration: 100, ease: t => t*t }) * ``` * * @param partials - The shape partials to update. * @param options - The animation's options. * * @public */ animateShapes( partials: (TLShapePartial | null | undefined)[], animationOptions = {} as TLAnimationOptions ): this { const { duration = 500, easing = EASINGS.linear } = animationOptions const animationId = uniqueId() let remaining = duration let t: number type ShapeAnimation = { partial: TLShapePartial values: { prop: string; from: number; to: number }[] } const animations: ShapeAnimation[] = [] let partial: TLShapePartial | null | undefined, result: ShapeAnimation for (let i = 0, n = partials.length; i < n; i++) { partial = partials[i] if (!partial) continue result = { partial, values: [], } const shape = this.getShape(partial.id)! if (!shape) continue // We only support animations for certain props 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, { squashing: false }) // update shapes also removes the shape from animating shapes } this.removeListener('tick', handleTick) return } t = easing(1 - remaining / duration) const { animatingShapes } = this const updates: TLShapePartial[] = [] let animationIdForShape: string | undefined for (let i = 0, n = animations.length; i < n; i++) { value = animations[i] // Is the animation for this shape still active? animationIdForShape = animatingShapes.get(value.partial.id) if (animationIdForShape !== animationId) continue // Create the update updates.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(updates, { squashing: true }) } this.addListener('tick', handleTick) return this } /** * Create a group containing the provided shapes. * * @param shapes - The shapes (or shape ids) to group. Defaults to the selected shapes. * @param groupId - The id of the group to create. * * @public */ groupShapes(shapes: TLShapeId[] | TLShape[], groupId = createShapeId()): this { if (!Array.isArray(shapes)) { throw Error('Editor.groupShapes: must provide an array of shapes or shape ids') } if (this.getInstanceState().isReadonly) return this const ids = typeof shapes[0] === 'string' ? (shapes as TLShapeId[]) : (shapes.map((s) => (s as TLShape).id) as TLShapeId[]) if (ids.length <= 1) return this const shapesToGroup = compact(this._getUnlockedShapeIds(ids).map((id) => this.getShape(id))) const sortedShapeIds = shapesToGroup.sort(sortByIndex).map((s) => s.id) const pageBounds = Box.Common(compact(shapesToGroup.map((id) => this.getShapePageBounds(id)))) const { x, y } = pageBounds.point const parentId = this.findCommonAncestor(shapesToGroup) ?? this.getCurrentPageId() // Only group when the select tool is active if (this.getCurrentToolId() !== '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 = shapesToGroup .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, opacity: 1, props: {}, }, ]) this.reparentShapes(sortedShapeIds, groupId) this.select(groupId) }) return this } /** * Ungroup some shapes. * * @param ids - Ids of the shapes to ungroup. Defaults to the selected shapes. * * @public */ ungroupShapes(ids: TLShapeId[]): this ungroupShapes(ids: TLShape[]): this ungroupShapes(_ids: TLShapeId[] | TLShape[]) { const ids = typeof _ids[0] === 'string' ? (_ids as TLShapeId[]) : (_ids as TLShape[]).map((s) => s.id) if (this.getInstanceState().isReadonly) return this if (ids.length === 0) return this // Only ungroup when the select tool is active if (this.getCurrentToolId() !== '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() // Get all groups in the selection const shapes = compact(ids.map((id) => this.getShape(id))) const groups: TLGroupShape[] = [] shapes.forEach((shape) => { if (this.isShapeOfType(shape, 'group')) { 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.getSortedChildIdsForParent(group.id) for (let j = 0, n = childIds.length; j < n; j++) { idsToSelect.add(childIds[j]) } this.reparentShapes(childIds, group.parentId, group.index) } this.deleteShapes(groups.map((group) => group.id)) this.select(...idsToSelect) }) return this } /** * Update a shape using a partial of the shape. * * @example * ```ts * editor.updateShape({ id: 'box1', type: 'geo', props: { w: 100, h: 100 } }) * ``` * * @param partial - The shape partial to update. * @param historyOptions - The history options for the change. * * @public */ updateShape( partial: TLShapePartial | null | undefined, historyOptions?: TLCommandHistoryOptions ) { this.updateShapes([partial], historyOptions) return this } /** * Update shapes using partials of each shape. * * @example * ```ts * editor.updateShapes([{ id: 'box1', type: 'geo', props: { w: 100, h: 100 } }]) * ``` * * @param partials - The shape partials to update. * @param historyOptions - The history options for the change. * * @public */ updateShapes( partials: (TLShapePartial | null | undefined)[], historyOptions?: TLCommandHistoryOptions ) { const compactedPartials: TLShapePartial[] = Array(partials.length) for (let i = 0, n = partials.length; i < n; i++) { const partial = partials[i] if (!partial) continue // Get the current shape referenced by the partial const shape = this.getShape(partial.id) if (!shape) continue // If the shape is locked and we're not setting isLocked to true, continue if (this.isShapeOrAncestorLocked(shape) && !Object.hasOwn(partial, 'isLocked')) continue // Remove any animating shapes from the list of partials this.animatingShapes.delete(partial.id) compactedPartials.push(partial) } this._updateShapes(compactedPartials, historyOptions) return this } /** @internal */ private _updateShapes = this.history.createCommand( 'updateShapes', ( _partials: (TLShapePartial | null | undefined)[], historyOptions?: TLCommandHistoryOptions ) => { if (this.getInstanceState().isReadonly) return null const snapshots: Record = {} const updates: Record = {} let shape: TLShape | undefined let updated: TLShape for (let i = 0, n = _partials.length; i < n; i++) { const partial = _partials[i] // Skip nullish partials (sometimes created by map fns returning undefined) if (!partial) continue // Get the current shape referenced by the partial // If there is no current shape, we'll skip this update shape = this.getShape(partial.id) if (!shape) continue // Get the updated version of the shape // If the update had no effect, we'll skip this update updated = applyPartialToShape(shape, partial) if (updated === shape) continue snapshots[shape.id] = shape updates[shape.id] = updated } return { data: { snapshots, updates }, ...historyOptions } }, { do: ({ updates }) => { // Iterate through array; if any shape has an onBeforeUpdate 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. this.store.put( objectMapValues(updates).map((shape) => { const current = this.store.get(shape.id) if (current) { const next = this.getShapeUtil(shape).onBeforeUpdate?.(current, shape) if (next) return next } return shape }) ) }, 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.getShape(id)?.isLocked) } /** * Delete shapes. * * @example * ```ts * editor.deleteShapes(['box1', 'box2']) * ``` * * @param ids - The ids of the shapes to delete. * * @public */ deleteShapes(ids: TLShapeId[]): this deleteShapes(shapes: TLShape[]): this deleteShapes(_ids: TLShapeId[] | TLShape[]) { if (!Array.isArray(_ids)) { throw Error('Editor.deleteShapes: must provide an array of shapes or shapeIds') } this._deleteShapes( this._getUnlockedShapeIds( typeof _ids[0] === 'string' ? (_ids as TLShapeId[]) : (_ids as TLShape[]).map((s) => s.id) ) ) return this } /** * Delete a shape. * * @example * ```ts * editor.deleteShapes(['box1', 'box2']) * ``` * * @param id - The id of the shape to delete. * * @public */ deleteShape(id: TLShapeId): this deleteShape(shape: TLShape): this deleteShape(_id: TLShapeId | TLShape) { this.deleteShapes([typeof _id === 'string' ? _id : _id.id]) return this } /** @internal */ private _deleteShapes = this.history.createCommand( 'delete_shapes', (ids: TLShapeId[]) => { if (this.getInstanceState().isReadonly) return null if (ids.length === 0) return null const prevSelectedShapeIds = [...this.getCurrentPageState().selectedShapeIds] const allIds = new Set(ids) for (const id of ids) { this.visitDescendants(id, (childId) => { allIds.add(childId) }) } const deletedIds = [...allIds] const arrowBindings = this._getArrowBindingsIndex().get() const snapshots = compact( deletedIds.flatMap((id) => { const shape = this.getShape(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.getShape(arrowId)).concat(shape) } return shape }) ) const postSelectedShapeIds = prevSelectedShapeIds.filter((id) => !allIds.has(id)) return { data: { deletedIds, snapshots, prevSelectedShapeIds, postSelectedShapeIds } } }, { do: ({ deletedIds, postSelectedShapeIds }) => { this.store.remove(deletedIds) this.store.update(this.getCurrentPageState().id, (state) => ({ ...state, selectedShapeIds: postSelectedShapeIds, })) }, undo: ({ snapshots, prevSelectedShapeIds }) => { this.store.put(snapshots) this.store.update(this.getCurrentPageState().id, (state) => ({ ...state, selectedShapeIds: prevSelectedShapeIds, })) }, } ) /* --------------------- Styles --------------------- */ /** * Get all the current styles among the users selected shapes * * @internal */ private _extractSharedStyles(shape: TLShape, sharedStyleMap: SharedStyleMap) { if (this.isShapeOfType(shape, 'group')) { // For groups, ignore the styles of the group shape and instead include the styles of the // group's children. These are the shapes that would have their styles changed if the // user called `setStyle` on the current selection. const childIds = this._parentIdsToChildIds.get()[shape.id] if (!childIds) return for (let i = 0, n = childIds.length; i < n; i++) { this._extractSharedStyles(this.getShape(childIds[i])!, sharedStyleMap) } } else { for (const [style, propKey] of this.styleProps[shape.type]) { sharedStyleMap.applyValue(style, getOwnProperty(shape.props, propKey)) } } } /** * A derived map containing all current styles among the user's selected shapes. * * @internal */ @computed private _getSelectionSharedStyles(): ReadonlySharedStyleMap { const selectedShapes = this.getSelectedShapes() const sharedStyles = new SharedStyleMap() for (const selectedShape of selectedShapes) { this._extractSharedStyles(selectedShape, sharedStyles) } return sharedStyles } /** * Get the style for the next shape. * * @example * ```ts * const color = editor.getStyleForNextShape(DefaultColorStyle) * ``` * * @param style - The style to get. * * @public */ getStyleForNextShape(style: StyleProp): T { const value = this.getInstanceState().stylesForNextShape[style.id] return value === undefined ? style.defaultValue : (value as T) } getShapeStyleIfExists(shape: TLShape, style: StyleProp): T | undefined { const styleKey = this.styleProps[shape.type].get(style) if (styleKey === undefined) return undefined return getOwnProperty(shape.props, styleKey) as T | undefined } /** * A map of all the current styles either in the current selection, or that are relevant to the * current tool. * * @example * ```ts * const color = editor.getSharedStyles().get(DefaultColorStyle) * if (color && color.type === 'shared') { * print('All selected shapes have the same color:', color.value) * } * ``` * * @public */ @computed({ isEqual: (a, b) => a.equals(b) }) getSharedStyles(): ReadonlySharedStyleMap { // If we're in selecting and if we have a selection, return the shared styles from the // current selection if (this.isIn('select') && this.getSelectedShapeIds().length > 0) { return this._getSelectionSharedStyles() } // If the current tool is associated with a shape, return the styles for that shape. // Otherwise, just return an empty map. const currentTool = this.root.getCurrent()! const styles = new SharedStyleMap() if (!currentTool) return styles if (currentTool.shapeType) { for (const style of this.styleProps[currentTool.shapeType].keys()) { styles.applyValue(style, this.getStyleForNextShape(style)) } } return styles } /** * Get the currently selected shared opacity. * If any shapes are selected, this returns the shared opacity of the selected shapes. * Otherwise, this returns the chosen opacity for the next shape. * * @public */ @computed getSharedOpacity(): SharedStyle { if (this.isIn('select') && this.getSelectedShapeIds().length > 0) { const shapesToCheck: TLShape[] = [] const addShape = (shapeId: TLShapeId) => { const shape = this.getShape(shapeId) if (!shape) return // For groups, ignore the opacity of the group shape and instead include // the opacity of the group's children. These are the shapes that would have // their opacity changed if the user called `setOpacity` on the current selection. if (this.isShapeOfType(shape, 'group')) { for (const childId of this.getSortedChildIdsForParent(shape.id)) { addShape(childId) } } else { shapesToCheck.push(shape) } } for (const shapeId of this.getSelectedShapeIds()) { addShape(shapeId) } let opacity: number | null = null for (const shape of shapesToCheck) { if (opacity === null) { opacity = shape.opacity } else if (opacity !== shape.opacity) { return { type: 'mixed' } } } if (opacity !== null) return { type: 'shared', value: opacity } } return { type: 'shared', value: this.getInstanceState().opacityForNextShape } } /** * Set the opacity for the next shapes. This will effect subsequently created shapes. * * @example * ```ts * editor.setOpacityForNextShapes(0.5) * editor.setOpacityForNextShapes(0.5, { squashing: true }) * ``` * * @param opacity - The opacity to set. Must be a number between 0 and 1 inclusive. * @param historyOptions - The history options for the change. */ setOpacityForNextShapes(opacity: number, historyOptions?: TLCommandHistoryOptions): this { this.updateInstanceState({ opacityForNextShape: opacity }, historyOptions) return this } /** * Set the current opacity. This will effect any selected shapes. * * @example * ```ts * editor.setOpacityForSelectedShapes(0.5) * editor.setOpacityForSelectedShapes(0.5, { squashing: true }) * ``` * * @param opacity - The opacity to set. Must be a number between 0 and 1 inclusive. * @param historyOptions - The history options for the change. */ setOpacityForSelectedShapes(opacity: number, historyOptions?: TLCommandHistoryOptions): this { const selectedShapes = this.getSelectedShapes() if (selectedShapes.length > 0) { const shapesToUpdate: TLShape[] = [] // We can have many deep levels of grouped shape // Making a recursive function to look through all the levels const addShapeById = (shape: TLShape) => { if (this.isShapeOfType(shape, 'group')) { const childIds = this.getSortedChildIdsForParent(shape) for (const childId of childIds) { addShapeById(this.getShape(childId)!) } } else { shapesToUpdate.push(shape) } } for (const id of selectedShapes) { addShapeById(id) } this.updateShapes( shapesToUpdate.map((shape) => { return { id: shape.id, type: shape.type, opacity, } }), historyOptions ) } return this } /** * Set the value of a {@link @tldraw/tlschema#StyleProp} for the next shapes. This change will be applied to subsequently created shapes. * * @example * ```ts * editor.setStyleForNextShapes(DefaultColorStyle, 'red') * editor.setStyleForNextShapes(DefaultColorStyle, 'red', { ephemeral: true }) * ``` * * @param style - The style to set. * @param value - The value to set. * @param historyOptions - The history options for the change. * * @public */ setStyleForNextShapes( style: StyleProp, value: T, historyOptions?: TLCommandHistoryOptions ): this { const stylesForNextShape = this.getInstanceState().stylesForNextShape this.updateInstanceState( { stylesForNextShape: { ...stylesForNextShape, [style.id]: value } }, historyOptions ) return this } /** * Set the value of a {@link @tldraw/tlschema#StyleProp}. This change will be applied to the currently selected shapes. * * @example * ```ts * editor.setStyleForSelectedShapes(DefaultColorStyle, 'red') * editor.setStyleForSelectedShapes(DefaultColorStyle, 'red', { ephemeral: true }) * ``` * * @param style - The style to set. * @param value - The value to set. * @param historyOptions - The history options for the change. * * @public */ setStyleForSelectedShapes>( style: S, value: StylePropValue, historyOptions?: TLCommandHistoryOptions ): this { const selectedShapes = this.getSelectedShapes() if (selectedShapes.length > 0) { const updates: { util: ShapeUtil originalShape: TLShape updatePartial: TLShapePartial }[] = [] // We can have many deep levels of grouped shape // Making a recursive function to look through all the levels const addShapeById = (shape: TLShape) => { if (this.isShapeOfType(shape, 'group')) { const childIds = this.getSortedChildIdsForParent(shape.id) for (const childId of childIds) { addShapeById(this.getShape(childId)!) } } else { const util = this.getShapeUtil(shape) const stylePropKey = this.styleProps[shape.type].get(style) if (stylePropKey) { const shapePartial: TLShapePartial = { id: shape.id, type: shape.type, props: { [stylePropKey]: value }, } updates.push({ util, originalShape: shape, updatePartial: shapePartial, }) } } } for (const shape of selectedShapes) { addShapeById(shape) } this.updateShapes( updates.map(({ updatePartial }) => updatePartial), historyOptions ) } return this } /* --------------------- Content -------------------- */ /** @internal */ externalAssetContentHandlers: { [K in TLExternalAssetContent['type']]: { [Key in K]: | null | ((info: TLExternalAssetContent & { type: Key }) => Promise) }[K] } = { file: null, url: null, } /** * Register an external content handler. This handler will be called when the editor receives * external content of the provided type. For example, the 'image' type handler will be called * when a user drops an image onto the canvas. * * @example * ```ts * editor.registerExternalAssetHandler('text', myHandler) * ``` * * @param type - The type of external content. * @param handler - The handler to use for this content type. * * @public */ registerExternalAssetHandler( type: T, handler: null | ((info: TLExternalAssetContent & { type: T }) => Promise) ): this { this.externalAssetContentHandlers[type] = handler as any return this } /** * Get an asset for an external asset content type. * * @example * ```ts * const asset = await editor.getAssetForExternalContent({ type: 'file', file: myFile }) * const asset = await editor.getAssetForExternalContent({ type: 'url', url: myUrl }) * ``` * * @param info - Info about the external content. * @returns The asset. */ async getAssetForExternalContent(info: TLExternalAssetContent): Promise { return await this.externalAssetContentHandlers[info.type]?.(info as any) } /** @internal */ externalContentHandlers: { [K in TLExternalContent['type']]: { [Key in K]: null | ((info: TLExternalContent & { type: Key }) => void) }[K] } = { text: null, files: null, embed: null, 'svg-text': null, url: null, } /** * Register an external content handler. This handler will be called when the editor receives * external content of the provided type. For example, the 'image' type handler will be called * when a user drops an image onto the canvas. * * @example * ```ts * editor.registerExternalContentHandler('text', myHandler) * ``` * * @param type - The type of external content. * @param handler - The handler to use for this content type. * * @public */ registerExternalContentHandler( type: T, handler: | null | (( info: T extends TLExternalContent['type'] ? TLExternalContent & { type: T } : TLExternalContent ) => void) ): this { this.externalContentHandlers[type] = handler as any return this } /** * Handle external content, such as files, urls, embeds, or plain text which has been put into the app, for example by pasting external text or dropping external images onto canvas. * * @param info - Info about the external content. */ async putExternalContent(info: TLExternalContent): Promise { return this.externalContentHandlers[info.type]?.(info as any) } /** * Get content that can be exported for the given shape ids. * * @param shapes - The shapes (or shape ids) to get content for. * * @returns The exported content. * * @public */ getContentFromCurrentPage(shapes: TLShapeId[] | TLShape[]): TLContent | undefined { // todo: make this work with any page, not just the current page const ids = typeof shapes[0] === 'string' ? (shapes as TLShapeId[]) : (shapes as TLShape[]).map((s) => s.id) if (!ids) return if (ids.length === 0) return const pageTransforms: Record = {} let shapesForContent = dedupe( ids .map((id) => this.getShape(id)!) .sort(sortByIndex) .flatMap((shape) => { const allShapes = [shape] this.visitDescendants(shape.id, (descendant) => { allShapes.push(this.getShape(descendant)!) }) return allShapes }) ) shapesForContent = shapesForContent.map((shape) => { pageTransforms[shape.id] = this.getShapePageTransform(shape.id)! shape = structuredClone(shape) as typeof shape if (this.isShapeOfType(shape, 'arrow')) { 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.getArrowInfo(shape) if (shape.props.start.type === 'binding') { if (!shapesForContent.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 (!shapesForContent.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 = Vec.Med(info.start.handle, info.end.handle) const distA = Vec.Dist(info.middle, mpA) const distB = Vec.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[] = [] shapesForContent.forEach((shape) => { if (shapesForContent.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 pageTransform = this.getShapePageTransform(shape.id)! const pagePoint = pageTransform.point() const pageRotation = pageTransform.rotation() shape.x = pagePoint.x shape.y = pagePoint.y shape.rotation = pageRotation shape.parentId = this.getCurrentPageId() rootShapeIds.push(shape.id) } }) const assetsSet = new Set() shapesForContent.forEach((shape) => { if ('assetId' in shape.props) { if (shape.props.assetId !== null) { assetsSet.add(shape.props.assetId) } } }) return { shapes: shapesForContent, rootShapeIds, schema: this.store.schema.serialize(), assets: compact(Array.from(assetsSet).map((id) => this.getAsset(id))), } } /** * Place content into the editor. * * @param content - The content. * @param options - Options for placing the content. * * @public */ putContentOntoCurrentPage( content: TLContent, options: { point?: VecLike select?: boolean preservePosition?: boolean preserveIds?: boolean } = {} ): this { if (this.getInstanceState().isReadonly) return this // todo: make this able to support putting content onto any page, not just the current page if (!content.schema) { throw Error('Could not put content:\ncontent 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.getCurrentPageId() const { rootShapeIds } = content // We need to collect the migrated shapes and assets const assets: TLAsset[] = [] const shapes: TLShape[] = [] // Let's treat the content as a store, and then migrate that store. const store: StoreSnapshot = { store: { ...Object.fromEntries(content.assets.map((asset) => [asset.id, asset] as const)), ...Object.fromEntries(content.shapes.map((asset) => [asset.id, asset] as const)), }, schema: content.schema, } const result = this.store.schema.migrateStoreSnapshot(store) if (result.type === 'error') { throw Error('Could not put content: could not migrate content') } for (const record of Object.values(result.value)) { switch (record.typeName) { case 'asset': { assets.push(record) break } case 'shape': { shapes.push(record) break } } } // Ok, we've got our migrated shapes and assets, now we can continue! const idMap = new Map(shapes.map((shape) => [shape.id, createShapeId()])) // By default, the paste parent will be the current page. let pasteParentId = this.getCurrentPageId() 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.getSelectedShapes()) { if (lowestDepth === 0) break const isFrame = this.isShapeOfType(shape, 'frame') const ancestors = this.getShapeAncestors(shape) if (isFrame) ancestors.push(shape) const depth = isFrame ? ancestors.length + 1 : ancestors.length if (depth < lowestDepth) { lowestDepth = depth lowestAncestors = ancestors pasteParentId = isFrame ? 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.getShape(pasteParentId) if (parent) { if (!this.getViewportPageBounds().includes(this.getShapePageBounds(parent)!)) { pasteParentId = currentPageId } else { if (rootShapeIds.length === 1) { const rootShape = shapes.find((s) => s.id === rootShapeIds[0])! if ( this.isShapeOfType(parent, 'frame') && this.isShapeOfType(rootShape, 'frame') && 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.getShape(pasteParentId)!.parentId } let index = this.getHighestIndexForParent(pasteParentId) // todo: requires that the putting page is the current page const rootShapes: TLShape[] = [] const newShapes: TLShape[] = shapes.map((shape): TLShape => { let newShape: TLShape if (preserveIds) { newShape = structuredClone(shape) idMap.set(shape.id, shape.id) } else { const id = idMap.get(shape.id)! // Create the new shape (new except for the id) newShape = structuredClone({ ...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, 'arrow')) { 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.getCurrentPageShapeIds().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 } // These are all the assets we need to create const assetsToCreate: TLAsset[] = [] // These assets have base64 data that may need to be hosted const assetsToUpdate: (TLImageAsset | TLVideoAsset)[] = [] for (const asset of assets) { if (this.store.has(asset.id)) { // We already have this asset continue } if ( (asset.type === 'image' || asset.type === 'video') && asset.props.src?.startsWith('data:image') ) { // it's src is a base64 image or video; we need to create a new asset without the src, // then create a new asset from the original src. So we save a copy of the original asset, // then delete the src from the original asset. assetsToUpdate.push(structuredClone(asset as TLImageAsset | TLVideoAsset)) asset.props.src = null } // Add the asset to the list of assets to create assetsToCreate.push(asset) } // Start loading the new assets, order does not matter Promise.allSettled( (assetsToUpdate as (TLImageAsset | TLVideoAsset)[]).map(async (asset) => { // Turn the data url into a file const file = await dataUrlToFile( asset.props.src!, asset.props.name, asset.props.mimeType ?? 'image/png' ) // Get a new asset for the file const newAsset = await this.getAssetForExternalContent({ type: 'file', file }) if (!newAsset) { // If we don't have a new asset, delete the old asset. // The shapes that reference this asset should break. this.deleteAssets([asset.id]) return } // Save the new asset under the old asset's id this.updateAssets([{ ...newAsset, id: asset.id }]) }) ) 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) if (select) { this.select(...rootShapes.map((s) => s.id)) } // And then, if needed, reparent the root shapes to the paste parent if (pasteParentId !== currentPageId) { this.reparentShapes( rootShapes.map((s) => s.id), pasteParentId ) } const newCreatedShapes = newShapes.map((s) => this.getShape(s.id)!) const bounds = Box.Common(newCreatedShapes.map((s) => this.getShapePageBounds(s)!)) if (point === undefined) { if (!isPageId(pasteParentId)) { // Put the shapes in the middle of the (on screen) parent const shape = this.getShape(pasteParentId)! point = Mat.applyToPoint( this.getShapePageTransform(shape), this.getShapeGeometry(shape).bounds.center ) } else { const viewportPageBounds = this.getViewportPageBounds() if (preservePosition || viewportPageBounds.includes(Box.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 (this.isShapeOfType(onlyRoot, 'frame')) { while ( this.getShapesAtPoint(point).some( (shape) => this.isShapeOfType(shape, 'frame') && shape.props.w === onlyRoot.props.w && shape.props.h === onlyRoot.props.h ) ) { point.x += bounds.w + 16 } } } const pageCenter = Box.Common( compact(rootShapes.map(({ id }) => this.getShapePageBounds(id))) ).center const offset = Vec.Sub(point, pageCenter) this.updateShapes( rootShapes.map(({ id }) => { const s = this.getShape(id)! const localRotation = this.getShapeParentTransform(id).decompose().rotation const localDelta = Vec.Rot(offset, -localRotation) return { id: s.id, type: s.type, x: s.x + localDelta.x, y: s.y + localDelta.y } }) ) }) return this } /** * Get an exported SVG element of the given shapes. * * @param ids - The shapes (or shape ids) to export. * @param opts - Options for the export. * * @returns The SVG element. * * @public */ async getSvgElement(shapes: TLShapeId[] | TLShape[], opts = {} as Partial) { const result = await getSvgJsx(this, shapes, opts) if (!result) return undefined const fragment = document.createDocumentFragment() const root = createRoot(fragment) flushSync(() => { root.render(result.jsx) }) const svg = fragment.firstElementChild assert(svg instanceof SVGSVGElement, 'Expected an SVG element') root.unmount() return { svg, width: result.width, height: result.height } } /** * Get an exported SVG string of the given shapes. * * @param ids - The shapes (or shape ids) to export. * @param opts - Options for the export. * * @returns The SVG element. * * @public */ async getSvgString(shapes: TLShapeId[] | TLShape[], opts = {} as Partial) { const result = await this.getSvgElement(shapes, opts) if (!result) return undefined const serializer = new XMLSerializer() return { svg: serializer.serializeToString(result.svg), width: result.width, height: result.height, } } /** @deprecated Use {@link Editor.getSvgString} or {@link Editor.getSvgElement} instead. */ async getSvg(shapes: TLShapeId[] | TLShape[], opts = {} as Partial) { const result = await this.getSvgElement(shapes, opts) if (!result) return undefined return result.svg } /* --------------------- Events --------------------- */ /** * The app's current input state. * * @public */ inputs = { /** The most recent pointer down's position in the current page space. */ originPagePoint: new Vec(), /** The most recent pointer down's position in screen space. */ originScreenPoint: new Vec(), /** The previous pointer position in the current page space. */ previousPagePoint: new Vec(), /** The previous pointer position in screen space. */ previousScreenPoint: new Vec(), /** The most recent pointer position in the current page space. */ currentPagePoint: new Vec(), /** The most recent pointer position in screen space. */ currentScreenPoint: new Vec(), /** A set containing the currently pressed keys. */ keys: new Set(), /** A set containing the currently pressed buttons. */ buttons: new Set(), /** 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, /** Velocity of mouse pointer, in pixels per millisecond */ pointerVelocity: new Vec(), } /** * Update the input points from a pointer, pinch, or wheel event. * * @param info - The event info. */ private _updateInputsFromEvent( info: TLPointerEventInfo | TLPinchEventInfo | TLWheelEventInfo ): void { const { pointerVelocity, previousScreenPoint, previousPagePoint, currentScreenPoint, currentPagePoint, } = this.inputs const { screenBounds } = this.store.unsafeGetWithoutCapture(TLINSTANCE_ID)! const { x: cx, y: cy, z: cz } = this.store.unsafeGetWithoutCapture(this.getCameraId())! const sx = info.point.x - screenBounds.x const sy = info.point.y - screenBounds.y const sz = info.point.z ?? 0.5 previousScreenPoint.setTo(currentScreenPoint) previousPagePoint.setTo(currentPagePoint) // The "screen bounds" is relative to the user's actual screen. // The "screen point" is relative to the "screen bounds"; // it will be 0,0 when its actual screen position is equal // to screenBounds.point. This is confusing! currentScreenPoint.set(sx, sy) currentPagePoint.set(sx / cz - cx, sy / cz - cy, sz) this.inputs.isPen = info.type === 'pointer' && info.isPen // Reset velocity on pointer down, or when a pinch starts or ends if (info.name === 'pointer_down' || this.inputs.isPinching) { pointerVelocity.set(0, 0) } // 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: // If our pointer moved only because we're following some other user, then don't // update our last activity timestamp; otherwise, update it to the current timestamp. info.type === 'pointer' && info.pointerId === INTERNAL_POINTER_IDS.CAMERA_MOVE ? this.store.unsafeGetWithoutCapture(TLPOINTER_ID)?.lastActivityTimestamp ?? this._tickManager.now : this._tickManager.now, meta: {}, }, ]) } /** * Dispatch a cancel event. * * @example * ```ts * editor.cancel() * ``` * * @public */ cancel(): this { this.dispatch({ type: 'misc', name: 'cancel' }) return this } /** * Dispatch an interrupt event. * * @example * ```ts * editor.interrupt() * ``` * * @public */ interrupt(): this { this.dispatch({ type: 'misc', name: 'interrupt' }) return this } /** * Dispatch a complete event. * * @example * ```ts * editor.complete() * ``` * * @public */ complete(): this { this.dispatch({ type: 'misc', name: 'complete' }) return this } /** * 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: 'ControlLeft', }) } /** @internal */ private _restoreToolId = 'select' /** @internal */ private _pinchStart = 1 /** @internal */ private _didPinch = false /** @internal */ private _selectedShapeIdsAtPointerDown: TLShapeId[] = [] /** @internal */ private _longPressTimeout = -1 as any /** @internal */ capturedPointerId: number | null = null /** * Dispatch an event to the editor. * * @example * ```ts * editor.dispatch(myPointerEvent) * ``` * * @param info - The event info. * * @public */ dispatch = (info: TLEventInfo): this => { this._pendingEventsForNextTick.push(info) if ( !( (info.type === 'pointer' && info.name === 'pointer_move') || info.type === 'wheel' || info.type === 'pinch' ) ) { this._flushEventsForTick(0) } return this } private _pendingEventsForNextTick: TLEventInfo[] = [] private _flushEventsForTick(elapsed: number) { this.batch(() => { if (this._pendingEventsForNextTick.length > 0) { const events = [...this._pendingEventsForNextTick] this._pendingEventsForNextTick.length = 0 for (const info of events) { this._flushEventForTick(info) } } if (elapsed > 0) { this.root.handleEvent({ type: 'misc', name: 'tick', elapsed }) } this.scribbles.tick(elapsed) }) } private _flushEventForTick = (info: TLEventInfo) => { // prevent us from spamming similar event errors if we're crashed. // todo: replace with new readonly mode? if (this.getCrashingError()) return this const { inputs } = this const { type } = info 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.updateInstanceState({ cursor: { type: this._prevCursor, rotation: 0, }, }) } } 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.getInstanceState().canMoveCamera) return clearTimeout(this._longPressTimeout) this._updateInputsFromEvent(info) switch (info.name) { case 'pinch_start': { if (inputs.isPinching) return if (!inputs.isEditing) { this._pinchStart = this.getCamera().z if (!this._selectedShapeIdsAtPointerDown.length) { this._selectedShapeIdsAtPointerDown = this.getSelectedShapeIds() } this._didPinch = true inputs.isPinching = true this.interrupt() } return // Stop here! } case 'pinch': { if (!inputs.isPinching) return const { point: { z = 1 }, delta: { x: dx, y: dy }, } = info const { screenBounds } = this.store.unsafeGetWithoutCapture(TLINSTANCE_ID)! const { x, y } = Vec.SubXY(info.point, screenBounds.x, screenBounds.y) const { x: cx, y: cy, z: cz } = this.getCamera() const zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z)) this.stopCameraAnimation() if (this.getInstanceState().followingUserId) { this.stopFollowingUser() } this._setCamera( { x: cx + dx / cz - x / cz + x / zoom, y: cy + dy / cz - y / cz + y / zoom, z: zoom, }, true ) return // Stop here! } case 'pinch_end': { if (!inputs.isPinching) return this inputs.isPinching = false const { _selectedShapeIdsAtPointerDown } = this this.setSelectedShapes(this._selectedShapeIdsAtPointerDown, { squashing: true }) this._selectedShapeIdsAtPointerDown = [] if (this._didPinch) { this._didPinch = false this.once('tick', () => { if (!this._didPinch) { this.setSelectedShapes(_selectedShapeIdsAtPointerDown, { squashing: true }) } }) } return // Stop here! } } } case 'wheel': { if (!this.getInstanceState().canMoveCamera) return this._updateInputsFromEvent(info) if (this.getIsMenuOpen()) { // noop } else { this.stopCameraAnimation() if (this.getInstanceState().followingUserId) { this.stopFollowingUser() } 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. // Subtract the top left offset from the user's point const { x, y } = this.inputs.currentScreenPoint const { x: cx, y: cy, z: cz } = this.getCamera() const zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, cz + (info.delta.z ?? 0) * cz)) this._setCamera( { x: cx + (x / zoom - x) - (x / cz - x), y: cy + (y / zoom - y) - (y / cz - y), z: zoom, }, true ) // 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 const { x: cx, y: cy, z: cz } = this.getCamera() this._setCamera({ x: cx + info.delta.x / cz, y: cy + info.delta.y / cz, z: cz }, true) if ( !inputs.isDragging && inputs.isPointing && Vec.Dist2(originPagePoint, currentPagePoint) > (this.getInstanceState().isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE) / this.getZoomLevel() ) { clearTimeout(this._longPressTimeout) 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.clearOpenMenus() this._longPressTimeout = setTimeout(() => { this.dispatch({ ...info, name: 'long_press' }) }, LONG_PRESS_DURATION) this._selectedShapeIdsAtPointerDown = this.getSelectedShapeIds() // 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.getInstanceState().isPenMode) { if (!isPen) { return } } else { if (isPen) { this.updateInstanceState({ isPenMode: true }) } } if (info.button === 5) { // Eraser button activates eraser this._restoreToolId = this.getCurrentToolId() this.complete() this.setCurrentTool('eraser') } else if (info.button === 1) { // Middle mouse pan activates panning if (!this.inputs.isPanning) { this._prevCursor = this.getInstanceState().cursor.type } this.inputs.isPanning = true } if (this.inputs.isPanning) { this.stopCameraAnimation() this.setCursor({ type: 'grabbing', rotation: 0 }) 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.getInstanceState().isPenMode) { return } if (this.inputs.isPanning && this.inputs.isPointing) { clearTimeout(this._longPressTimeout) // Handle panning const { currentScreenPoint, previousScreenPoint } = this.inputs this.pan(Vec.Sub(currentScreenPoint, previousScreenPoint)) return } if ( !inputs.isDragging && inputs.isPointing && Vec.Dist2(originPagePoint, currentPagePoint) > (this.getInstanceState().isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE) / this.getZoomLevel() ) { clearTimeout(this._longPressTimeout) 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.getIsMenuOpen()) { // Suppressing pointerup here as doesn't seem to do what we what here. return } if (!isPen && this.getInstanceState().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: CAMERA_SLIDE_FRICTION, }) this.setCursor({ type: this._prevCursor, rotation: 0 }) } else { this.slideCamera({ speed: Math.min(2, this.inputs.pointerVelocity.len()), direction: this.inputs.pointerVelocity, friction: CAMERA_SLIDE_FRICTION, }) this.setCursor({ type: 'grab', rotation: 0, }) } } else if (info.button === 0) { this.slideCamera({ speed: Math.min(2, this.inputs.pointerVelocity.len()), direction: this.inputs.pointerVelocity, friction: CAMERA_SLIDE_FRICTION, }) this.setCursor({ type: 'grab', rotation: 0, }) } } else { if (info.button === 5) { // Eraser button activates eraser this.complete() this.setCurrentTool(this._restoreToolId) } } break } } break } case 'keyboard': { // please, please if (info.key === 'ShiftRight') info.key = 'ShiftLeft' if (info.key === 'AltRight') info.key = 'AltLeft' if (info.code === 'ControlRight') info.code = 'ControlLeft' 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.getInstanceState().cursor.type } this.inputs.isPanning = true this.setCursor({ type: this.inputs.isPointing ? 'grabbing' : 'grab', rotation: 0 }) } 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, rotation: 0 }) } break } case 'key_repeat': { // noop 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.getInstanceState().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': { clearTimeout(this._longPressTimeout) 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 } } function alertMaxShapes(editor: Editor, pageId = editor.getCurrentPageId()) { const name = editor.getPage(pageId)!.name editor.emit('max-shapes', { name, pageId, count: MAX_SHAPES_PER_PAGE }) } function applyPartialToShape(prev: T, partial?: TLShapePartial): T { if (!partial) return prev let next = null as null | T const entries = Object.entries(partial) for (let i = 0, n = entries.length; i < n; i++) { const [k, v] = entries[i] if (v === undefined) continue // Is the key a special key? We don't update those if (k === 'id' || k === 'type' || k === 'typeName') continue // Is the value the same as it was before? if (v === (prev as any)[k]) continue // There's a new value, so create the new shape if we haven't already (should we be cloning this?) if (!next) next = { ...prev } // for props / meta properties, we support updates with partials of this object if (k === 'props' || k === 'meta') { next[k] = { ...prev[k] } as JsonObject for (const [nextKey, nextValue] of Object.entries(v as object)) { if (nextValue !== undefined) { ;(next[k] as JsonObject)[nextKey] = nextValue } } continue } // base property ;(next as any)[k] = v } if (!next) return prev return next } function pushShapeWithDescendants( shape: TLShape, parentChildMap: Map, result: TLShape[] ): void { result.push(shape) const children = parentChildMap.get(shape.id) if (children) { for (let i = 0, n = children.length; i < n; i++) { pushShapeWithDescendants(children[i], parentChildMap, result) } } }