Tldraw/packages/editor/src/lib/app/App.ts

9082 wiersze
226 KiB
TypeScript
Czysty Zwykły widok Historia

2023-04-25 11:01:25 +00:00
import {
getIndexAbove,
getIndexBetween,
getIndices,
getIndicesAbove,
getIndicesBetween,
sortByIndex,
} from '@tldraw/indices'
import {
2023-04-25 11:01:25 +00:00
Box2d,
EASINGS,
MatLike,
Matrix2d,
Matrix2dModel,
PI2,
Vec2d,
VecLike,
approximately,
areAnglesCompatible,
clamp,
intersectPolygonPolygon,
pointInPolygon,
2023-04-25 11:01:25 +00:00
} from '@tldraw/primitives'
import {
Box2dModel,
CameraRecordType,
InstancePageStateRecordType,
PageRecordType,
2023-04-25 11:01:25 +00:00
TLArrowShape,
TLAsset,
TLAssetId,
TLAssetPartial,
TLColorStyle,
TLColorType,
TLCursor,
TLCursorType,
TLDOCUMENT_ID,
Add support for project names (#1340) This PR adds some things that we need for the Project Name feature on tldraw.com. It should be reviewed alongside https://github.com/tldraw/tldraw-lite/pull/1814 ## Name Property This PR adds a `name` property to `TLDocument`. We use this to store a project's name. <img width="454" alt="Screenshot 2023-05-09 at 15 47 26" src="https://github.com/tldraw/tldraw/assets/15892272/f3be438e-aa0f-4dec-8f51-8dfd9f9d0ced"> ## Top Zone This PR adds a `topZone` area of the UI that we can add stuff to, similar to how `shareZone` works. It also adds an example to show where the `topZone` and `shareZone` are: <img width="1511" alt="Screenshot 2023-05-12 at 10 57 40" src="https://github.com/tldraw/tldraw/assets/15892272/f5e1cd33-017e-4aaf-bfee-4d85119e2974"> ## Breakpoints This PR change's the UI's breakpoints a little bit. It moves the action bar to the bottom a little bit earlier. (This gives us more space at the top for the project name). ![2023-05-12 at 11 08 26 - Fuchsia Bison](https://github.com/tldraw/tldraw/assets/15892272/34563cea-b1d1-47be-ac5e-5650ee0ba02d) ![2023-05-12 at 13 45 04 - Tan Mole](https://github.com/tldraw/tldraw/assets/15892272/ab190bd3-51d4-4a8b-88de-c72ab14bcba6) ## Input Blur This PR adds an `onBlur` parameter to `Input`. This was needed because 'clicking off' the input wasn't firing `onComplete` or `onCancel`. <img width="620" alt="Screenshot 2023-05-09 at 16 12 58" src="https://github.com/tldraw/tldraw/assets/15892272/3b28da74-0a74-4063-8053-e59e47027caf"> ## Create Project Name This PR adds an internal `createProjectName` property to `TldrawEditorConfig`. Similar to `derivePresenceState`, you can pass a custom function to it. It lets you control what gets used as the default project name. We use it to set different names in our local projects compared to shared projects. In the future, when we add more advanced project features, we could handle this better within the UI. <img width="454" alt="Screenshot 2023-05-09 at 15 47 26" src="https://github.com/tldraw/tldraw/assets/15892272/da9a4699-ac32-40d9-a97c-6c682acfac41"> ### Test Plan 1. Gradually reduce the width of the browser window. 2. Check that the actions menu jumps to the bottom before the style panel moves to the bottom. --- 1. In the examples app, open the `/zones` example. 2. Check that there's a 'top zone' at the top. - [ ] Unit Tests - [ ] Webdriver tests ### Release Note - [dev] Added a `topZone` area where you can put stuff. - [dev] Added a `name` property to `TLDocument` - and `app` methods for it. - [dev] Added an internal `createProjectName` config property for controlling the default project name. - [dev] Added an `onBlur` parameter to `Input`. - Moved the actions bar to the bottom on medium-sized screens. --------- Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
2023-06-01 18:46:26 +00:00
TLDocument,
2023-04-25 11:01:25 +00:00
TLFrameShape,
TLGroupShape,
TLImageAsset,
TLInstance,
TLInstanceId,
TLInstancePageState,
TLNullableShapeProps,
TLPOINTER_ID,
2023-04-25 11:01:25 +00:00
TLPage,
TLPageId,
TLParentId,
TLRecord,
TLScribble,
TLShape,
TLShapeId,
TLShapePartial,
TLShapeProp,
TLSizeStyle,
TLStore,
TLUnknownShape,
TLUserDocument,
TLVideoAsset,
Vec2dModel,
createCustomShapeId,
createShapeId,
isPageId,
isShape,
isShapeId,
2023-04-25 11:01:25 +00:00
} from '@tldraw/tlschema'
[refactor] User-facing APIs (#1478) This PR updates our user-facing APIs for the Tldraw and TldrawEditor components, as well as the Editor (App). It mainly incorporates surface changes from #1450 without any changes to validators or migrators, incorporating feedback / discussion with @SomeHats and @ds300. Here we: - remove the TldrawEditorConfig - bring back a loose version of shape definitions - make a separation between "core" shapes and "default" shapes - do not allow custom shapes, migrators or validators to overwrite core shapes - but _do_ allow new shapes ## `<Tldraw>` component In this PR, the `Tldraw` component wraps both the `TldrawEditor` component and our `TldrawUi` component. It accepts a union of props for both components. Previously, this component also added local syncing via a `useLocalSyncClient` hook call, however that has been pushed down to the `TldrawEditor` component. ## `<TldrawEditor>` component The `TldrawEditor` component now more neatly wraps up the different ways that the editor can be configured. ## The store prop (`TldrawEditorProps.store`) There are three main ways for the `TldrawEditor` component to be run: 1. with an externally defined store 2. with an externally defined syncing store (local or remote) 3. with an internally defined store 4. with an internally defined locally syncing store The `store` prop allows for these configurations. If the `store` prop is defined, it may be defined either as a `TLStore` or as a `SyncedStore`. If the store is a `TLStore`, then the Editor will assume that the store is ready to go; if it is defined as a SyncedStore, then the component will display the loading / error screens as needed, or the final editor once the store's status is "synced". When the store is left undefined, then the `TldrawEditor` will create its own internal store using the optional `instanceId`, `initialData`, or `shapes` props to define the store / store schema. If the `persistenceKey` prop is left undefined, then the store will not be synced. If the `persistenceKey` is defined, then the store will be synced locally. In the future, we may also here accept the API key / roomId / etc for creating a remotely synced store. The `SyncedStore` type has been expanded to also include types used for remote syncing, e.g. with `ConnectionStatus`. ## Tools By default, the App has two "baked-in" tools: the select tool and the zoom tool. These cannot (for now) be replaced or removed. The default tools are used by default, but may be replaced by other tools if provided. ## Shapes By default, the App has a set of "core" shapes: - group - embed - bookmark - image - video - text That cannot by overwritten because they're created by the app at different moments, such as when double clicking on the canvas or via a copy and paste event. In follow up PRs, we'll split these out so that users can replace parts of the code where these shapes are created. ### Change Type - [x] `major` — Breaking Change ### Test Plan - [x] Unit Tests
2023-06-01 15:47:34 +00:00
import { ComputedCache, HistoryEntry, RecordType, UnknownRecord } from '@tldraw/tlstore'
import {
annotateError,
compact,
dedupe,
deepCopy,
partition,
sortById,
structuredClone,
} from '@tldraw/utils'
2023-04-25 11:01:25 +00:00
import { EventEmitter } from 'eventemitter3'
import { nanoid } from 'nanoid'
import { EMPTY_ARRAY, atom, computed, transact } from 'signia'
[refactor] User-facing APIs (#1478) This PR updates our user-facing APIs for the Tldraw and TldrawEditor components, as well as the Editor (App). It mainly incorporates surface changes from #1450 without any changes to validators or migrators, incorporating feedback / discussion with @SomeHats and @ds300. Here we: - remove the TldrawEditorConfig - bring back a loose version of shape definitions - make a separation between "core" shapes and "default" shapes - do not allow custom shapes, migrators or validators to overwrite core shapes - but _do_ allow new shapes ## `<Tldraw>` component In this PR, the `Tldraw` component wraps both the `TldrawEditor` component and our `TldrawUi` component. It accepts a union of props for both components. Previously, this component also added local syncing via a `useLocalSyncClient` hook call, however that has been pushed down to the `TldrawEditor` component. ## `<TldrawEditor>` component The `TldrawEditor` component now more neatly wraps up the different ways that the editor can be configured. ## The store prop (`TldrawEditorProps.store`) There are three main ways for the `TldrawEditor` component to be run: 1. with an externally defined store 2. with an externally defined syncing store (local or remote) 3. with an internally defined store 4. with an internally defined locally syncing store The `store` prop allows for these configurations. If the `store` prop is defined, it may be defined either as a `TLStore` or as a `SyncedStore`. If the store is a `TLStore`, then the Editor will assume that the store is ready to go; if it is defined as a SyncedStore, then the component will display the loading / error screens as needed, or the final editor once the store's status is "synced". When the store is left undefined, then the `TldrawEditor` will create its own internal store using the optional `instanceId`, `initialData`, or `shapes` props to define the store / store schema. If the `persistenceKey` prop is left undefined, then the store will not be synced. If the `persistenceKey` is defined, then the store will be synced locally. In the future, we may also here accept the API key / roomId / etc for creating a remotely synced store. The `SyncedStore` type has been expanded to also include types used for remote syncing, e.g. with `ConnectionStatus`. ## Tools By default, the App has two "baked-in" tools: the select tool and the zoom tool. These cannot (for now) be replaced or removed. The default tools are used by default, but may be replaced by other tools if provided. ## Shapes By default, the App has a set of "core" shapes: - group - embed - bookmark - image - video - text That cannot by overwritten because they're created by the app at different moments, such as when double clicking on the canvas or via a copy and paste event. In follow up PRs, we'll split these out so that users can replace parts of the code where these shapes are created. ### Change Type - [x] `major` — Breaking Change ### Test Plan - [x] Unit Tests
2023-06-01 15:47:34 +00:00
import { ShapeInfo } from '../config/createTLStore'
import { TLUser, createTLUser } from '../config/createTLUser'
import { coreShapes, defaultShapes } from '../config/defaultShapes'
import { defaultTools } from '../config/defaultTools'
2023-04-25 11:01:25 +00:00
import {
ANIMATION_MEDIUM_MS,
BLACKLISTED_PROPS,
COARSE_DRAG_DISTANCE,
2023-04-25 11:01:25 +00:00
DEFAULT_ANIMATION_OPTIONS,
DRAG_DISTANCE,
FOLLOW_CHASE_PAN_SNAP,
FOLLOW_CHASE_PAN_UNSNAP,
FOLLOW_CHASE_PROPORTION,
FOLLOW_CHASE_ZOOM_SNAP,
FOLLOW_CHASE_ZOOM_UNSNAP,
GRID_INCREMENT,
HAND_TOOL_FRICTION,
MAJOR_NUDGE_FACTOR,
MAX_PAGES,
MAX_SHAPES_PER_PAGE,
MAX_ZOOM,
MINOR_NUDGE_FACTOR,
MIN_ZOOM,
2023-04-25 11:01:25 +00:00
STYLES,
SVG_PADDING,
ZOOMS,
} from '../constants'
import { exportPatternSvgDefs } from '../hooks/usePattern'
import { WeakMapCache } from '../utils/WeakMapCache'
2023-04-25 11:01:25 +00:00
import { dataUrlToFile, getMediaAssetFromFile } from '../utils/assets'
import { getIncrementedName, uniqueId } from '../utils/data'
import { setPropsForNextShape } from '../utils/props-for-next-shape'
import { applyRotationToSnapshotShapes, getRotationSnapshot } from '../utils/rotation'
import { arrowBindingsIndex } from './derivations/arrowBindingsIndex'
import { parentsToChildrenWithIndexes } from './derivations/parentsToChildrenWithIndexes'
import { shapeIdsInCurrentPage } from './derivations/shapeIdsInCurrentPage'
import { ActiveAreaManager, getActiveAreaScreenSpace } from './managers/ActiveAreaManager'
import { CameraManager } from './managers/CameraManager'
import { ClickManager } from './managers/ClickManager'
import { DprManager } from './managers/DprManager'
import { HistoryManager } from './managers/HistoryManager'
import { SnapManager } from './managers/SnapManager'
import { TextManager } from './managers/TextManager'
import { TickManager } from './managers/TickManager'
import { UserPreferencesManager } from './managers/UserPreferencesManager'
import { TLArrowUtil } from './shapeutils/TLArrowUtil/TLArrowUtil'
2023-04-25 11:01:25 +00:00
import { getCurvedArrowInfo } from './shapeutils/TLArrowUtil/arrow/curved-arrow'
import {
getArrowTerminalsInArrowSpace,
getIsArrowStraight,
} from './shapeutils/TLArrowUtil/arrow/shared'
import { getStraightArrowInfo } from './shapeutils/TLArrowUtil/arrow/straight-arrow'
import { TLFrameUtil } from './shapeutils/TLFrameUtil/TLFrameUtil'
import { TLGroupUtil } from './shapeutils/TLGroupUtil/TLGroupUtil'
2023-04-25 11:01:25 +00:00
import { TLResizeMode, TLShapeUtil } from './shapeutils/TLShapeUtil'
import { TLTextUtil } from './shapeutils/TLTextUtil/TLTextUtil'
import { TLExportColors } from './shapeutils/shared/TLExportColors'
2023-04-25 11:01:25 +00:00
import { RootState } from './statechart/RootState'
[refactor] User-facing APIs (#1478) This PR updates our user-facing APIs for the Tldraw and TldrawEditor components, as well as the Editor (App). It mainly incorporates surface changes from #1450 without any changes to validators or migrators, incorporating feedback / discussion with @SomeHats and @ds300. Here we: - remove the TldrawEditorConfig - bring back a loose version of shape definitions - make a separation between "core" shapes and "default" shapes - do not allow custom shapes, migrators or validators to overwrite core shapes - but _do_ allow new shapes ## `<Tldraw>` component In this PR, the `Tldraw` component wraps both the `TldrawEditor` component and our `TldrawUi` component. It accepts a union of props for both components. Previously, this component also added local syncing via a `useLocalSyncClient` hook call, however that has been pushed down to the `TldrawEditor` component. ## `<TldrawEditor>` component The `TldrawEditor` component now more neatly wraps up the different ways that the editor can be configured. ## The store prop (`TldrawEditorProps.store`) There are three main ways for the `TldrawEditor` component to be run: 1. with an externally defined store 2. with an externally defined syncing store (local or remote) 3. with an internally defined store 4. with an internally defined locally syncing store The `store` prop allows for these configurations. If the `store` prop is defined, it may be defined either as a `TLStore` or as a `SyncedStore`. If the store is a `TLStore`, then the Editor will assume that the store is ready to go; if it is defined as a SyncedStore, then the component will display the loading / error screens as needed, or the final editor once the store's status is "synced". When the store is left undefined, then the `TldrawEditor` will create its own internal store using the optional `instanceId`, `initialData`, or `shapes` props to define the store / store schema. If the `persistenceKey` prop is left undefined, then the store will not be synced. If the `persistenceKey` is defined, then the store will be synced locally. In the future, we may also here accept the API key / roomId / etc for creating a remotely synced store. The `SyncedStore` type has been expanded to also include types used for remote syncing, e.g. with `ConnectionStatus`. ## Tools By default, the App has two "baked-in" tools: the select tool and the zoom tool. These cannot (for now) be replaced or removed. The default tools are used by default, but may be replaced by other tools if provided. ## Shapes By default, the App has a set of "core" shapes: - group - embed - bookmark - image - video - text That cannot by overwritten because they're created by the app at different moments, such as when double clicking on the canvas or via a copy and paste event. In follow up PRs, we'll split these out so that users can replace parts of the code where these shapes are created. ### Change Type - [x] `major` — Breaking Change ### Test Plan - [x] Unit Tests
2023-06-01 15:47:34 +00:00
import { StateNode, StateNodeConstructor } from './statechart/StateNode'
2023-04-25 11:01:25 +00:00
import { TLClipboardModel } from './types/clipboard-types'
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
import { TLEventMap } from './types/emit-types'
2023-04-25 11:01:25 +00:00
import { TLEventInfo, TLPinchEventInfo, TLPointerEventInfo } from './types/event-types'
import { RequiredKeys } from './types/misc-types'
import { TLResizeHandle } from './types/selection-types'
/** @public */
export type TLChange<T extends UnknownRecord = any> = HistoryEntry<T>
2023-04-25 11:01:25 +00:00
/** @public */
export type AnimationOptions = Partial<{
duration: number
easing: typeof EASINGS.easeInOutCubic
}>
/** @public */
export type ViewportOptions = Partial<{
/** Whether to animate the viewport change or not. Defaults to true. */
stopFollowing: boolean
}>
/** @public */
export interface AppOptions {
/**
* The Store instance to use for keeping the app's data. This may be prepopulated, e.g. by loading
* from a server or database.
*/
store: TLStore
[refactor] User-facing APIs (#1478) This PR updates our user-facing APIs for the Tldraw and TldrawEditor components, as well as the Editor (App). It mainly incorporates surface changes from #1450 without any changes to validators or migrators, incorporating feedback / discussion with @SomeHats and @ds300. Here we: - remove the TldrawEditorConfig - bring back a loose version of shape definitions - make a separation between "core" shapes and "default" shapes - do not allow custom shapes, migrators or validators to overwrite core shapes - but _do_ allow new shapes ## `<Tldraw>` component In this PR, the `Tldraw` component wraps both the `TldrawEditor` component and our `TldrawUi` component. It accepts a union of props for both components. Previously, this component also added local syncing via a `useLocalSyncClient` hook call, however that has been pushed down to the `TldrawEditor` component. ## `<TldrawEditor>` component The `TldrawEditor` component now more neatly wraps up the different ways that the editor can be configured. ## The store prop (`TldrawEditorProps.store`) There are three main ways for the `TldrawEditor` component to be run: 1. with an externally defined store 2. with an externally defined syncing store (local or remote) 3. with an internally defined store 4. with an internally defined locally syncing store The `store` prop allows for these configurations. If the `store` prop is defined, it may be defined either as a `TLStore` or as a `SyncedStore`. If the store is a `TLStore`, then the Editor will assume that the store is ready to go; if it is defined as a SyncedStore, then the component will display the loading / error screens as needed, or the final editor once the store's status is "synced". When the store is left undefined, then the `TldrawEditor` will create its own internal store using the optional `instanceId`, `initialData`, or `shapes` props to define the store / store schema. If the `persistenceKey` prop is left undefined, then the store will not be synced. If the `persistenceKey` is defined, then the store will be synced locally. In the future, we may also here accept the API key / roomId / etc for creating a remotely synced store. The `SyncedStore` type has been expanded to also include types used for remote syncing, e.g. with `ConnectionStatus`. ## Tools By default, the App has two "baked-in" tools: the select tool and the zoom tool. These cannot (for now) be replaced or removed. The default tools are used by default, but may be replaced by other tools if provided. ## Shapes By default, the App has a set of "core" shapes: - group - embed - bookmark - image - video - text That cannot by overwritten because they're created by the app at different moments, such as when double clicking on the canvas or via a copy and paste event. In follow up PRs, we'll split these out so that users can replace parts of the code where these shapes are created. ### Change Type - [x] `major` — Breaking Change ### Test Plan - [x] Unit Tests
2023-06-01 15:47:34 +00:00
/**
* An array of shapes to use in the app. These will be used to create and manage shapes in the app.
*/
shapes?: Record<string, ShapeInfo>
/**
* An array of tools to use in the app. These will be used to handle events and manage user interactions in the app.
*/
tools?: StateNodeConstructor[]
/**
* A user defined externally to replace the default user.
*/
user?: TLUser
2023-04-25 11:01:25 +00:00
/**
* Should return a containing html element which has all the styles applied to the app. If not
* given, the body element will be used.
*/
getContainer: () => HTMLElement
}
/** @public */
export function isShapeWithHandles(shape: TLShape) {
return shape.type === 'arrow' || shape.type === 'line' || shape.type === 'draw'
}
/** @public */
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
export class App extends EventEmitter<TLEventMap> {
[refactor] User-facing APIs (#1478) This PR updates our user-facing APIs for the Tldraw and TldrawEditor components, as well as the Editor (App). It mainly incorporates surface changes from #1450 without any changes to validators or migrators, incorporating feedback / discussion with @SomeHats and @ds300. Here we: - remove the TldrawEditorConfig - bring back a loose version of shape definitions - make a separation between "core" shapes and "default" shapes - do not allow custom shapes, migrators or validators to overwrite core shapes - but _do_ allow new shapes ## `<Tldraw>` component In this PR, the `Tldraw` component wraps both the `TldrawEditor` component and our `TldrawUi` component. It accepts a union of props for both components. Previously, this component also added local syncing via a `useLocalSyncClient` hook call, however that has been pushed down to the `TldrawEditor` component. ## `<TldrawEditor>` component The `TldrawEditor` component now more neatly wraps up the different ways that the editor can be configured. ## The store prop (`TldrawEditorProps.store`) There are three main ways for the `TldrawEditor` component to be run: 1. with an externally defined store 2. with an externally defined syncing store (local or remote) 3. with an internally defined store 4. with an internally defined locally syncing store The `store` prop allows for these configurations. If the `store` prop is defined, it may be defined either as a `TLStore` or as a `SyncedStore`. If the store is a `TLStore`, then the Editor will assume that the store is ready to go; if it is defined as a SyncedStore, then the component will display the loading / error screens as needed, or the final editor once the store's status is "synced". When the store is left undefined, then the `TldrawEditor` will create its own internal store using the optional `instanceId`, `initialData`, or `shapes` props to define the store / store schema. If the `persistenceKey` prop is left undefined, then the store will not be synced. If the `persistenceKey` is defined, then the store will be synced locally. In the future, we may also here accept the API key / roomId / etc for creating a remotely synced store. The `SyncedStore` type has been expanded to also include types used for remote syncing, e.g. with `ConnectionStatus`. ## Tools By default, the App has two "baked-in" tools: the select tool and the zoom tool. These cannot (for now) be replaced or removed. The default tools are used by default, but may be replaced by other tools if provided. ## Shapes By default, the App has a set of "core" shapes: - group - embed - bookmark - image - video - text That cannot by overwritten because they're created by the app at different moments, such as when double clicking on the canvas or via a copy and paste event. In follow up PRs, we'll split these out so that users can replace parts of the code where these shapes are created. ### Change Type - [x] `major` — Breaking Change ### Test Plan - [x] Unit Tests
2023-06-01 15:47:34 +00:00
constructor({
store,
user,
tools = defaultTools,
shapes = defaultShapes,
getContainer,
}: AppOptions) {
2023-04-25 11:01:25 +00:00
super()
this.store = store
[refactor] User-facing APIs (#1478) This PR updates our user-facing APIs for the Tldraw and TldrawEditor components, as well as the Editor (App). It mainly incorporates surface changes from #1450 without any changes to validators or migrators, incorporating feedback / discussion with @SomeHats and @ds300. Here we: - remove the TldrawEditorConfig - bring back a loose version of shape definitions - make a separation between "core" shapes and "default" shapes - do not allow custom shapes, migrators or validators to overwrite core shapes - but _do_ allow new shapes ## `<Tldraw>` component In this PR, the `Tldraw` component wraps both the `TldrawEditor` component and our `TldrawUi` component. It accepts a union of props for both components. Previously, this component also added local syncing via a `useLocalSyncClient` hook call, however that has been pushed down to the `TldrawEditor` component. ## `<TldrawEditor>` component The `TldrawEditor` component now more neatly wraps up the different ways that the editor can be configured. ## The store prop (`TldrawEditorProps.store`) There are three main ways for the `TldrawEditor` component to be run: 1. with an externally defined store 2. with an externally defined syncing store (local or remote) 3. with an internally defined store 4. with an internally defined locally syncing store The `store` prop allows for these configurations. If the `store` prop is defined, it may be defined either as a `TLStore` or as a `SyncedStore`. If the store is a `TLStore`, then the Editor will assume that the store is ready to go; if it is defined as a SyncedStore, then the component will display the loading / error screens as needed, or the final editor once the store's status is "synced". When the store is left undefined, then the `TldrawEditor` will create its own internal store using the optional `instanceId`, `initialData`, or `shapes` props to define the store / store schema. If the `persistenceKey` prop is left undefined, then the store will not be synced. If the `persistenceKey` is defined, then the store will be synced locally. In the future, we may also here accept the API key / roomId / etc for creating a remotely synced store. The `SyncedStore` type has been expanded to also include types used for remote syncing, e.g. with `ConnectionStatus`. ## Tools By default, the App has two "baked-in" tools: the select tool and the zoom tool. These cannot (for now) be replaced or removed. The default tools are used by default, but may be replaced by other tools if provided. ## Shapes By default, the App has a set of "core" shapes: - group - embed - bookmark - image - video - text That cannot by overwritten because they're created by the app at different moments, such as when double clicking on the canvas or via a copy and paste event. In follow up PRs, we'll split these out so that users can replace parts of the code where these shapes are created. ### Change Type - [x] `major` — Breaking Change ### Test Plan - [x] Unit Tests
2023-06-01 15:47:34 +00:00
this.user = new UserPreferencesManager(user ?? createTLUser())
2023-04-25 11:01:25 +00:00
this.getContainer = getContainer ?? (() => document.body)
this.textMeasure = new TextManager(this)
[refactor] User-facing APIs (#1478) This PR updates our user-facing APIs for the Tldraw and TldrawEditor components, as well as the Editor (App). It mainly incorporates surface changes from #1450 without any changes to validators or migrators, incorporating feedback / discussion with @SomeHats and @ds300. Here we: - remove the TldrawEditorConfig - bring back a loose version of shape definitions - make a separation between "core" shapes and "default" shapes - do not allow custom shapes, migrators or validators to overwrite core shapes - but _do_ allow new shapes ## `<Tldraw>` component In this PR, the `Tldraw` component wraps both the `TldrawEditor` component and our `TldrawUi` component. It accepts a union of props for both components. Previously, this component also added local syncing via a `useLocalSyncClient` hook call, however that has been pushed down to the `TldrawEditor` component. ## `<TldrawEditor>` component The `TldrawEditor` component now more neatly wraps up the different ways that the editor can be configured. ## The store prop (`TldrawEditorProps.store`) There are three main ways for the `TldrawEditor` component to be run: 1. with an externally defined store 2. with an externally defined syncing store (local or remote) 3. with an internally defined store 4. with an internally defined locally syncing store The `store` prop allows for these configurations. If the `store` prop is defined, it may be defined either as a `TLStore` or as a `SyncedStore`. If the store is a `TLStore`, then the Editor will assume that the store is ready to go; if it is defined as a SyncedStore, then the component will display the loading / error screens as needed, or the final editor once the store's status is "synced". When the store is left undefined, then the `TldrawEditor` will create its own internal store using the optional `instanceId`, `initialData`, or `shapes` props to define the store / store schema. If the `persistenceKey` prop is left undefined, then the store will not be synced. If the `persistenceKey` is defined, then the store will be synced locally. In the future, we may also here accept the API key / roomId / etc for creating a remotely synced store. The `SyncedStore` type has been expanded to also include types used for remote syncing, e.g. with `ConnectionStatus`. ## Tools By default, the App has two "baked-in" tools: the select tool and the zoom tool. These cannot (for now) be replaced or removed. The default tools are used by default, but may be replaced by other tools if provided. ## Shapes By default, the App has a set of "core" shapes: - group - embed - bookmark - image - video - text That cannot by overwritten because they're created by the app at different moments, such as when double clicking on the canvas or via a copy and paste event. In follow up PRs, we'll split these out so that users can replace parts of the code where these shapes are created. ### Change Type - [x] `major` — Breaking Change ### Test Plan - [x] Unit Tests
2023-06-01 15:47:34 +00:00
this.root = new RootState(this)
// Shapes.
// Accept shapes from constructor parameters which may not conflict with the root note's core tools.
const shapeUtils = Object.fromEntries(
Object.values(coreShapes).map(({ util: Util }) => [Util.type, new Util(this, Util.type)])
2023-04-25 11:01:25 +00:00
)
[refactor] User-facing APIs (#1478) This PR updates our user-facing APIs for the Tldraw and TldrawEditor components, as well as the Editor (App). It mainly incorporates surface changes from #1450 without any changes to validators or migrators, incorporating feedback / discussion with @SomeHats and @ds300. Here we: - remove the TldrawEditorConfig - bring back a loose version of shape definitions - make a separation between "core" shapes and "default" shapes - do not allow custom shapes, migrators or validators to overwrite core shapes - but _do_ allow new shapes ## `<Tldraw>` component In this PR, the `Tldraw` component wraps both the `TldrawEditor` component and our `TldrawUi` component. It accepts a union of props for both components. Previously, this component also added local syncing via a `useLocalSyncClient` hook call, however that has been pushed down to the `TldrawEditor` component. ## `<TldrawEditor>` component The `TldrawEditor` component now more neatly wraps up the different ways that the editor can be configured. ## The store prop (`TldrawEditorProps.store`) There are three main ways for the `TldrawEditor` component to be run: 1. with an externally defined store 2. with an externally defined syncing store (local or remote) 3. with an internally defined store 4. with an internally defined locally syncing store The `store` prop allows for these configurations. If the `store` prop is defined, it may be defined either as a `TLStore` or as a `SyncedStore`. If the store is a `TLStore`, then the Editor will assume that the store is ready to go; if it is defined as a SyncedStore, then the component will display the loading / error screens as needed, or the final editor once the store's status is "synced". When the store is left undefined, then the `TldrawEditor` will create its own internal store using the optional `instanceId`, `initialData`, or `shapes` props to define the store / store schema. If the `persistenceKey` prop is left undefined, then the store will not be synced. If the `persistenceKey` is defined, then the store will be synced locally. In the future, we may also here accept the API key / roomId / etc for creating a remotely synced store. The `SyncedStore` type has been expanded to also include types used for remote syncing, e.g. with `ConnectionStatus`. ## Tools By default, the App has two "baked-in" tools: the select tool and the zoom tool. These cannot (for now) be replaced or removed. The default tools are used by default, but may be replaced by other tools if provided. ## Shapes By default, the App has a set of "core" shapes: - group - embed - bookmark - image - video - text That cannot by overwritten because they're created by the app at different moments, such as when double clicking on the canvas or via a copy and paste event. In follow up PRs, we'll split these out so that users can replace parts of the code where these shapes are created. ### Change Type - [x] `major` — Breaking Change ### Test Plan - [x] Unit Tests
2023-06-01 15:47:34 +00:00
for (const [type, { util: Util }] of Object.entries(shapes)) {
if (shapeUtils[type]) {
throw Error(`May not overwrite core shape of type "${type}".`)
}
if (type !== Util.type) {
throw Error(`Shape util's type "${Util.type}" does not match provided type "${type}".`)
}
shapeUtils[type] = new Util(this, Util.type)
}
this.shapeUtils = shapeUtils
// Tools.
// Accept tools from constructor parameters which may not conflict with the root note's default or
// "baked in" tools, select and zoom.
const uniqueTools = Object.fromEntries(tools.map((Ctor) => [Ctor.id, Ctor]))
for (const [id, Ctor] of Object.entries(uniqueTools)) {
if (this.root.children?.[id]) {
throw Error(`Can't override tool with id "${id}"`)
}
this.root.children![id] = new Ctor(this)
}
2023-04-25 11:01:25 +00:00
if (typeof window !== 'undefined' && 'navigator' in window) {
this.isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
this.isIos = !!navigator.userAgent.match(/iPad/i) || !!navigator.userAgent.match(/iPhone/i)
this.isChromeForIos = /crios.*safari/i.test(navigator.userAgent)
} else {
this.isSafari = false
this.isIos = false
this.isChromeForIos = false
}
// Set styles
this.colors = new Map(App.styles.color.map((c) => [c.id, `var(--palette-${c.id})`]))
this.store.onBeforeDelete = (record) => {
if (record.typeName === 'shape') {
this._shapeWillBeDeleted(record)
} else if (record.typeName === 'page') {
this._pageWillBeDeleted(record)
}
}
this.store.onAfterChange = (prev, next) => {
this._updateDepth++
if (this._updateDepth > 1000) {
console.error('[onAfterChange] Maximum update depth exceeded, bailing out.')
}
if (prev.typeName === 'shape' && next.typeName === 'shape') {
this._shapeDidChange(prev, next)
} else if (
prev.typeName === 'instance_page_state' &&
next.typeName === 'instance_page_state'
) {
this._tabStateDidChange(prev, next)
}
this._updateDepth--
}
this.store.onAfterCreate = (record) => {
if (record.typeName === 'shape' && this.isShapeOfType(record, TLArrowUtil)) {
2023-04-25 11:01:25 +00:00
this._arrowDidUpdate(record)
}
}
this._shapeIds = shapeIdsInCurrentPage(this.store, () => this.currentPageId)
this._parentIdsToChildIds = parentsToChildrenWithIndexes(this.store)
this.disposables.add(
this.store.listen((changes) => {
this.emit('change', changes as TLChange)
})
)
const container = this.getContainer()
const focusin = () => {
this._isFocused.set(true)
}
const focusout = () => {
this._isFocused.set(false)
}
container.addEventListener('focusin', focusin)
container.addEventListener('focus', focusin)
container.addEventListener('focusout', focusout)
container.addEventListener('blur', focusout)
this.disposables.add(() => {
container.removeEventListener('focusin', focusin)
container.removeEventListener('focus', focusin)
container.removeEventListener('focusout', focusout)
container.removeEventListener('blur', focusout)
})
this.store.ensureStoreIsUsable()
// clear ephemeral state
this.setInstancePageState(
{
editingId: null,
hoveredId: null,
erasingIds: [],
},
true
)
this.root.enter(undefined, 'initial')
2023-04-25 11:01:25 +00:00
if (this.instanceState.followingUserId) {
this.stopFollowingUser()
}
this.updateCullingBounds()
requestAnimationFrame(() => {
this._tickManager.start()
})
}
/**
* The editor's store
*
* @public
*/
readonly store: TLStore
/**
* The root state of the statechart.
*
* @public
*/
readonly root: RootState
/**
* A cache of shape ids in the current page.
*
* @internal
*/
private readonly _shapeIds: ReturnType<typeof shapeIdsInCurrentPage>
/**
* A set of functions to call when the app is disposed.
*
* @public
*/
readonly disposables = new Set<() => void>()
/** @internal */
private _dprManager = new DprManager(this)
/** @internal */
private _cameraManager = new CameraManager(this)
/** @internal */
private _activeAreaManager = new ActiveAreaManager(this)
/** @internal */
private _tickManager = new TickManager(this)
/** @internal */
private _updateDepth = 0
/**
* A manager for the app's snapping feature.
*
* @public
*/
readonly snaps = new SnapManager(this)
/**
* @internal
*/
readonly user: UserPreferencesManager
2023-04-25 11:01:25 +00:00
/**
* Whether the editor is running in Safari.
*
* @public
*/
readonly isSafari: boolean
/**
* Whether the editor is running on iOS.
*
* @public
*/
readonly isIos: boolean
/**
* Whether the editor is running on iOS.
*
* @public
*/
readonly isChromeForIos: boolean
// Flags
private _canMoveCamera = atom('can move camera', true)
/**
* Set whether the editor's camera can move.
*
* @example
*
* ```ts
* app.canMoveCamera = false
* ```
*
* @param canMove - Whether the camera can move.
* @public
*/
get canMoveCamera() {
return this._canMoveCamera.value
}
set canMoveCamera(canMove: boolean) {
this._canMoveCamera.set(canMove)
}
private _isFocused = atom('_isFocused', false)
/**
* Whether or not the editor is focused.
*
* @public
*/
get isFocused() {
return this._isFocused.value
}
/**
* The current HTML element containing the editor.
*
* @example
*
* ```ts
* const container = app.getContainer()
* ```
*
* @public
*/
getContainer: () => HTMLElement
/**
* The editor's instanceId (defined in its store.props).
*
* @example
*
* ```ts
* const instanceId = app.instanceId
* ```
*
* @public
*/
get instanceId(): TLInstanceId {
return this.store.props.instanceId
}
/** @internal */
annotateError(
error: unknown,
{
origin,
willCrashApp,
tags,
extras,
}: {
origin: string
willCrashApp: boolean
tags?: Record<string, string | boolean | number>
extras?: Record<string, unknown>
}
) {
const defaultAnnotations = this.createErrorAnnotations(origin, willCrashApp)
annotateError(error, {
tags: { ...defaultAnnotations.tags, ...tags },
extras: { ...defaultAnnotations.extras, ...extras },
})
if (willCrashApp) {
this.store.markAsPossiblyCorrupted()
}
}
/** @internal */
createErrorAnnotations(
origin: string,
willCrashApp: boolean | 'unknown'
): {
tags: { origin: string; willCrashApp: boolean | 'unknown' }
extras: {
activeStateNode?: string
selectedShapes?: TLUnknownShape[]
editingShape?: TLUnknownShape
inputs?: Record<string, unknown>
}
} {
try {
return {
tags: {
origin: origin,
willCrashApp,
},
extras: {
activeStateNode: this.root.path.value,
selectedShapes: this.selectedShapes,
editingShape: this.editingId ? this.getShapeById(this.editingId) : undefined,
inputs: this.inputs,
},
}
} catch {
return {
tags: {
origin: origin,
willCrashApp,
},
extras: {},
}
}
}
/** @internal */
private _crashingError: unknown | null = null
/**
* We can't use an `atom` here because there's a chance that when `crashAndReportError` is called,
* we're in a transaction that's about to be rolled back due to the same error we're currently
* reporting.
*
* Instead, to listen to changes to this value, you need to listen to app's `crash` event.
*
* @internal
*/
get crashingError() {
return this._crashingError
}
/** @internal */
crash(error: unknown) {
this._crashingError = error
this.store.markAsPossiblyCorrupted()
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
this.emit('crash', { error })
2023-04-25 11:01:25 +00:00
}
get devicePixelRatio() {
return this._dprManager.dpr.value
}
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
private _openMenus = atom('open-menus', [] as string[])
/**
* A set of strings representing any open menus. When menus are open,
* certain interactions will behave differently; for example, when a
* draw tool is selected and a menu is open, a pointer-down will not
* create a dot (because the user is probably trying to close the menu)
* however a pointer-down event followed by a drag will begin drawing
* a line (because the user is BOTH trying to close the menu AND start
* drawing a line).
*
* @public
*/
@computed get openMenus(): string[] {
return this._openMenus.value
}
/**
* Add an open menu.
*
* ```ts
* app.addOpenMenu('menu-id')
* ```
* @public
*/
addOpenMenu = (id: string) => {
const menus = new Set(this.openMenus)
if (!menus.has(id)) {
menus.add(id)
this._openMenus.set([...menus])
}
return this
}
2023-04-25 11:01:25 +00:00
/**
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
* Delete an open menu.
2023-04-25 11:01:25 +00:00
*
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
* ```ts
* app.deleteOpenMenu('menu-id')
* ```
2023-04-25 11:01:25 +00:00
* @public
*/
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
deleteOpenMenu = (id: string) => {
const menus = new Set(this.openMenus)
if (menus.has(id)) {
menus.delete(id)
this._openMenus.set([...menus])
}
return this
}
2023-04-25 11:01:25 +00:00
/**
* Get whether any menus are open.
*
* @public
*/
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
@computed get isMenuOpen() {
return this.openMenus.length > 0
2023-04-25 11:01:25 +00:00
}
/** @internal */
private _isCoarsePointer = atom<boolean>('isCoarsePointer', false as any)
/**
* Whether the user is using a "coarse" pointer, such as on a touch screen.
*
* @public
*/
get isCoarsePointer() {
return this._isCoarsePointer.value
}
set isCoarsePointer(v) {
this._isCoarsePointer.set(v)
}
/** @internal */
private _isChangingStyle = atom<boolean>('isChangingStyle', false as any)
/** @internal */
private _isChangingStyleTimeout = -1 as any
/**
* Whether the user is currently changing the style of a shape. This may cause the UI to change.
*
* @example
*
* ```ts
* app.isChangingStyle = true
* ```
*
* @public
*/
get isChangingStyle() {
return this._isChangingStyle.value
}
set isChangingStyle(v) {
this._isChangingStyle.set(v)
// Clear any reset timeout
clearTimeout(this._isChangingStyleTimeout)
if (v) {
// If we've set to true, set a new reset timeout to change the value back to false after 2 seonds
this._isChangingStyleTimeout = setTimeout(() => (this.isChangingStyle = false), 2000)
}
}
/**
* A cache of page transforms.
*
* @internal
*/
@computed private get _pageTransformCache(): ComputedCache<Matrix2d, TLShape> {
return this.store.createComputedCache<Matrix2d, TLShape>('pageTransformCache', (shape) => {
if (isPageId(shape.parentId)) {
2023-04-25 11:01:25 +00:00
return this.getTransform(shape)
}
// some weird circular type thing here that I had to work wround with (as any)
const parent = (this._pageTransformCache as any).get(shape.parentId)
return Matrix2d.Compose(parent, this.getTransform(shape))
})
}
/**
* A cache of axis aligned page bounding boxes.
*
* @internal
*/
@computed private get _pageBoundsCache(): ComputedCache<Box2d, TLShape> {
return this.store.createComputedCache<Box2d, TLShape>('pageBoundsCache', (shape) => {
const pageTransform = this._pageTransformCache.get(shape.id)
if (!pageTransform) return new Box2d()
const result = Box2d.FromPoints(
Matrix2d.applyToPoints(pageTransform, this.getShapeUtil(shape).outline(shape))
)
return result
})
}
/**
* A cache of page masks used for clipping.
*
* @internal
*/
@computed private get _pageMaskCache(): ComputedCache<VecLike[], TLShape> {
return this.store.createComputedCache<VecLike[], TLShape>('pageMaskCache', (shape) => {
if (isPageId(shape.parentId)) {
2023-04-25 11:01:25 +00:00
return undefined
}
const frameAncestors = this.getAncestorsById(shape.id).filter((s) => s.type === 'frame')
if (frameAncestors.length === 0) return undefined
const pageMask = frameAncestors
.map<VecLike[] | undefined>((s) =>
// Apply the frame transform to the frame outline to get the frame outline in page space
Matrix2d.applyToPoints(this._pageTransformCache.get(s.id)!, this.getOutline(s))
)
.reduce((acc, b) => (b && acc ? intersectPolygonPolygon(acc, b) ?? undefined : undefined))
return pageMask
})
}
/**
* Get the page mask for a shape.
*
* @example
*
* ```ts
* const pageMask = app.getPageMaskById(shape.id)
* ```
*
* @param id - The id of the shape to get the page mask for.
* @returns The page mask for the shape.
* @public
*/
getPageMaskById(id: TLShapeId) {
return this._pageMaskCache.get(id)
}
/**
* A cache of clip paths used for clipping.
*
* @internal
*/
@computed private get _clipPathCache(): ComputedCache<string, TLShape> {
return this.store.createComputedCache<string, TLShape>('clipPathCache', (shape) => {
const pageMask = this._pageMaskCache.get(shape.id)
if (!pageMask) return undefined
const pageTransform = this._pageTransformCache.get(shape.id)
if (!pageTransform) return undefined
if (pageMask.length === 0) {
return `polygon(0px 0px, 0px 0px, 0px 0px)`
}
const localMask = Matrix2d.applyToPoints(Matrix2d.Inverse(pageTransform), pageMask)
return `polygon(${localMask.map((p) => `${p.x}px ${p.y}px`).join(',')})`
})
}
/**
* Get the clip path for a shape.
*
* @example
*
* ```ts
* const clipPath = app.getClipPathById(shape.id)
* ```
*
* @param id - The shape id.
* @returns The clip path or undefined.
* @public
*/
getClipPathById(id: TLShapeId) {
return this._clipPathCache.get(id)
}
/**
* A cache of parents to children.
*
* @internal
*/
private readonly _parentIdsToChildIds: ReturnType<typeof parentsToChildrenWithIndexes>
/**
* Dispose the app.
*
* @public
*/
dispose() {
this.disposables.forEach((dispose) => dispose())
this.disposables.clear()
}
/**
* A manager for the app's history.
*
* @readonly
*/
readonly history = new HistoryManager(
this,
() => this._complete(),
(error) => {
this.annotateError(error, { origin: 'history.batch', willCrashApp: true })
this.crash(error)
}
)
/**
* Undo to the last mark.
*
* @example
*
* ```ts
* app.undo()
* ```
*
* @public
*/
undo() {
return this.history.undo()
}
/**
* Whether the app can undo.
*
* @public
*/
@computed get canUndo() {
return this.history.numUndos > 0
}
/**
* Redo to the next mark.
*
* @example
*
* ```ts
* app.redo()
* ```
*
* @public
*/
redo() {
this.history.redo()
return this
}
/**
* Whether the app can redo.
*
* @public
*/
@computed get canRedo() {
return this.history.numRedos > 0
}
/**
* Create a new "mark", or stopping point, in the undo redo history. Creating a mark will clear
* any redos.
*
* @example
*
* ```ts
* app.mark()
* app.mark('flip shapes')
* ```
*
* @param reason - The reason for the mark.
* @param onUndo - Whether to stop at the mark when undoing.
* @param onRedo - Whether to stop at the mark when redoing.
* @public
*/
mark(reason?: string, onUndo?: boolean, onRedo?: boolean) {
return this.history.mark(reason, onUndo, onRedo)
}
/**
* Clear all marks in the undo stack back to the next mark.
*
* @example
*
* ```ts
* app.bail()
* ```
*
* @public
*/
bail() {
this.history.bail()
return this
}
/**
* Clear all marks in the undo stack back to the mark with the provided mark id.
*
* @example
*
* ```ts
* app.bailToMark('creating')
* ```
*
* @public
*/
bailToMark(id: string) {
this.history.bailToMark(id)
return this
}
/**
* Run a function in a batch, which will be undone/redone as a single action.
*
* @example
*
* ```ts
* app.batch(() => {
* app.selectAll()
* app.deleteShapes()
* app.createShapes(myShapes)
* app.selectNone()
* })
*
* app.undo() // will undo all of the above
* ```
*
* @public
*/
batch(fn: () => void) {
this.history.batch(fn)
return this
}
/**
* A map of shape utility classes (TLShapeUtils) by shape type.
*
* @public
*/
shapeUtils: { readonly [K in string]?: TLShapeUtil<TLUnknownShape> }
/**
* Get a shape util by its definition.
2023-04-25 11:01:25 +00:00
*
* @example
*
* ```ts
* app.getShapeUtil(TLArrowUtil)
2023-04-25 11:01:25 +00:00
* ```
*
* @param util - The shape util.
2023-04-25 11:01:25 +00:00
* @public
*/
getShapeUtil<C extends { new (...args: any[]): TLShapeUtil<any>; type: string }>(
util: C
): InstanceType<C>
2023-04-25 11:01:25 +00:00
/**
* Get a shape util from a shape itself.
2023-04-25 11:01:25 +00:00
*
* @example
*
* ```ts
* const util = app.getShapeUtil(myShape)
* const util = app.getShapeUtil<TLArrowUtil>(myShape)
* const util = app.getShapeUtil(TLArrowUtil)
2023-04-25 11:01:25 +00:00
* ```
*
* @param shape - A shape or shape partial.
2023-04-25 11:01:25 +00:00
* @public
*/
getShapeUtil<S extends TLUnknownShape>(shape: S | TLShapePartial<S>): TLShapeUtil<S>
getShapeUtil<T extends TLShapeUtil>({
type,
}: {
type: T extends TLShapeUtil<infer R> ? R['type'] : string
}): T {
return this.shapeUtils[type] as T
2023-04-25 11:01:25 +00:00
}
/**
* A cache of children for each parent.
*
* @internal
*/
private _childIdsCache = new WeakMapCache<any[], TLShapeId[]>()
/**
* Get an array of all the children of a shape.
*
* @example
*
* ```ts
* app.getSortedChildIds('frame1')
* ```
*
* @param parentId - The id of the parent shape.
* @public
*/
getSortedChildIds(parentId: TLParentId): TLShapeId[] {
const withIndices = this._parentIdsToChildIds.value[parentId]
if (!withIndices) return EMPTY_ARRAY
return this._childIdsCache.get(withIndices, () => withIndices.map(([id]) => id))
}
/**
* Run a visitor function for all descendants of a shape.
*
* @example
*
* ```ts
* app.visitDescendants('frame1', myCallback)
* ```
*
* @param parentId - The id of the parent shape.
* @param visitor - The visitor function.
* @public
*/
visitDescendants(parentId: TLParentId, visitor: (id: TLShapeId) => void | false) {
const children = this.getSortedChildIds(parentId)
for (const id of children) {
if (visitor(id) === false) continue
this.visitDescendants(id, visitor)
}
}
/**
* The editor's current erasing ids.
*
* @public
*/
@computed get erasingIds() {
return this.pageState.erasingIds
}
/**
* The editor's current hinting ids.
*
* @public
*/
@computed get hintingIds() {
return this.pageState.hintingIds
}
/**
* A derived set containing the current erasing ids.
*
* @public
*/
@computed get erasingIdsSet() {
// todo: Make incremental derivation, so that this only gets updated when erasingIds changes: we're creating this too often!
return new Set<TLShapeId>(this.erasingIds)
}
/**
* Get all the current props among the users selected shapes
*
* @internal
*/
private _extractSharedProps(shape: TLShape, sharedProps: TLNullableShapeProps) {
if (shape.type === 'group') {
// For groups, ignore the props of the group shape and instead include
// the props of the group's children. These are the shapes that would have
// their props changed if the user called `setProp` on the current selection.
const childIds = this._parentIdsToChildIds.value[shape.id]
if (!childIds) return
for (let i = 0, n = childIds.length; i < n; i++) {
this._extractSharedProps(this.getShapeById(childIds[i][0])!, sharedProps)
}
} else {
const props = Object.entries(shape.props)
let prop: [TLShapeProp, any]
for (let i = 0, n = props.length; i < n; i++) {
prop = props[i] as [TLShapeProp, any]
// We should probably white list rather than black list here
if (BLACKLISTED_PROPS.has(prop[0])) continue
// Check the value of this prop on the shared props object.
switch (sharedProps[prop[0]]) {
case undefined: {
// If this key hasn't been defined yet in the shared props object,
// we can set it to the value from the shape's props object.
sharedProps[prop[0]] = prop[1]
break
}
case null:
case prop[1]: {
// If the value in the shared props object matches the value from
// the shape's props object exactly—or if there is already a mixed
// value (null) in the shared props object—then this is a noop. We
// want to leave the value as it is in the shared props object.
continue
}
default: {
// If there's a value in the shared props object that isn't null AND
// that isn't undefined AND that doesn't match the shape's props object,
// then we've got a conflict, mixed props, so set the value to null.
sharedProps[prop[0]] = null
}
}
}
}
}
/**
* A derived object containing all current props among the user's selected shapes.
*
* @internal
*/
private _selectionSharedProps = computed<TLNullableShapeProps>('_selectionSharedProps', () => {
const { selectedShapes } = this
const sharedProps = {} as TLNullableShapeProps
for (let i = 0, n = selectedShapes.length; i < n; i++) {
this._extractSharedProps(selectedShapes[i], sharedProps)
}
return sharedProps as TLNullableShapeProps
})
/** @internal */
private _prevProps: any = {}
/**
* A derived object containing either all current props among the user's selected shapes, or else
* the user's most recent prop choices that correspond to the current active state (i.e. the
* selected tool).
*
* @internal
*/
@computed get props(): TLNullableShapeProps | null {
let next: TLNullableShapeProps | null
// If we're in selecting and if we have a selection,
// return the shared props from the current selection
if (this.isIn('select') && this.selectedIds.length > 0) {
next = this._selectionSharedProps.value
} else {
// Otherwise, pull the style props from the app state
// (the most recent choices made by the user) that are
// exposed by the current state (i.e. the active tool).
const currentState = this.root.current.value!
if (currentState.styles.length === 0) {
next = null
} else {
const { propsForNextShape } = this.instanceState
next = Object.fromEntries(
currentState.styles.map((k) => {
return [k, propsForNextShape[k]]
})
)
}
}
// todo: any way to improve this? still faster than rendering the style panel every frame
if (JSON.stringify(this._prevProps) === JSON.stringify(next)) {
return this._prevProps
}
this._prevProps = next
return next
}
/**
* An array of all of the shapes on the current page.
*
* @public
*/
get shapeIds() {
return this._shapeIds.value
}
/**
* _invalidParents is used to trigger the 'onChildrenChange' callback that shapes can have.
*
* @internal
*/
private readonly _invalidParents = new Set<TLShapeId>()
/** @internal */
private _complete() {
const { lastUpdatedPageId, lastUsedTabId } = this.userDocumentSettings
if (lastUsedTabId !== this.instanceId || lastUpdatedPageId !== this.currentPageId) {
this.store.put([
{
...this.userDocumentSettings,
lastUsedTabId: this.instanceId,
lastUpdatedPageId: this.currentPageId,
},
])
}
2023-04-25 11:01:25 +00:00
for (const parentId of this._invalidParents) {
this._invalidParents.delete(parentId)
const parent = this.getShapeById(parentId)
if (!parent) continue
const util = this.getShapeUtil(parent)
const changes = util.onChildrenChange?.(parent)
if (changes?.length) {
this.updateShapes(changes, true)
}
}
this.emit('update')
}
/** @internal */
@computed
private get _arrowBindingsIndex() {
return arrowBindingsIndex(this.store)
}
/** GetArrowsBoundTo */
getArrowsBoundTo(shapeId: TLShapeId) {
return this._arrowBindingsIndex.value[shapeId] || EMPTY_ARRAY
}
/** @internal */
private _reparentArrow(arrowId: TLShapeId) {
const arrow = this.getShapeById<TLArrowShape>(arrowId)
2023-04-25 11:01:25 +00:00
if (!arrow) return
const { start, end } = arrow.props
const startShape = start.type === 'binding' ? this.getShapeById(start.boundShapeId) : undefined
const endShape = end.type === 'binding' ? this.getShapeById(end.boundShapeId) : undefined
const parentPageId = this.getParentPageId(arrow)
if (!parentPageId) return
let nextParentId: TLParentId
if (startShape && endShape) {
// if arrow has two bindings, always parent arrow to closest common ancestor of the bindings
nextParentId = this.findCommonAncestor([startShape, endShape]) ?? parentPageId
} else if (startShape || endShape) {
// if arrow has one binding, keep arrow on its own page
nextParentId = parentPageId
} else {
return
}
if (nextParentId && nextParentId !== arrow.parentId) {
this.reparentShapesById([arrowId], nextParentId)
}
const reparentedArrow = this.getShapeById<TLArrowShape>(arrowId)
if (!reparentedArrow) throw Error('no reparented arrow')
2023-04-25 11:01:25 +00:00
const startSibling = this.getNearestSiblingShape(reparentedArrow, startShape)
const endSibling = this.getNearestSiblingShape(reparentedArrow, endShape)
let highestSibling: TLShape | undefined
if (startSibling && endSibling) {
highestSibling = startSibling.index > endSibling.index ? startSibling : endSibling
} else if (startSibling && !endSibling) {
highestSibling = startSibling
} else if (endSibling && !startSibling) {
highestSibling = endSibling
} else {
return
}
let finalIndex: string
const higherSiblings = this.getSortedChildIds(highestSibling.parentId)
.map((id) => this.getShapeById(id)!)
.filter((sibling) => sibling.index > highestSibling!.index)
if (higherSiblings.length) {
// there are siblings above the highest bound sibling, we need to
// insert between them.
// if the next sibling is also a bound arrow though, we can end up
// all fighting for the same indexes. so lets find the next
// non-arrow sibling...
const nextHighestNonArrowSibling = higherSiblings.find((sibling) => sibling.type !== 'arrow')
if (
// ...then, if we're above the last shape we want to be above...
reparentedArrow.index > highestSibling.index &&
// ...but below the next non-arrow sibling...
(!nextHighestNonArrowSibling || reparentedArrow.index < nextHighestNonArrowSibling.index)
) {
// ...then we're already in the right place. no need to update!
return
}
// otherwise, we need to find the index between the highest sibling
// we want to be above, and the next highest sibling we want to be
// below:
finalIndex = getIndexBetween(highestSibling.index, higherSiblings[0].index)
} else {
// if there are no siblings above us, we can just get the next index:
finalIndex = getIndexAbove(highestSibling.index)
}
if (finalIndex !== reparentedArrow.index) {
this.updateShapes([{ id: arrowId, type: 'arrow', index: finalIndex }])
}
}
/** @internal */
private _unbindArrowTerminal(arrow: TLArrowShape, handleId: 'start' | 'end') {
const { x, y } = getArrowTerminalsInArrowSpace(this, arrow)[handleId]
this.store.put([{ ...arrow, props: { ...arrow.props, [handleId]: { type: 'point', x, y } } }])
}
// private _shapeWillUpdate = (prev: TLShape, next: TLShape) => {
// const update = this.getShapeUtil(next).onUpdate?.(prev, next)
// return update ?? next
// }
@computed
private get _allPageStates() {
return this.store.query.records('instance_page_state')
}
2023-04-25 11:01:25 +00:00
/** @internal */
private _shapeWillBeDeleted(deletedShape: TLShape) {
// if the deleted shape has a parent shape make sure we call it's onChildrenChange callback
if (deletedShape.parentId && isShapeId(deletedShape.parentId)) {
this._invalidParents.add(deletedShape.parentId)
}
// clean up any arrows bound to this shape
const bindings = this._arrowBindingsIndex.value[deletedShape.id]
if (bindings?.length) {
for (const { arrowId, handleId } of bindings) {
const arrow = this.getShapeById<TLArrowShape>(arrowId)
if (!arrow) continue
this._unbindArrowTerminal(arrow, handleId)
}
}
const pageStates = this._allPageStates.value
2023-04-25 11:01:25 +00:00
const deletedIds = new Set([deletedShape.id])
const updates = compact(
pageStates.map((pageState) => {
return this._cleanupInstancePageState(pageState, deletedIds)
})
)
if (updates.length) {
this.store.put(updates)
}
}
/** @internal */
private _arrowDidUpdate(arrow: TLArrowShape) {
// if the shape is an arrow and its bound shape is on another page
// or was deleted, unbind it
for (const handle of ['start', 'end'] as const) {
const terminal = arrow.props[handle]
if (terminal.type !== 'binding') continue
const boundShape = this.getShapeById(terminal.boundShapeId)
const isShapeInSamePageAsArrow =
this.getParentPageId(arrow) === this.getParentPageId(boundShape)
if (!boundShape || !isShapeInSamePageAsArrow) {
this._unbindArrowTerminal(arrow, handle)
}
}
// always check the arrow parents
this._reparentArrow(arrow.id)
}
/** @internal */
private _cleanupInstancePageState(
prevPageState: TLInstancePageState,
shapesNoLongerInPage: Set<TLShapeId>
) {
let nextPageState = null as null | TLInstancePageState
const selectedIds = prevPageState.selectedIds.filter((id) => !shapesNoLongerInPage.has(id))
if (selectedIds.length !== prevPageState.selectedIds.length) {
if (!nextPageState) nextPageState = { ...prevPageState }
nextPageState.selectedIds = selectedIds
}
const erasingIds = prevPageState.erasingIds.filter((id) => !shapesNoLongerInPage.has(id))
if (erasingIds.length !== prevPageState.erasingIds.length) {
if (!nextPageState) nextPageState = { ...prevPageState }
nextPageState.erasingIds = erasingIds
}
if (prevPageState.hoveredId && shapesNoLongerInPage.has(prevPageState.hoveredId)) {
if (!nextPageState) nextPageState = { ...prevPageState }
nextPageState.hoveredId = null
}
if (prevPageState.editingId && shapesNoLongerInPage.has(prevPageState.editingId)) {
if (!nextPageState) nextPageState = { ...prevPageState }
nextPageState.editingId = null
}
const hintingIds = prevPageState.hintingIds.filter((id) => !shapesNoLongerInPage.has(id))
if (hintingIds.length !== prevPageState.hintingIds.length) {
if (!nextPageState) nextPageState = { ...prevPageState }
nextPageState.hintingIds = hintingIds
}
if (prevPageState.focusLayerId && shapesNoLongerInPage.has(prevPageState.focusLayerId)) {
if (!nextPageState) nextPageState = { ...prevPageState }
nextPageState.focusLayerId = null
}
return nextPageState
}
/** @internal */
private _shapeDidChange(prev: TLShape, next: TLShape) {
if (this.isShapeOfType(next, TLArrowUtil)) {
2023-04-25 11:01:25 +00:00
this._arrowDidUpdate(next)
}
// if the shape's parent changed and it is bound to an arrow, update the arrow's parent
if (prev.parentId !== next.parentId) {
const reparentBoundArrows = (id: TLShapeId) => {
const boundArrows = this._arrowBindingsIndex.value[id]
if (boundArrows?.length) {
for (const arrow of boundArrows) {
this._reparentArrow(arrow.arrowId)
}
}
}
reparentBoundArrows(next.id)
this.visitDescendants(next.id, reparentBoundArrows)
}
// if this shape moved to a new page, clean up any previous page's instance state
if (prev.parentId !== next.parentId && isPageId(next.parentId)) {
2023-04-25 11:01:25 +00:00
const allMovingIds = new Set([prev.id])
this.visitDescendants(prev.id, (id) => {
allMovingIds.add(id)
})
for (const instancePageState of this.store.query.records('instance_page_state').value) {
if (instancePageState.pageId === next.parentId) continue
const nextPageState = this._cleanupInstancePageState(instancePageState, allMovingIds)
if (nextPageState) {
this.store.put([nextPageState])
}
}
}
if (prev.parentId && isShapeId(prev.parentId)) {
this._invalidParents.add(prev.parentId)
}
if (next.parentId !== prev.parentId && isShapeId(next.parentId)) {
this._invalidParents.add(next.parentId)
}
}
/** @internal */
private _tabStateDidChange(prev: TLInstancePageState, next: TLInstancePageState) {
if (prev?.selectedIds !== next?.selectedIds) {
// ensure that descendants and ascenants are not selected at the same time
const filtered = next.selectedIds.filter((id) => {
let parentId = this.getShapeById(id)?.parentId
while (isShapeId(parentId)) {
if (next.selectedIds.includes(parentId)) {
return false
}
parentId = this.getShapeById(parentId)?.parentId
}
return true
})
const nextFocusLayerId =
filtered.length === 0
? next?.focusLayerId
: this.findCommonAncestor(
compact(filtered.map((id) => this.getShapeById(id))),
(shape) => shape.type === 'group'
)
if (filtered.length !== next.selectedIds.length || nextFocusLayerId != next.focusLayerId) {
this.store.put([{ ...next, selectedIds: filtered, focusLayerId: nextFocusLayerId ?? null }])
}
}
}
/** @internal */
private _pageWillBeDeleted(page: TLPage) {
// page was deleted, need to check whether it's the current page and select another one if so
const instanceStates = this.store.query.exec('instance', { currentPageId: { eq: page.id } })
if (!instanceStates.length) return
const backupPageId = this.pages.find((p) => p.id !== page.id)?.id
if (!backupPageId) return
this.store.put(instanceStates.map((state) => ({ ...state, currentPageId: backupPageId })))
}
/* -------------------- Shortcuts ------------------- */
/** The global document settings that applies to all users */
@computed get documentSettings() {
return this.store.get(TLDOCUMENT_ID)!
}
Add support for project names (#1340) This PR adds some things that we need for the Project Name feature on tldraw.com. It should be reviewed alongside https://github.com/tldraw/tldraw-lite/pull/1814 ## Name Property This PR adds a `name` property to `TLDocument`. We use this to store a project's name. <img width="454" alt="Screenshot 2023-05-09 at 15 47 26" src="https://github.com/tldraw/tldraw/assets/15892272/f3be438e-aa0f-4dec-8f51-8dfd9f9d0ced"> ## Top Zone This PR adds a `topZone` area of the UI that we can add stuff to, similar to how `shareZone` works. It also adds an example to show where the `topZone` and `shareZone` are: <img width="1511" alt="Screenshot 2023-05-12 at 10 57 40" src="https://github.com/tldraw/tldraw/assets/15892272/f5e1cd33-017e-4aaf-bfee-4d85119e2974"> ## Breakpoints This PR change's the UI's breakpoints a little bit. It moves the action bar to the bottom a little bit earlier. (This gives us more space at the top for the project name). ![2023-05-12 at 11 08 26 - Fuchsia Bison](https://github.com/tldraw/tldraw/assets/15892272/34563cea-b1d1-47be-ac5e-5650ee0ba02d) ![2023-05-12 at 13 45 04 - Tan Mole](https://github.com/tldraw/tldraw/assets/15892272/ab190bd3-51d4-4a8b-88de-c72ab14bcba6) ## Input Blur This PR adds an `onBlur` parameter to `Input`. This was needed because 'clicking off' the input wasn't firing `onComplete` or `onCancel`. <img width="620" alt="Screenshot 2023-05-09 at 16 12 58" src="https://github.com/tldraw/tldraw/assets/15892272/3b28da74-0a74-4063-8053-e59e47027caf"> ## Create Project Name This PR adds an internal `createProjectName` property to `TldrawEditorConfig`. Similar to `derivePresenceState`, you can pass a custom function to it. It lets you control what gets used as the default project name. We use it to set different names in our local projects compared to shared projects. In the future, when we add more advanced project features, we could handle this better within the UI. <img width="454" alt="Screenshot 2023-05-09 at 15 47 26" src="https://github.com/tldraw/tldraw/assets/15892272/da9a4699-ac32-40d9-a97c-6c682acfac41"> ### Test Plan 1. Gradually reduce the width of the browser window. 2. Check that the actions menu jumps to the bottom before the style panel moves to the bottom. --- 1. In the examples app, open the `/zones` example. 2. Check that there's a 'top zone' at the top. - [ ] Unit Tests - [ ] Webdriver tests ### Release Note - [dev] Added a `topZone` area where you can put stuff. - [dev] Added a `name` property to `TLDocument` - and `app` methods for it. - [dev] Added an internal `createProjectName` config property for controlling the default project name. - [dev] Added an `onBlur` parameter to `Input`. - Moved the actions bar to the bottom on medium-sized screens. --------- Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
2023-06-01 18:46:26 +00:00
/** @internal */
updateDocumentSettings(settings: Partial<TLDocument>) {
this.store.put([{ ...this.documentSettings, ...settings }])
}
2023-04-25 11:01:25 +00:00
get gridSize() {
return this.documentSettings.gridSize
}
Add support for project names (#1340) This PR adds some things that we need for the Project Name feature on tldraw.com. It should be reviewed alongside https://github.com/tldraw/tldraw-lite/pull/1814 ## Name Property This PR adds a `name` property to `TLDocument`. We use this to store a project's name. <img width="454" alt="Screenshot 2023-05-09 at 15 47 26" src="https://github.com/tldraw/tldraw/assets/15892272/f3be438e-aa0f-4dec-8f51-8dfd9f9d0ced"> ## Top Zone This PR adds a `topZone` area of the UI that we can add stuff to, similar to how `shareZone` works. It also adds an example to show where the `topZone` and `shareZone` are: <img width="1511" alt="Screenshot 2023-05-12 at 10 57 40" src="https://github.com/tldraw/tldraw/assets/15892272/f5e1cd33-017e-4aaf-bfee-4d85119e2974"> ## Breakpoints This PR change's the UI's breakpoints a little bit. It moves the action bar to the bottom a little bit earlier. (This gives us more space at the top for the project name). ![2023-05-12 at 11 08 26 - Fuchsia Bison](https://github.com/tldraw/tldraw/assets/15892272/34563cea-b1d1-47be-ac5e-5650ee0ba02d) ![2023-05-12 at 13 45 04 - Tan Mole](https://github.com/tldraw/tldraw/assets/15892272/ab190bd3-51d4-4a8b-88de-c72ab14bcba6) ## Input Blur This PR adds an `onBlur` parameter to `Input`. This was needed because 'clicking off' the input wasn't firing `onComplete` or `onCancel`. <img width="620" alt="Screenshot 2023-05-09 at 16 12 58" src="https://github.com/tldraw/tldraw/assets/15892272/3b28da74-0a74-4063-8053-e59e47027caf"> ## Create Project Name This PR adds an internal `createProjectName` property to `TldrawEditorConfig`. Similar to `derivePresenceState`, you can pass a custom function to it. It lets you control what gets used as the default project name. We use it to set different names in our local projects compared to shared projects. In the future, when we add more advanced project features, we could handle this better within the UI. <img width="454" alt="Screenshot 2023-05-09 at 15 47 26" src="https://github.com/tldraw/tldraw/assets/15892272/da9a4699-ac32-40d9-a97c-6c682acfac41"> ### Test Plan 1. Gradually reduce the width of the browser window. 2. Check that the actions menu jumps to the bottom before the style panel moves to the bottom. --- 1. In the examples app, open the `/zones` example. 2. Check that there's a 'top zone' at the top. - [ ] Unit Tests - [ ] Webdriver tests ### Release Note - [dev] Added a `topZone` area where you can put stuff. - [dev] Added a `name` property to `TLDocument` - and `app` methods for it. - [dev] Added an internal `createProjectName` config property for controlling the default project name. - [dev] Added an `onBlur` parameter to `Input`. - Moved the actions bar to the bottom on medium-sized screens. --------- Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
2023-06-01 18:46:26 +00:00
/** @internal */
get projectName() {
return this.documentSettings.name
}
/** @internal */
setProjectName(name: string) {
this.updateDocumentSettings({ name })
}
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
get isSnapMode() {
return this.userDocumentSettings.isSnapMode
}
setSnapMode(isSnapMode: boolean) {
if (isSnapMode !== this.isSnapMode) {
this.updateUserDocumentSettings({ isSnapMode }, true)
}
return this
}
get isDarkMode() {
return this.user.isDarkMode
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
}
setDarkMode(isDarkMode: boolean) {
if (isDarkMode !== this.isDarkMode) {
this.user.updateUserPreferences({ isDarkMode })
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
}
return this
}
get animationSpeed() {
return this.user.animationSpeed
}
setAnimationSpeed(animationSpeed: number) {
if (animationSpeed !== this.animationSpeed) {
this.user.updateUserPreferences({ animationSpeed })
}
return this
}
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
get isFocusMode() {
return this.instanceState.isFocusMode
}
setFocusMode(isFocusMode: boolean) {
if (isFocusMode !== this.isFocusMode) {
this.updateInstanceState({ isFocusMode }, true)
}
return this
}
get isToolLocked() {
return this.instanceState.isToolLocked
}
setToolLocked(isToolLocked: boolean) {
if (isToolLocked !== this.isToolLocked) {
this.updateInstanceState({ isToolLocked }, true)
}
return this
}
2023-04-25 11:01:25 +00:00
/** @internal */
@computed private get _userDocumentSettings() {
return this.store.query.record('user_document')
2023-04-25 11:01:25 +00:00
}
get userDocumentSettings(): TLUserDocument {
return this._userDocumentSettings.value!
}
get isGridMode() {
return this.userDocumentSettings.isGridMode
}
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
setGridMode(isGridMode: boolean): this {
if (isGridMode !== this.isGridMode) {
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
this.updateUserDocumentSettings({ isGridMode }, true)
}
return this
2023-04-25 11:01:25 +00:00
}
private _isReadOnly = atom<boolean>('isReadOnly', false as any)
2023-04-25 11:01:25 +00:00
/** @internal */
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
setReadOnly(isReadOnly: boolean): this {
this._isReadOnly.set(isReadOnly)
if (isReadOnly) {
this.setSelectedTool('hand')
2023-04-25 11:01:25 +00:00
}
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
return this
2023-04-25 11:01:25 +00:00
}
get isReadOnly() {
return this._isReadOnly.value
}
2023-04-25 11:01:25 +00:00
/** @internal */
private _isPenMode = atom<boolean>('isPenMode', false as any)
/** @internal */
private _touchEventsRemainingBeforeExitingPenMode = 0
get isPenMode() {
return this._isPenMode.value
}
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
setPenMode(isPenMode: boolean): this {
2023-04-25 11:01:25 +00:00
if (isPenMode) this._touchEventsRemainingBeforeExitingPenMode = 3
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
if (isPenMode !== this.isPenMode) {
this._isPenMode.set(isPenMode)
}
return this
2023-04-25 11:01:25 +00:00
}
// User / User App State
/** The current tab state */
get instanceState(): TLInstance {
return this.store.get(this.instanceId)!
}
get cursor() {
return this.instanceState.cursor
}
get brush() {
return this.instanceState.brush
}
get zoomBrush() {
return this.instanceState.zoomBrush
}
get scribble() {
return this.instanceState.scribble
}
/** @internal */
@computed private get _pageState() {
return this.store.query.record(
'instance_page_state',
() => {
return {
pageId: { eq: this.currentPageId },
instanceId: { eq: this.instanceId },
}
},
'app._pageState'
)
}
/**
* The current page state.
*
* @public
*/
get pageState(): TLInstancePageState {
return this._pageState.value!
}
/** The current camera. */
@computed get camera() {
return this.store.get(this.pageState.cameraId)!
}
/** The current camera zoom level. */
@computed get zoomLevel() {
return this.camera.z
}
/**
* The current selected ids.
*
* @public
*/
@computed get selectedIds() {
return this.pageState.selectedIds
}
/**
* The current selected ids as a set
*
* @public
*/
@computed get selectedIdsSet(): ReadonlySet<TLShapeId> {
return new Set(this.selectedIds)
}
// Pages
/** @internal */
@computed private get _pages() {
return this.store.query.records('page')
}
/**
* Info about the project's current pages.
*
* @public
* @readonly
*/
@computed get pages() {
return this._pages.value.sort(sortByIndex)
}
/**
* The current page.
*
* @public
*/
get currentPage() {
return this.getPageById(this.currentPageId)!
}
/**
* The current page id.
*
* @public
*/
get currentPageId() {
return this.instanceState.currentPageId
}
/**
* Get a page by its ID.
*
* @example
*
* ```ts
* app.getPageById(myPage.id)
* ```
*
* @public
*/
getPageById(id: TLPage['id']) {
return this.store.get(id)
}
/** @internal */
@computed private get _pageStates() {
return this.store.query.records('instance_page_state', () => ({
instanceId: { eq: this.instanceId },
}))
}
/**
* Get a page state by its id.
*
* @example
*
* ```ts
* app.getPageStateByPageId('page1')
* ```
*
* @public
*/
getPageStateByPageId(id: TLPageId) {
return this._pageStates.value.find((p) => p.pageId === id)
}
/**
* Get a page by its ID.
*
* @example
*
* ```ts
* app.getPageById(myPage.id)
* ```
*
* @public
*/
getPageInfoById(id: TLPage['id']) {
return this.store.get(id)
}
/** Get shapes on a page. */
getShapeIdsInPage(pageId: TLPageId): Set<TLShapeId> {
2023-04-25 11:01:25 +00:00
const result = this.store.query.exec('shape', { parentId: { eq: pageId } })
return this.getShapeAndDescendantIds(result.map((s) => s.id))
2023-04-25 11:01:25 +00:00
}
/* --------------------- Shapes --------------------- */
/**
* Get the local transform for a shape as a matrix model. This transform reflects both its
* translation (x, y) from from either its parent's top left corner, if the shape's parent is
* another shape, or else from the 0,0 of the page, if the shape's parent is the page; and the
* shape's rotation.
*
* @example
*
* ```ts
* app.getTransform(myShape)
* ```
*
* @param shape - The shape to get the local transform for.
* @public
*/
getTransform(shape: TLShape) {
const util = this.getShapeUtil(shape)
return util.transform(shape)
}
/**
* Get the local transform of a shape's parent as a matrix model.
*
* @example
*
* ```ts
* app.getParentTransform(myShape)
* ```
*
* @param shape - The shape to get the parent transform for.
* @public
*/
getParentTransform(shape: TLShape) {
if (isPageId(shape.parentId)) {
2023-04-25 11:01:25 +00:00
return Matrix2d.Identity()
}
return this._pageTransformCache.get(shape.parentId) ?? Matrix2d.Identity()
}
/**
* Get the page transform (or absolute transform) of a shape.
*
* @example
*
* ```ts
* app.getPageTransform(myShape)
* ```
*
* @param shape - The shape to get the page transform for.
* @public
*/
getPageTransform(shape: TLShape) {
return this.getPageTransformById(shape.id)
}
/**
* Get the page transform (or absolute transform) of a shape by its id.
*
* @example
*
* ```ts
* app.getPageTransformById(myShape)
* ```
*
* @param id - The if of the shape to get the page transform for.
* @public
*/
getPageTransformById(id: TLShapeId) {
return this._pageTransformCache.get(id)
}
/**
* Get the page point (or absolute point) of a shape.
*
* @example
*
* ```ts
* app.getPagePoint(myShape)
* ```
*
* @param shape - The shape to get the page point for.
* @public
*/
getPagePointById(id: TLShapeId) {
const pageTransform = this.getPageTransformById(id)
if (!pageTransform) return
return Matrix2d.applyToPoint(pageTransform, new Vec2d())
}
/**
* Get the page point (or absolute point) of a shape.
*
* @example
*
* ```ts
* app.getPagePoint(myShape)
* ```
*
* @param shape - The shape to get the page point for.
* @public
*/
getPageCenter(shape: TLShape) {
const pageTransform = this.getPageTransformById(shape.id)
if (!pageTransform) return null
const util = this.getShapeUtil(shape)
const center = util.center(shape)
return Matrix2d.applyToPoint(pageTransform, center)
}
/**
* Get the page point (or absolute point) of a shape by its id.
*
* @example
*
* ```ts
* app.getPagePoint(myShape)
* ```
*
* @param id - The shape id to get the page point for.
* @public
*/
getPageCenterById(id: TLShapeId) {
const shape = this.getShapeById(id)!
return this.getPageCenter(shape)
}
/**
* Get the page rotation (or absolute rotation) of a shape.
*
* @example
*
* ```ts
* app.getPageRotation(myShape)
* ```
*
* @param shape - The shape to get the page rotation for.
* @public
*/
getPageRotation(shape: TLShape): number {
return this.getPageRotationById(shape.id)
}
/**
* Get the page rotation (or absolute rotation) of a shape by its id.
*
* @param id - The id of the shape to get the page rotation for.
*/
getPageRotationById(id: TLShapeId): number {
const pageTransform = this.getPageTransformById(id)
if (pageTransform) {
return Matrix2d.Decompose(pageTransform).rotation
}
return 0
}
/**
* Get the local bounds of a shape.
*
* @example
*
* ```ts
* app.getBounds(myShape)
* ```
*
* @param shape - The shape to get the bounds for.
* @public
*/
getBounds(shape: TLShape): Box2d {
return this.getShapeUtil(shape).bounds(shape)
}
/**
* Get the local bounds of a shape by its id.
*
* @example
*
* ```ts
* app.getBoundsById(myShape)
* ```
*
* @param id - The id of the shape to get the bounds for.
* @public
*/
getBoundsById(id: TLShapeId): Box2d | undefined {
const shape = this.getShapeById(id)
if (!shape) return undefined
return this.getBounds(shape)
}
/**
* Get the page (or absolute) bounds of a shape.
*
* @example
*
* ```ts
* app.getPageBounds(myShape)
* ```
*
* @param shape - The shape to get the bounds for.
* @public
*/
getPageBounds(shape: TLShape): Box2d | undefined {
return this.getPageBoundsById(shape.id)
}
/**
* Get the page (or absolute) bounds of a shape by its id.
*
* @example
*
* ```ts
* app.getPageBoundsById(myShape)
* ```
*
* @param id - The id of the shape to get the page bounds for.
* @public
*/
getPageBoundsById(id: TLShapeId): Box2d | undefined {
return this._pageBoundsCache.get(id)
}
/**
* Get the page (or absolute) bounds of a shape, incorporating any masks. For example, if the
* shape were the child of a frame and was half way out of the frame, the bounds would be the half
* of the shape that was in the frame.
*
* @example
*
* ```ts
* app.getMaskedPageBounds(myShape)
* ```
*
* @param shape - The shape to get the masked bounds for.
* @public
*/
getMaskedPageBounds(shape: TLShape): Box2d | undefined {
return this.getMaskedPageBoundsById(shape.id)
}
/**
* Get the page (or absolute) bounds of a shape by its id, incorporating any masks. For example,
* if the shape were the child of a frame and was half way out of the frame, the bounds would be
* the half of the shape that was in the frame.
*
* @example
*
* ```ts
* app.getMaskedPageBoundsById(myShape)
* ```
*
* @param id - The id of the shape to get the masked page bounds for.
* @public
*/
getMaskedPageBoundsById(id: TLShapeId): Box2d | undefined {
const pageBounds = this._pageBoundsCache.get(id)
if (!pageBounds) return
const pageMask = this._pageMaskCache.get(id)
if (pageMask) {
const intersection = intersectPolygonPolygon(pageMask, pageBounds.corners)
if (!intersection) return
return Box2d.FromPoints(intersection)
}
return pageBounds
}
/**
* Get the local outline of a shape.
*
* @example
*
* ```ts
* app.getOutline(myShape)
* ```
*
* @param shape - The shape to get the outline for.
* @public
*/
getOutline(shape: TLShape) {
return this.getShapeUtil(shape).outline(shape)
}
/**
* Get the local outline of a shape.
*
* @example
*
* ```ts
* app.getOutlineById(myShape)
* ```
*
* @param id - The shape id to get the outline for.
* @public
*/
getOutlineById(id: TLShapeId) {
return this.getOutline(this.getShapeById(id)!)
}
/**
* Get the ancestors of a shape.
*
* @example
*
* ```ts
* const ancestors = app.getAncestors(myShape)
* ```
*
* @param shape - The shape to get the ancestors for.
* @public
*/
getAncestors(shape: TLShape, acc: TLShape[] = []): TLShape[] {
const parentId = shape.parentId
if (isPageId(parentId)) {
2023-04-25 11:01:25 +00:00
acc.reverse()
return acc
}
const parent = this.store.get(parentId)!
acc.push(parent)
return this.getAncestors(parent, acc)
}
/**
* Get the ancestors of a shape by its id.
*
* @example
*
* ```ts
* const ancestors = app.getAncestorsById(myShape)
* ```
*
* @param id - The id of the shape to get the ancestors for.
* @public
*/
getAncestorsById(id: TLShapeId, acc: TLShape[] = []): TLShape[] {
const shape = this.getShapeById(id)!
return this.getAncestors(shape, acc)
}
/**
* Find the first ancestor matching the given predicate
*
* @example
*
* ```ts
* const ancestor = app.findAncestor(myShape)
* ```
*
* @param shape - The shape to check the ancestors for.
* @public
*/
findAncestor(shape: TLShape, predicate: (parent: TLShape) => boolean): TLShape | undefined {
const parentId = shape.parentId
if (isPageId(parentId)) {
2023-04-25 11:01:25 +00:00
return undefined
}
const parent = this.getShapeById(parentId)
if (parent) {
if (predicate(parent)) {
return parent
}
return this.findAncestor(parent, predicate)
}
return undefined
}
/** Returns true if the the given shape has the given ancestor */
hasAncestor(shape: TLShape | undefined, ancestorId: TLShapeId): boolean {
if (!shape) return false
if (shape.parentId === ancestorId) return true
return this.hasAncestor(this.getParentShape(shape), ancestorId)
}
/**
* Get the common ancestor of two or more shapes that matches a predicate.
*
* @param shapes - The shapes to check.
* @param predicate - The predicate to match.
*/
findCommonAncestor(
shapes: TLShape[],
predicate?: (shape: TLShape) => boolean
): TLShapeId | undefined {
if (shapes.length === 0) {
return
}
if (shapes.length === 1) {
const parentId = shapes[0].parentId
if (isPageId(parentId)) {
2023-04-25 11:01:25 +00:00
return
}
return predicate ? this.findAncestor(shapes[0], predicate)?.id : parentId
}
const [nodeA, ...others] = shapes
let ancestor = this.getParentShape(nodeA)
while (ancestor) {
// TODO: this is not ideal, optimize
if (predicate && !predicate(ancestor)) {
ancestor = this.getParentShape(ancestor)
continue
}
if (others.every((shape) => this.hasAncestor(shape, ancestor!.id))) {
return ancestor!.id
}
ancestor = this.getParentShape(ancestor)
}
return undefined
}
/**
* Check whether a shape is within the bounds of the current viewport.
*
* @param id - The id of the shape to check.
* @public
*/
isShapeInViewport(id: TLShapeId) {
const pageBounds = this.getPageBoundsById(id)
if (!pageBounds) return false
return this.viewportPageBounds.includes(pageBounds)
}
/**
* Check whether a shape or its parent is locked.
*
* @param id - The id of the shape to check.
* @public
*/
isShapeOrAncestorLocked(shape?: TLShape): boolean {
if (shape === undefined) return false
if (shape.isLocked) return true
return this.isShapeOrAncestorLocked(this.getParentShape(shape))
}
private computeUnorderedRenderingShapes(
ids: TLParentId[],
{
cullingBounds,
cullingBoundsExpanded,
erasingIdsSet,
editingId,
}: {
cullingBounds?: Box2d
cullingBoundsExpanded?: Box2d
erasingIdsSet?: Set<TLShapeId>
editingId?: TLShapeId | null
} = {}
) {
2023-04-25 11:01:25 +00:00
// 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
2023-04-25 11:01:25 +00:00
// being erased, then we reduce the opacity of the shape and all of its
// ancestors; but we don't apply this effect more than once among a set
// of descendants so that it does not compound.
// This is designed to keep all the shapes in a single list which
// allows the DOM nodes to be reused even when they become children
// of other nodes.
const renderingShapes: {
id: TLShapeId
index: number
backgroundIndex: number
2023-04-25 11:01:25 +00:00
opacity: number
isCulled: boolean
isInViewport: boolean
maskedPageBounds: Box2d | undefined
2023-04-25 11:01:25 +00:00
}[] = []
let nextIndex = MAX_SHAPES_PER_PAGE
let nextBackgroundIndex = 0
2023-04-25 11:01:25 +00:00
const addShapeById = (id: TLParentId, parentOpacity: number, isAncestorErasing: boolean) => {
if (PageRecordType.isId(id)) {
for (const childId of this.getSortedChildIds(id)) {
addShapeById(childId, parentOpacity, isAncestorErasing)
}
return
}
const shape = this.getShapeById(id)
2023-04-25 11:01:25 +00:00
if (!shape) return
// todo: move opacity to a property of shape, rather than a property of props
let opacity = (+(shape.props as { opacity: string }).opacity ?? 1) * parentOpacity
let isShapeErasing = false
if (!isAncestorErasing && erasingIdsSet?.has(id)) {
2023-04-25 11:01:25 +00:00
isShapeErasing = true
opacity *= 0.32
}
// If a child is outside of its parent's clipping bounds, then bounds will be undefined.
const maskedPageBounds = this.getMaskedPageBoundsById(id)
2023-04-25 11:01:25 +00:00
// Whether the shape is on screen. Use the "strict" viewport here.
const isInViewport = maskedPageBounds
? cullingBounds?.includes(maskedPageBounds) ?? true
: false
2023-04-25 11:01:25 +00:00
// Whether the shape should actually be culled / unmounted.
// - Use the "expanded" culling viewport to include shapes that are just off-screen.
// - Editing shapes should never be culled.
const isCulled = maskedPageBounds
? (editingId !== id && !cullingBoundsExpanded?.includes(maskedPageBounds)) ?? true
: true
2023-04-25 11:01:25 +00:00
renderingShapes.push({
id,
index: nextIndex,
backgroundIndex: nextBackgroundIndex,
opacity,
isCulled,
isInViewport,
maskedPageBounds,
2023-04-25 11:01:25 +00:00
})
nextIndex += 1
nextBackgroundIndex += 1
const childIds = this.getSortedChildIds(id)
if (!childIds.length) return
let backgroundIndexToRestore = null
if (this.getShapeUtil(shape).providesBackgroundForChildren(shape)) {
backgroundIndexToRestore = nextBackgroundIndex
nextBackgroundIndex = nextIndex
nextIndex += MAX_SHAPES_PER_PAGE
}
for (const childId of childIds) {
addShapeById(childId, opacity, isAncestorErasing || isShapeErasing)
}
if (backgroundIndexToRestore !== null) {
nextBackgroundIndex = backgroundIndexToRestore
}
}
for (const id of ids) {
addShapeById(id, 1, false)
2023-04-25 11:01:25 +00:00
}
return renderingShapes
}
2023-04-25 11:01:25 +00:00
/**
* Get the shapes that should be displayed in the current viewport.
*
* @public
*/
@computed get renderingShapes() {
const renderingShapes = this.computeUnorderedRenderingShapes([this.currentPageId], {
cullingBounds: this.cullingBounds,
cullingBoundsExpanded: this.cullingBoundsExpanded,
erasingIdsSet: this.erasingIdsSet,
editingId: this.editingId,
})
// Its IMPORTANT that the result be sorted by id AND include the index
// that the shape should be displayed at. Steve, this is the past you
// telling the present you not to change this.
// We want to sort by id because moving elements about in the DOM will
// cause the element to get removed by react as it moves the DOM node. This
// causes <iframes/> to re-render which is hella annoying and a perf
// drain. By always sorting by 'id' we keep the shapes always in the
// same order; but we later use index to set the element's 'z-index'
// to change the "rendered" position in z-space.
2023-04-25 11:01:25 +00:00
return renderingShapes.sort(sortById)
}
/**
* The common bounds of all of the shapes on the page.
*
* @public
*/
@computed get allShapesCommonBounds(): Box2d | null {
let commonBounds = null as Box2d | null
this.shapeIds.forEach((shapeId) => {
const bounds = this.getMaskedPageBoundsById(shapeId)
if (bounds) {
if (commonBounds) {
commonBounds.expand(bounds)
} else {
commonBounds = bounds.clone()
}
}
})
return commonBounds
}
/**
* Get the corners of a shape in page space.
*
* @example
*
* ```ts
* const corners = app.getPageCorners(myShape)
* ```
*
* @param shape - The shape to get the corners for.
* @public
*/
getPageCorners(shape: TLShape): Vec2d[] {
const ancestors = this.getAncestors(shape)
const corners = this.getBounds(shape).corners
const transform = Matrix2d.Compose(
...ancestors.flatMap((s) => [Matrix2d.Translate(s.x, s.y), Matrix2d.Rotate(s.rotation)]),
Matrix2d.Translate(shape.x, shape.y),
Matrix2d.Rotate(shape.rotation, 0, 0)
)
return Matrix2d.applyToPoints(transform, corners)
}
/**
* Test whether a point (in page space) will will a shape. This method takes into account masks,
* such as when a shape is the child of a frame and is partially clipped by the frame.
*
* @example
*
* ```ts
* app.isPointInShape({ x: 100, y: 100 }, myShape)
* ```
*
* @param point - The page point to test.
* @param shape - The shape to test against.
* @public
*/
isPointInShape(point: VecLike, shape: TLShape): boolean {
const util = this.getShapeUtil(shape)
const pageMask = this._pageMaskCache.get(shape.id)
if (pageMask) {
const hit = pointInPolygon(point, pageMask)
if (!hit) return false
}
return util.hitTestPoint(shape, this.getPointInShapeSpace(shape, point))
}
/**
* Get the shapes, if any, at a given page point.
*
* @example
*
* ```ts
* app.getShapesAtPoint({ x: 100, y: 100 })
* ```
*
* @param point - The page point to test.
* @public
*/
getShapesAtPoint(point: VecLike): TLShape[] {
return this.shapesArray.filter((shape) => {
// Check the page mask too
2023-04-25 11:01:25 +00:00
const pageMask = this._pageMaskCache.get(shape.id)
if (pageMask) {
return pointInPolygon(point, pageMask)
2023-04-25 11:01:25 +00:00
}
// Otherwise, use the shape's own hit test method
2023-04-25 11:01:25 +00:00
return this.getShapeUtil(shape).hitTestPoint(shape, this.getPointInShapeSpace(shape, point))
})
}
/**
* Convert a point in page space to a point in the local space of a shape. For example, if a
* shape's page point were `{ x: 100, y: 100 }`, a page point at `{ x: 110, y: 110 }` would be at
* `{ x: 10, y: 10 }` in the shape's local space.
*
* @example
*
* ```ts
* app.getPointInShapeSpace(myShape, { x: 100, y: 100 })
* ```
*
* @param shape - The shape to get the point in the local space of.
* @param point - The page point to get in the local space of the shape.
* @public
*/
getPointInShapeSpace(shape: TLShape, point: VecLike): Vec2d {
return Matrix2d.applyToPoint(Matrix2d.Inverse(this.getPageTransform(shape)!), point)
}
/**
* Convert a delta in page space to a point in the local space of a shape. For example, if a
* shape's page point were `{ x: 100, y: 100 }`, a page point at `{ x: 110, y: 110 }` would be at
* `{ x: 10, y: 10 }` in the shape's local space.
*
* @example
*
* ```ts
* app.getPointInShapeSpace(myShape.id, { x: 100, y: 100 })
* ```
*
* @param shape - The shape to get the point in the local space of.
* @param point - The page point to get in the local space of the shape.
* @public
*/
getPointInParentSpace(shapeId: TLShapeId, point: VecLike): Vec2d {
const shape = this.getShapeById(shapeId)!
if (!shape) {
return new Vec2d(0, 0)
}
if (isPageId(shape.parentId)) return Vec2d.From(point)
2023-04-25 11:01:25 +00:00
const parentTransform = this.getPageTransformById(shape.parentId)
if (!parentTransform) return Vec2d.From(point)
return Matrix2d.applyToPoint(Matrix2d.Inverse(parentTransform), point)
}
/**
* Convert a delta in page space to a delta in the local space of a shape.
*
* @example
*
* ```ts
* app.getDeltaInShapeSpace(myShape, { x: 100, y: 100 })
* ```
*
* @param shape - The shape to get the delta in the local space of.
* @param delta - The page delta to convert.
* @public
*/
getDeltaInShapeSpace(shape: TLShape, delta: VecLike): Vec2d {
const pageTransform = this.getPageTransform(shape)
if (!pageTransform) return Vec2d.From(delta)
return Vec2d.Rot(delta, -Matrix2d.Decompose(pageTransform).rotation)
}
/**
* Convert a delta in page space to a delta in the parent space of a shape.
*
* @example
*
* ```ts
* app.getDeltaInParentSpace(myShape, { x: 100, y: 100 })
* ```
*
* @param shape - The shape to get the delta in the parent space of.
* @param delta - The page delta to convert.
* @public
*/
getDeltaInParentSpace(shape: TLShape, delta: VecLike): Vec2d {
if (isPageId(shape.parentId)) return Vec2d.From(delta)
2023-04-25 11:01:25 +00:00
const parent = this.getShapeById(shape.parentId)
if (!parent) return Vec2d.From(delta)
return this.getDeltaInShapeSpace(parent, delta)
}
/**
* For a given set of ids, get a map containing the ids of their parents and the children of those
* parents.
*
* @example
*
* ```ts
* app.getParentsMappedToChildren(['id1', 'id2', 'id3'])
* ```
*
* @param ids - The ids to get the parents and children of.
* @public
*/
getParentsMappedToChildren(ids: TLShapeId[]) {
const shapes = ids.map((id) => this.store.get(id)!)
const parents = new Map<TLParentId, Set<TLShape>>()
shapes.forEach((shape) => {
if (!parents.has(shape.parentId)) {
parents.set(shape.parentId, new Set())
}
parents.get(shape.parentId)?.add(shape)
})
return parents
}
/* -------------------- Viewport -------------------- */
/**
* Update the viewport. The viewport will measure the size and screen position of its container
* element. This should be done whenever the container's position on the screen changes.
*
* @example
*
* ```ts
* app.updateViewportScreenBounds()
* ```
*
* @param center - Whether to preserve the viewport page center as the viewport changes.
* (optional)
* @public
*/
updateViewportScreenBounds(center = false) {
const container = this.getContainer()
if (!container) return this
const rect = container.getBoundingClientRect()
const screenBounds = new Box2d(0, 0, Math.max(rect.width, 1), Math.max(rect.height, 1))
const boundsAreEqual = screenBounds.equals(this.viewportScreenBounds)
// Get the current value
const { _willSetInitialBounds } = this
if (boundsAreEqual) {
this._willSetInitialBounds = false
} else {
if (_willSetInitialBounds) {
// If we have just received the initial bounds, don't center the camera.
this._willSetInitialBounds = false
this.updateInstanceState({ screenBounds: screenBounds.toJson() }, true, true)
} else {
const { zoomLevel } = this
if (center) {
const before = this.viewportPageCenter
this.updateInstanceState({ screenBounds: screenBounds.toJson() }, true, true)
const after = this.viewportPageCenter
if (!this.instanceState.followingUserId) {
this.pan((after.x - before.x) * zoomLevel, (after.y - before.y) * zoomLevel)
}
} else {
const before = this.screenToPage(0, 0)
this.updateInstanceState({ screenBounds: screenBounds.toJson() }, true, true)
const after = this.screenToPage(0, 0)
if (!this.instanceState.followingUserId) {
this.pan((after.x - before.x) * zoomLevel, (after.y - before.y) * zoomLevel)
}
}
}
}
this._cameraManager.tick()
this.updateCullingBounds()
const { editingId } = this
if (editingId) {
this.panZoomIntoView([editingId])
}
return this
}
/**
* The bounds of the editor's viewport in screen space.
*
* @public
*/
@computed get viewportScreenBounds() {
const { x, y, w, h } = this.instanceState.screenBounds
return new Box2d(x, y, w, h)
}
/**
* The center of the editor's viewport in screen space.
*
* @public
*/
@computed get viewportScreenCenter() {
return this.viewportScreenBounds.center
}
/**
* The current viewport in page space.
*
* @public
*/
@computed get viewportPageBounds() {
const { x, y, w, h } = this.viewportScreenBounds
const tl = this.screenToPage(x, y)
const br = this.screenToPage(x + w, y + h)
return new Box2d(tl.x, tl.y, br.x - tl.x, br.y - tl.y)
}
/**
* The current culling bounds in page space, used for checking which shapes are "on screen".
*
* @public
*/
@computed get cullingBounds() {
return this._cullingBounds.value
}
/** @internal */
readonly _cullingBounds = atom('culling viewport', new Box2d())
/**
* The current culling bounds in page space, expanded slightly. Used for determining which shapes
* to render and which to "cull".
*
* @public
*/
@computed get cullingBoundsExpanded() {
return this._cullingBoundsExpanded.value
}
/** @internal */
readonly _cullingBoundsExpanded = atom('culling viewport expanded', new Box2d())
/**
* Update the culling bounds. This should be called when the viewport has stopped changing, such
* as at the end of a pan, zoom, or animation.
*
* @example
*
* ```ts
* app.updateCullingBounds()
* ```
*
* @internal
*/
updateCullingBounds(): this {
const { viewportPageBounds } = this
if (viewportPageBounds.equals(this._cullingBounds.__unsafe__getWithoutCapture())) return this
this._cullingBounds.set(viewportPageBounds.clone())
this._cullingBoundsExpanded.set(viewportPageBounds.clone().expandBy(100 / this.zoomLevel))
return this
}
/**
* The center of the viewport in page space.
*
* @public
*/
@computed get viewportPageCenter() {
return this.viewportPageBounds.center
}
/**
* Convert a point in screen space to a point in page space.
*
* @example
*
* ```ts
* app.screenToPage(100, 100)
* ```
*
* @param x - The x coordinate of the point in screen space.
* @param y - The y coordinate of the point in screen space.
* @param camera - The camera to use. Defaults to the current camera.
* @public
*/
screenToPage(x: number, y: number, z = 0.5, camera: Vec2dModel = this.camera) {
const { screenBounds } = this.store.unsafeGetWithoutCapture(this.instanceId)!
const { x: cx, y: cy, z: cz = 1 } = camera
return {
x: (x - screenBounds.x) / cz - cx,
y: (y - screenBounds.y) / cz - cy,
z,
}
}
/**
* Convert a point in page space to a point in screen space.
*
* @example
*
* ```ts
* app.pageToScreen(100, 100)
* ```
*
* @param x - The x coordinate of the point in screen space.
* @param y - The y coordinate of the point in screen space.
* @param camera - The camera to use. Defaults to the current camera.
* @public
*/
pageToScreen(x: number, y: number, z = 0.5, camera: Vec2dModel = this.camera) {
const { x: cx, y: cy, z: cz = 1 } = camera
return {
x: x + cx * cz,
y: y + cy * cz,
z,
}
}
/* Focus Layers */
get focusLayerId() {
return this.pageState.focusLayerId ?? this.currentPageId
}
get focusLayerShape(): TLShape | undefined {
const id = this.pageState.focusLayerId
if (!id) {
return
}
return this.getShapeById(id)
}
popFocusLayer() {
const current = this.pageState.focusLayerId
const focusedShape = current && this.getShapeById(current)
if (focusedShape) {
// If we have a focused layer, look for an ancestor of the focused shape that is a group
const match = this.findAncestor(focusedShape, (s) => s.type === 'group')
// If we have an ancestor that can become a focused layer, set it as the focused layer
this.setFocusLayer(match?.id ?? null)
this.select(focusedShape.id)
} else {
// If there's no focused shape, then clear the focus layer and clear selection
this.setFocusLayer(null)
this.selectNone()
}
return this
}
/**
* Set the focus layer to the given shape id.
*
* @param next - The next focus layer id or null to reset the focus layer to the page
* @public
*/
setFocusLayer(next: null | TLShapeId) {
this._setFocusLayer(next)
return this
}
/** @internal */
private _setFocusLayer = this.history.createCommand(
'setFocusLayer',
(next: null | TLShapeId) => {
// When we first click an empty canvas we don't want this to show up in the undo stack
if (next === null && !this.canUndo) {
return
}
const prev = this.pageState.focusLayerId
return { data: { prev, next }, preservesRedoStack: true, squashing: true }
},
{
do: ({ next }) => {
this.store.update(this.pageState.id, (s) => ({ ...s, focusLayerId: next }))
},
undo: ({ prev }) => {
this.store.update(this.pageState.id, (s) => ({ ...s, focusLayerId: prev }))
},
squash({ prev }, { next }) {
return { prev, next }
},
}
)
/**
* Set the hinted shape ids.
*
* @param ids - The ids to set as hinted.
* @public
*/
setHintingIds(ids: TLShapeId[]): this {
// always ephemeral
this.store.update(this.pageState.id, (s) => ({ ...s, hintingIds: dedupe(ids) }))
return this
}
/**
* The current editing shape's id.
*
* @public
*/
get editingId() {
return this.pageState.editingId
}
/**
* The current cropping shape's id.
*
* @public
*/
get croppingId() {
return this.pageState.croppingId
}
@computed get editingShape() {
if (!this.editingId) return null
return this.getShapeById(this.editingId) ?? null
}
/**
* Set the current editing id.
*
* @param id - The id of the shape to edit or null to clear the editing id.
* @public
*/
setEditingId(id: TLShapeId | null): this {
if (!id) {
this.setInstancePageState({ editingId: null })
} else {
if (id !== this.editingId) {
const shape = this.getShapeById(id)!
const util = this.getShapeUtil(shape)
if (shape && util.canEdit(shape)) {
this.setInstancePageState({ editingId: id, hoveredId: null }, false)
const { viewportPageBounds } = this
const localEditingBounds = util.getEditingBounds(shape)!
const pageTransform = this.getPageTransformById(id)!
const pageEditingBounds = Box2d.FromPoints(
Matrix2d.applyToPoints(pageTransform, localEditingBounds.corners)
)
if (!viewportPageBounds.contains(pageEditingBounds)) {
if (
pageEditingBounds.width > viewportPageBounds.width ||
pageEditingBounds.height > viewportPageBounds.height
) {
this.zoomToBounds(
pageEditingBounds.minX,
pageEditingBounds.minY,
pageEditingBounds.width,
pageEditingBounds.height
)
} else {
this.centerOnPoint(pageEditingBounds.midX, pageEditingBounds.midY)
}
}
}
}
}
return this
}
setCroppingId(id: TLShapeId | null): this {
if (id !== this.croppingId) {
if (!id) {
this.setInstancePageState({ croppingId: null })
if (this.isInAny('select.crop', 'select.pointing_crop_handle', 'select.cropping')) {
this.setSelectedTool('select.idle')
}
} else {
const shape = this.getShapeById(id)!
const util = this.getShapeUtil(shape)
if (shape && util.canCrop(shape)) {
this.setInstancePageState({ croppingId: id, hoveredId: null })
}
}
}
return this
}
getParentIdForNewShapeAtPoint(point: VecLike, shapeType: TLShape['type']) {
2023-04-25 11:01:25 +00:00
const shapes = this.sortedShapesArray
for (let i = shapes.length - 1; i >= 0; i--) {
const shape = shapes[i]
const util = this.getShapeUtil(shape)
if (!util.canReceiveNewChildrenOfType(shape, shapeType)) continue
2023-04-25 11:01:25 +00:00
const maskedPageBounds = this.getMaskedPageBoundsById(shape.id)
if (
maskedPageBounds &&
maskedPageBounds.containsPoint(point) &&
util.hitTestPoint(shape, this.getPointInShapeSpace(shape, point))
) {
return shape.id
}
}
return this.focusLayerId
}
getDroppingShape(point: VecLike, droppingShapes: TLShape[] = []) {
const shapes = this.sortedShapesArray
for (let i = shapes.length - 1; i >= 0; i--) {
const shape = shapes[i]
// don't allow dropping a shape on itself or one of it's children
if (droppingShapes.find((s) => s.id === shape.id || this.hasAncestor(shape, s.id))) continue
const util = this.getShapeUtil(shape)
if (!util.canDropShapes(shape, droppingShapes)) continue
const maskedPageBounds = this.getMaskedPageBoundsById(shape.id)
if (
maskedPageBounds &&
maskedPageBounds.containsPoint(point) &&
util.hitTestPoint(shape, this.getPointInShapeSpace(shape, point))
) {
return shape
}
}
return undefined
}
// This returns the node that should be selected when you click on this one, assuming there is nothing
// already selected. It will not return anything higher than or including the current focus layer.
getOutermostSelectableShape(shape: TLShape, filter?: (shape: TLShape) => boolean): TLShape {
let match = shape
let node = shape as TLShape | undefined
while (node) {
if (
node.type === 'group' &&
this.focusLayerId !== node.id &&
!this.hasAncestor(this.focusLayerShape, node.id) &&
(filter?.(node) ?? true)
) {
match = node
} else if (this.focusLayerId === node.id) {
break
}
node = this.getParentShape(node)
}
return match
}
/* --------------------- Shapes --------------------- */
/**
* The app's set of styles.
*
* @public
*/
static styles = STYLES
/**
* The current page bounds of all the selected shapes (Not the same thing as the page bounds of
* the selection bounding box when the selection has been rotated)
*
* @readonly
* @public
*/
@computed get selectedPageBounds(): Box2d | null {
const {
pageState: { selectedIds },
} = this
if (selectedIds.length === 0) return null
return Box2d.Common(compact(selectedIds.map((id) => this.getPageBoundsById(id))))
}
/** The rotation of the selection bounding box. */
@computed get selectionRotation(): number {
const { selectedIds } = this
if (selectedIds.length === 0) {
return 0
}
if (selectedIds.length === 1) {
return this.getPageRotationById(this.selectedIds[0])
}
const allRotations = selectedIds.map((id) => this.getPageRotationById(id) % (Math.PI / 2))
// if the rotations are all compatible with each other, return the rotation of any one of them
if (allRotations.every((rotation) => Math.abs(rotation - allRotations[0]) < Math.PI / 180)) {
return this.getPageRotationById(selectedIds[0])
}
return 0
}
@computed get selectionBounds(): Box2d | undefined {
const { selectedIds } = this
if (selectedIds.length === 0) {
return undefined
}
const { selectionRotation } = this
if (selectionRotation === 0) {
return this.selectedPageBounds!
}
if (selectedIds.length === 1) {
const bounds = this.getBounds(this.getShapeById(selectedIds[0])!).clone()
bounds.point = Matrix2d.applyToPoint(this.getPageTransformById(selectedIds[0])!, bounds.point)
return bounds
}
// need to 'un-rotate' all the outlines of the existing nodes so we can fit them inside a box
const allPoints = this.selectedIds
.flatMap((id) => {
const pageTransform = this.getPageTransformById(id)
if (!pageTransform) return []
return this.getOutlineById(id).map((point) => Matrix2d.applyToPoint(pageTransform, point))
})
.map((p) => Vec2d.Rot(p, -selectionRotation))
const box = Box2d.FromPoints(allPoints)
// now position box so that it's top-left corner is in the right place
box.point = box.point.rot(selectionRotation)
return box
}
@computed get selectionPageCenter() {
const { selectionBounds, selectionRotation } = this
if (!selectionBounds) return null
return Vec2d.RotWith(selectionBounds.center, selectionBounds.point, selectionRotation)
}
/**
* An array containing all of the shapes in the current page.
*
* @example
*
* ```ts
* app.shapesArray
* ```
*
* @readonly
* @public
*/
@computed get shapesArray() {
return Array.from(this.shapeIds, (id) => this.store.get(id)! as TLShape)
2023-04-25 11:01:25 +00:00
}
/**
* An array containing all of the shapes in the current page, sorted in z-index order (accounting
* for nested shapes): e.g. A, B, BA, BB, C.
*
* @example
*
* ```ts
* app.sortedShapesArray
* ```
*
* @readonly
* @public
*/
@computed get sortedShapesArray(): TLShape[] {
const shapes = new Set(this.shapesArray.sort(sortByIndex))
const results: TLShape[] = []
function pushShapeWithDescendants(shape: TLShape): void {
results.push(shape)
shapes.delete(shape)
shapes.forEach((otherShape) => {
if (otherShape.parentId === shape.id) {
pushShapeWithDescendants(otherShape)
}
})
}
shapes.forEach((shape) => {
const parent = this.getShapeById(shape.parentId)
if (!isShape(parent)) {
pushShapeWithDescendants(shape)
}
})
return results
}
/**
* An array containing all of the currently selected shapes.
*
* @example
*
* ```ts
* app.selectedShapes
* ```
*
* @public
* @readonly
*/
@computed get selectedShapes(): TLShape[] {
2023-04-25 11:01:25 +00:00
const { selectedIds } = this.pageState
return compact(selectedIds.map((id) => this.store.get(id)))
}
/**
* The app's only selected shape.
*
* @example
*
* ```ts
* app.onlySelectedShape
* ```
*
* @returns Null if there is no shape or more than one selected shape, otherwise the selected
* shape.
* @public
* @readonly
*/
@computed get onlySelectedShape(): TLShape | null {
2023-04-25 11:01:25 +00:00
const { selectedShapes } = this
return selectedShapes.length === 1 ? selectedShapes[0] : null
}
/**
* Get whether a shape matches the type of a TLShapeUtil.
*
* @example
*
* ```ts
* const isArrowShape = isShapeOfType(someShape, TLArrowUtil)
* ```
*
* @param util - the TLShapeUtil constructor to test against
* @param shape - the shape to test
*
* @public
*/
isShapeOfType<T extends TLUnknownShape>(
shape: TLUnknownShape,
util: { new (...args: any): TLShapeUtil<T>; type: string }
): shape is T {
return shape.type === util.type
}
2023-04-25 11:01:25 +00:00
/**
* Get a shape by its id.
*
* @example
*
* ```ts
* app.getShapeById('box1')
* ```
*
* @param id - The id of the shape to get.
* @public
*/
getShapeById<T extends TLShape = TLShape>(id: TLParentId): T | undefined {
if (!isShapeId(id)) return undefined
return this.store.get(id) as T
}
/**
* Get the parent shape for a given shape. Returns undefined if the shape is the direct child of
* the page.
*
* @example
*
* ```ts
* app.getParentShape(myShape)
* ```
*
* @public
*/
getParentShape(shape?: TLShape): TLShape | undefined {
if (shape === undefined || !isShapeId(shape.parentId)) return undefined
return this.store.get(shape.parentId)
}
/**
* If siblingShape and targetShape are siblings, this returns targetShape. If targetShape has an
* ancestor who is a sibling of siblingShape, this returns that ancestor. Otherwise, this returns
* undefined
*/
private getNearestSiblingShape(
siblingShape: TLShape,
targetShape: TLShape | undefined
): TLShape | undefined {
if (!targetShape) {
return undefined
}
if (targetShape.parentId === siblingShape.parentId) {
return targetShape
}
const ancestor = this.findAncestor(
targetShape,
(ancestor) => ancestor.parentId === siblingShape.parentId
)
return ancestor
}
/** Get the id of the containing page for a given shape. */
getParentPageId(shape?: TLShape): TLPageId | undefined {
if (shape === undefined) return undefined
if (isPageId(shape.parentId)) {
2023-04-25 11:01:25 +00:00
return shape.parentId
} else {
return this.getParentPageId(this.getShapeById(shape.parentId))
}
}
/**
* Get whether the given shape is the descendant of the given page.
*
* @example
*
* ```ts
* app.isShapeInPage(myShape)
* app.isShapeInPage(myShape, 'page1')
* ```
*
* @param shape - The shape to check.
* @param pageId - The id of the page to check against. Defaults to the current page.
* @public
*/
isShapeInPage(shape: TLShape, pageId = this.currentPageId): boolean {
let shapeIsInPage = false
if (shape.parentId === pageId) {
shapeIsInPage = true
} else {
let parent = this.getShapeById(shape.parentId)
isInPageSearch: while (parent) {
if (parent.parentId === pageId) {
shapeIsInPage = true
break isInPageSearch
}
parent = this.getShapeById(parent.parentId)
}
}
return shapeIsInPage
}
/* --------------------- Styles --------------------- */
/**
* A mapping of color ids to CSS color values.
*
* @internal
*/
private colors: Map<TLColorStyle['id'], string>
/**
* A mapping of size ids to size values.
*
* @internal
*/
private sizes = {
s: 2,
m: 3.5,
l: 5,
xl: 10,
}
/**
* Get the CSS color value for a given color id.
*
* @example
*
* ```ts
* app.getCssColor('red')
* ```
*
* @param id - The id of the color to get.
* @public
*/
getCssColor(id: TLColorStyle['id']): string {
return this.colors.get(id)!
}
/**
* Get the stroke width value for a given size id.
*
* @example
*
* ```ts
* app.getStrokeWidth('m')
* ```
*
* @param id - The id of the size to get.
* @public
*/
getStrokeWidth(id: TLSizeStyle['id']): number {
return this.sizes[id]
}
/* ------------------- Statechart ------------------- */
/**
* The id of the current selected tool.
*
* @public
*/
get currentToolId(): string {
const activeTool = this.root.current.value
let activeToolId = activeTool?.id
// Often a tool will transition into one of the following select states after the initial pointerdown: 'translating', 'resizing', 'dragging_handle'
// It should then supply the tool id to the `onInteractionEnd` property to tell us which tool initially triggered the interaction.
// If tool lock mode is on then tldraw will switch to the given tool id.
// If tool lock mode is off then tldraw will switch back to the select tool when the interaction ends.
if (activeToolId === 'select' || activeToolId === 'zoom') {
const currentChildState = activeTool?.current.value as any
activeToolId = currentChildState?.info?.onInteractionEnd ?? 'select'
}
return activeToolId ?? 'select'
}
/**
* Set the selected tool.
*
* @example
*
* ```ts
* app.setSelectedTool('hand')
* app.setSelectedTool('hand', { date: Date.now() })
* ```
*
* @param id - The id of the tool to select.
* @param info - Arbitrary data to pass along into the transition.
* @public
*/
setSelectedTool(id: string, info = {}) {
this.root.transition(id, info)
return this
}
/**
* Get a descendant by its path.
*
* @example
*
* ```ts
* state.getStateDescendant('select')
* state.getStateDescendant('select.brushing')
* ```
*
* @param path - The descendant's path of state ids, separated by periods.
* @public
*/
getStateDescendant(path: string): StateNode | undefined {
const ids = path.split('.').reverse()
let state = this.root as StateNode
while (ids.length > 0) {
const id = ids.pop()
if (!id) return state
const childState = state.children?.[id]
if (!childState) return undefined
state = childState
}
return state
}
/**
* Get whether a certain tool (or other state node) is currently active.
*
* @example
*
* ```ts
* app.isIn('select')
* app.isIn('select.brushing')
* ```
*
* @param path - The path of active states, separated by periods.
* @public
*/
isIn(path: string): boolean {
const ids = path.split('.').reverse()
let state = this.root as StateNode
while (ids.length > 0) {
const id = ids.pop()
if (!id) return true
const current = state.current.value
if (current?.id === id) {
if (ids.length === 0) return true
state = current
continue
} else return false
}
return false
}
/**
* Get whether the state node is in any of the given active paths.
*
* @example
*
* ```ts
* state.isInAny('select', 'erase')
* state.isInAny('select.brushing', 'erase.idle')
* ```
*
* @public
*/
isInAny(...paths: string[]): boolean {
return paths.some((path) => this.isIn(path))
}
/* --------------------- Inputs --------------------- */
/**
* The app's current input state.
*
* @public
*/
inputs = {
/** The most recent pointer down's position in page space. */
originPagePoint: new Vec2d(),
/** The most recent pointer down's position in screen space. */
originScreenPoint: new Vec2d(),
/** The previous pointer position in page space. */
previousPagePoint: new Vec2d(),
/** The previous pointer position in screen space. */
previousScreenPoint: new Vec2d(),
/** The most recent pointer position in page space. */
currentPagePoint: new Vec2d(),
/** The most recent pointer position in screen space. */
currentScreenPoint: new Vec2d(),
/** A set containing the currently pressed keys. */
keys: new Set<string>(),
/** A set containing the currently pressed buttons. */
buttons: new Set<number>(),
/** Whether the input is from a pe. */
isPen: false,
/** Whether the shift key is currently pressed. */
shiftKey: false,
/** Whether the control or command key is currently pressed. */
ctrlKey: false,
/** Whether the alt or option key is currently pressed. */
altKey: false,
/** Whether the user is dragging. */
isDragging: false,
/** Whether the user is pointing. */
isPointing: false,
/** Whether the user is pinching. */
isPinching: false,
/** Whether the user is editing. */
isEditing: false,
/** Whether the user is panning. */
isPanning: false,
/** Veclocity of mouse pointer, in pixels per millisecond */
pointerVelocity: new Vec2d(),
}
/**
* Update the input points from a pointer or pinch event.
*
* @param info - The event info.
* @internal
*/
private _updateInputsFromEvent(info: TLPointerEventInfo | TLPinchEventInfo) {
const { previousScreenPoint, previousPagePoint, currentScreenPoint, currentPagePoint } =
this.inputs
const { screenBounds } = this.store.unsafeGetWithoutCapture(this.instanceId)!
const { x: sx, y: sy, z: sz } = info.point
const { x: cx, y: cy, z: cz } = this.camera
previousScreenPoint.setTo(currentScreenPoint)
previousPagePoint.setTo(currentPagePoint)
const px = (sx - screenBounds.x) / cz - cx
const py = (sy - screenBounds.y) / cz - cy
currentScreenPoint.set(sx, sy)
currentPagePoint.set(px, py, sz ?? 0.5)
this.inputs.isPen = info.type === 'pointer' && info.isPen
// Reset velocity on pointer down
if (info.name === 'pointer_down') {
this.inputs.pointerVelocity = new Vec2d()
}
// todo: We only have to do this if there are multiple users in the document
this.store.put([
{
id: TLPOINTER_ID,
typeName: 'pointer',
x: currentPagePoint.x,
y: currentPagePoint.y,
lastActivityTimestamp: Date.now(),
},
])
2023-04-25 11:01:25 +00:00
}
/* --------------------- Events --------------------- */
/**
* A manager for recording multiple click events.
*
* @internal
*/
protected _clickManager = new ClickManager(this)
/**
* Prevent a double click event from firing the next time the user clicks
*
* @public
*/
cancelDoubleClick() {
this._clickManager.cancelDoubleClickTimeout()
}
/**
* The previous cursor. Used for restoring the cursor after pan events.
*
* @internal
*/
private _prevCursor: TLCursorType = 'default'
/** @internal */
private _shiftKeyTimeout = -1 as any
/** @internal */
private _setShiftKeyTimeout = () => {
this.inputs.shiftKey = false
this.dispatch({
type: 'keyboard',
name: 'key_up',
key: 'Shift',
shiftKey: this.inputs.shiftKey,
ctrlKey: this.inputs.ctrlKey,
altKey: this.inputs.altKey,
code: 'ShiftLeft',
})
}
/** @internal */
private _altKeyTimeout = -1 as any
/** @internal */
private _setAltKeyTimeout = () => {
this.inputs.altKey = false
this.dispatch({
type: 'keyboard',
name: 'key_up',
key: 'Alt',
shiftKey: this.inputs.shiftKey,
ctrlKey: this.inputs.ctrlKey,
altKey: this.inputs.altKey,
code: 'AltLeft',
})
}
/** @internal */
private _ctrlKeyTimeout = -1 as any
/** @internal */
private _setCtrlKeyTimeout = () => {
this.inputs.ctrlKey = false
this.dispatch({
type: 'keyboard',
name: 'key_up',
key: 'Ctrl',
shiftKey: this.inputs.shiftKey,
ctrlKey: this.inputs.ctrlKey,
altKey: this.inputs.altKey,
code: 'CtrlLeft',
})
}
/** @internal */
private _restoreToolId = 'select'
/** @internal */
private _pinchStart = 1
/** @internal */
private _didPinch = false
/** @internal */
private _selectedIdsAtPointerDown: TLShapeId[] = []
/** @internal */
capturedPointerId: number | null = null
2023-04-25 11:01:25 +00:00
/**
* Dispatch an event to the app.
*
* @example
*
* ```ts
* app.dispatch(myPointerEvent)
* ```
*
* @param info - The event info.
* @public
*/
dispatch = (info: TLEventInfo): this => {
// prevent us from spamming similar event errors if we're crashed.
// todo: replace with new readonly mode?
if (this.crashingError) return this
const { inputs } = this
const { type } = info
this.batch(() => {
if (info.type === 'misc') {
// stop panning if the interaction is cancelled or completed
if (info.name === 'cancel' || info.name === 'complete') {
this.inputs.isDragging = false
if (this.inputs.isPanning) {
this.inputs.isPanning = false
this.setCursor({
type: this._prevCursor,
})
}
}
this.root.handleEvent(info)
return
}
if (info.shiftKey) {
clearInterval(this._shiftKeyTimeout)
this._shiftKeyTimeout = -1
inputs.shiftKey = true
} else if (!info.shiftKey && inputs.shiftKey && this._shiftKeyTimeout === -1) {
this._shiftKeyTimeout = setTimeout(this._setShiftKeyTimeout, 150)
}
if (info.altKey) {
clearInterval(this._altKeyTimeout)
this._altKeyTimeout = -1
inputs.altKey = true
} else if (!info.altKey && inputs.altKey && this._altKeyTimeout === -1) {
this._altKeyTimeout = setTimeout(this._setAltKeyTimeout, 150)
}
if (info.ctrlKey) {
clearInterval(this._ctrlKeyTimeout)
this._ctrlKeyTimeout = -1
inputs.ctrlKey = true /** @internal */ /** @internal */ /** @internal */
} else if (!info.ctrlKey && inputs.ctrlKey && this._ctrlKeyTimeout === -1) {
this._ctrlKeyTimeout = setTimeout(this._setCtrlKeyTimeout, 150)
}
const { originPagePoint, originScreenPoint, currentPagePoint, currentScreenPoint } = inputs
if (!inputs.isPointing) {
inputs.isDragging = false
}
switch (type) {
case 'pinch': {
if (!this.canMoveCamera) return
this._updateInputsFromEvent(info)
switch (info.name) {
case 'pinch_start': {
if (inputs.isPinching) return
if (!inputs.isEditing) {
this._pinchStart = this.camera.z
if (!this._selectedIdsAtPointerDown.length) {
this._selectedIdsAtPointerDown = this.selectedIds.slice()
}
this._didPinch = true
inputs.isPinching = true
this.interrupt()
}
return // Stop here!
}
case 'pinch': {
if (!inputs.isPinching) return
const {
point: { x, y, z = 1 },
delta: { x: dx, y: dy },
} = info
const {
camera: { x: cx, y: cy, z: cz },
} = this
const zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z))
this.setCamera(
cx + dx / cz - x / cz + x / zoom,
cy + dy / cz - y / cz + y / zoom,
zoom
)
return // Stop here!
}
case 'pinch_end': {
if (!inputs.isPinching) return this
inputs.isPinching = false
const { _selectedIdsAtPointerDown } = this
this.setSelectedIds(this._selectedIdsAtPointerDown, true)
this._selectedIdsAtPointerDown = []
const {
camera: { x: cx, y: cy, z: cz },
} = this
let zoom: number | undefined
if (cz > 0.9 && cz < 1.05) {
zoom = 1
} else if (cz > 0.49 && cz < 0.505) {
zoom = 0.5
}
if (cz > this._pinchStart - 0.1 && cz < this._pinchStart + 0.05) {
zoom = this._pinchStart
}
if (zoom !== undefined) {
const { x, y } = this.viewportScreenCenter
this.animateCamera(
cx + (x / zoom - x) - (x / cz - x),
cy + (y / zoom - y) - (y / cz - y),
zoom,
{ duration: 100 }
)
}
if (this._didPinch) {
this._didPinch = false
requestAnimationFrame(() => {
if (!this._didPinch) {
this.setSelectedIds(_selectedIdsAtPointerDown, true)
}
})
}
return // Stop here!
}
}
}
case 'wheel': {
if (!this.canMoveCamera) return
if (this.isMenuOpen) {
// noop
} else {
if (inputs.ctrlKey) {
// todo: Start or update the zoom end interval
// If the alt or ctrl keys are pressed,
// zoom or pan the camera and then return.
const { x, y } = this.inputs.currentScreenPoint
const { x: cx, y: cy, z: cz } = this.camera
const zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, cz + (info.delta.z ?? 0) * cz))
this.setCamera(
cx + (x / zoom - x) - (x / cz - x),
cy + (y / zoom - y) - (y / cz - y),
zoom
)
// We want to return here because none of the states in our
// statechart should respond to this event (a camera zoom)
return
}
// Update the camera here, which will dispatch a pointer move...
// this will also update the pointer position, etc
this.pan(info.delta.x, info.delta.y)
if (
!inputs.isDragging &&
inputs.isPointing &&
originPagePoint.dist(currentPagePoint) >
(this.isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE) / this.zoomLevel
2023-04-25 11:01:25 +00:00
) {
inputs.isDragging = true
}
}
break
}
case 'pointer': {
// If we're pinching, return
if (inputs.isPinching) return
this._updateInputsFromEvent(info)
const { isPen } = info
switch (info.name) {
case 'pointer_down': {
this._selectedIdsAtPointerDown = this.selectedIds.slice()
// Firefox bug fix...
// If it's a left-mouse-click, we store the pointer id for later user
if (info.button === 0) {
this.capturedPointerId = info.pointerId
}
2023-04-25 11:01:25 +00:00
// Add the button from the buttons set
inputs.buttons.add(info.button)
inputs.isPointing = true
inputs.isDragging = false
if (this.isPenMode) {
if (!isPen) {
// decrement the remaining taps before exiting pen mode
this._touchEventsRemainingBeforeExitingPenMode--
if (this._touchEventsRemainingBeforeExitingPenMode === 0) {
this.setPenMode(false)
} else {
return
}
} else {
// reset the remaining taps before exiting pen mode
this._touchEventsRemainingBeforeExitingPenMode = 3
}
} else {
if (isPen) {
this.setPenMode(true)
}
}
if (info.button === 5) {
// Eraser button activates eraser
this._restoreToolId = this.currentToolId
this.complete()
this.setSelectedTool('eraser')
} else if (info.button === 1) {
// Middle mouse pan activates panning
if (!this.inputs.isPanning) {
this._prevCursor = this.instanceState.cursor.type
}
this.inputs.isPanning = true
}
if (this.inputs.isPanning) {
this.stopCameraAnimation()
this.setCursor({
type: 'grabbing',
})
return this
}
originScreenPoint.setTo(currentScreenPoint)
originPagePoint.setTo(currentPagePoint)
break
}
case 'pointer_move': {
// If the user is in pen mode, but the pointer is not a pen, stop here.
if (!isPen && this.isPenMode) {
return
}
if (this.inputs.isPanning && this.inputs.isPointing) {
// Handle panning
const { currentScreenPoint, previousScreenPoint } = this.inputs
const delta = Vec2d.Sub(currentScreenPoint, previousScreenPoint)
this.pan(delta.x, delta.y)
return
}
if (
!inputs.isDragging &&
inputs.isPointing &&
originPagePoint.dist(currentPagePoint) >
(this.isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE) / this.zoomLevel
2023-04-25 11:01:25 +00:00
) {
inputs.isDragging = true
}
break
}
case 'pointer_up': {
// Remove the button from the buttons set
inputs.buttons.delete(info.button)
inputs.isPointing = false
inputs.isDragging = false
if (this.isMenuOpen) {
// Surpressing pointerup here as <ContextMenu/> doesn't seem to do what we what here.
return
}
if (!isPen && this.isPenMode) {
return
}
// Firefox bug fix...
// If it's the same pointer that we stored earlier...
// ... then it's probably still a left-mouse-click!
if (this.capturedPointerId === info.pointerId) {
this.capturedPointerId = null
info.button = 0
}
2023-04-25 11:01:25 +00:00
if (inputs.isPanning) {
if (info.button === 1) {
if (!this.inputs.keys.has(' ')) {
inputs.isPanning = false
this.slideCamera({
speed: Math.min(2, this.inputs.pointerVelocity.len()),
direction: this.inputs.pointerVelocity,
friction: HAND_TOOL_FRICTION,
})
this.setCursor({
type: this._prevCursor,
})
} else {
this.slideCamera({
speed: Math.min(2, this.inputs.pointerVelocity.len()),
direction: this.inputs.pointerVelocity,
friction: HAND_TOOL_FRICTION,
})
this.setCursor({
type: 'grab',
})
}
} else if (info.button === 0) {
this.slideCamera({
speed: Math.min(2, this.inputs.pointerVelocity.len()),
direction: this.inputs.pointerVelocity,
friction: HAND_TOOL_FRICTION,
})
this.setCursor({
type: 'grab',
})
}
} else {
if (info.button === 5) {
// Eraser button activates eraser
this.complete()
this.setSelectedTool(this._restoreToolId)
}
}
break
}
}
break
}
case 'keyboard': {
switch (info.name) {
case 'key_down': {
// Add the key from the keys set
inputs.keys.add(info.code)
// If the space key is pressed (but meta / control isn't!) activate panning
if (!info.ctrlKey && info.code === 'Space') {
if (!this.inputs.isPanning) {
this._prevCursor = this.instanceState.cursor.type
}
this.inputs.isPanning = true
this.setCursor({
type: this.inputs.isPointing ? 'grabbing' : 'grab',
})
}
break
}
case 'key_up': {
// Remove the key from the keys set
inputs.keys.delete(info.code)
if (info.code === 'Space' && !this.inputs.buttons.has(1)) {
this.inputs.isPanning = false
this.setCursor({
type: this._prevCursor,
})
}
break
}
case 'key_repeat': {
// nooop
break
}
}
break
}
}
// Correct the info name for right / middle clicks
if (info.type === 'pointer') {
if (info.button === 1) {
info.name = 'middle_click'
} else if (info.button === 2) {
info.name = 'right_click'
}
// If a pointer event, send the event to the click manager.
if (info.isPen === this.isPenMode) {
switch (info.name) {
case 'pointer_down': {
const otherEvent = this._clickManager.transformPointerDownEvent(info)
if (info.name !== otherEvent.name) {
this.root.handleEvent(info)
this.emit('event', info)
this.root.handleEvent(otherEvent)
this.emit('event', otherEvent)
return
}
break
}
case 'pointer_up': {
const otherEvent = this._clickManager.transformPointerUpEvent(info)
if (info.name !== otherEvent.name) {
this.root.handleEvent(info)
this.emit('event', info)
this.root.handleEvent(otherEvent)
this.emit('event', otherEvent)
return
}
break
}
case 'pointer_move': {
this._clickManager.handleMove()
break
}
}
}
}
// Send the event to the statechart. It will be handled by all
// active states, starting at the root.
this.root.handleEvent(info)
this.emit('event', info)
})
return this
}
replaceStoreContentsWithRecordsForOtherDocument(records: TLRecord[]) {
transact(() => {
this.store.clear()
const [shapes, nonShapes] = partition(records, (record) => record.typeName === 'shape')
this.store.put(nonShapes, 'initialize')
this.store.ensureStoreIsUsable()
this.store.put(shapes, 'initialize')
this.history.clear()
this.updateViewportScreenBounds()
this.updateCullingBounds()
const bounds = this.allShapesCommonBounds
if (bounds) {
this.zoomToBounds(bounds.minX, bounds.minY, bounds.width, bounds.height, 1)
}
})
}
getContent(ids: TLShapeId[] = this.selectedIds): TLClipboardModel | undefined {
if (!ids) return
if (ids.length === 0) return
const pageTransforms: Record<string, Matrix2dModel> = {}
let shapes = dedupe(
ids
.map((id) => this.getShapeById(id)!)
2023-04-25 11:01:25 +00:00
.sort(sortByIndex)
.flatMap((shape) => {
const allShapes = [shape]
this.visitDescendants(shape.id, (descendant) => {
allShapes.push(this.getShapeById(descendant)!)
2023-04-25 11:01:25 +00:00
})
return allShapes
})
)
shapes = shapes.map((shape) => {
pageTransforms[shape.id] = this.getPageTransformById(shape.id)!
shape = structuredClone(shape) as typeof shape
if (this.isShapeOfType(shape, TLArrowUtil)) {
2023-04-25 11:01:25 +00:00
const startBindingId =
shape.props.start.type === 'binding' ? shape.props.start.boundShapeId : undefined
const endBindingId =
shape.props.end.type === 'binding' ? shape.props.end.boundShapeId : undefined
const info = this.getShapeUtil(TLArrowUtil).getArrowInfo(shape)
2023-04-25 11:01:25 +00:00
if (shape.props.start.type === 'binding') {
if (!shapes.some((s) => s.id === startBindingId)) {
// Uh oh, the arrow's bound-to shape isn't among the shapes
// that we're getting the content for. We should try to adjust
// the arrow so that it appears in the place it would be
if (info?.isValid) {
const { x, y } = info.start.point
shape.props.start = {
type: 'point',
x,
y,
}
} else {
const { start } = getArrowTerminalsInArrowSpace(this, shape)
shape.props.start = {
type: 'point',
x: start.x,
y: start.y,
}
}
}
}
if (shape.props.end.type === 'binding') {
if (!shapes.some((s) => s.id === endBindingId)) {
if (info?.isValid) {
const { x, y } = info.end.point
shape.props.end = {
type: 'point',
x,
y,
}
} else {
const { end } = getArrowTerminalsInArrowSpace(this, shape)
shape.props.end = {
type: 'point',
x: end.x,
y: end.y,
}
}
}
}
const infoAfter = getIsArrowStraight(shape)
? getStraightArrowInfo(this, shape)
: getCurvedArrowInfo(this, shape)
if (info?.isValid && infoAfter?.isValid && !getIsArrowStraight(shape)) {
const mpA = Vec2d.Med(info.start.handle, info.end.handle)
const distA = Vec2d.Dist(info.middle, mpA)
const distB = Vec2d.Dist(infoAfter.middle, mpA)
if (shape.props.bend < 0) {
shape.props.bend += distB - distA
} else {
shape.props.bend -= distB - distA
}
}
return shape
}
return shape
})
const rootShapeIds: TLShapeId[] = []
shapes.forEach((shape) => {
if (shapes.find((s) => s.id === shape.parentId) === undefined) {
// Need to get page point and rotation of the shape because shapes in
// groups use local position/rotation
const pagePoint = this.getPagePointById(shape.id)!
const pageRotation = this.getPageRotationById(shape.id)!
shape.x = pagePoint.x
shape.y = pagePoint.y
shape.rotation = pageRotation
shape.parentId = this.currentPageId
rootShapeIds.push(shape.id)
}
})
const assetsSet = new Set<TLAssetId>()
shapes.forEach((shape) => {
if ('assetId' in shape.props) {
if (shape.props.assetId !== null) {
assetsSet.add(shape.props.assetId)
}
}
})
return {
shapes,
rootShapeIds,
schema: this.store.schema.serialize(),
assets: compact(Array.from(assetsSet).map((id) => this.getAssetById(id))),
}
}
/* --------------------- Commands --------------------- */
putContent(
content: TLClipboardModel,
options: {
point?: VecLike
select?: boolean
preservePosition?: boolean
preserveIds?: boolean
} = {}
): this {
if (this.isReadOnly) return this
if (!content.schema) {
throw Error('Could not put content: content is missing a schema.')
}
const { select = false, preserveIds = false, preservePosition = false } = options
let { point = undefined } = options
// decide on a parent for the put shapes; if the parent is among the put shapes(?) then use its parent
const { currentPageId } = this
const { assets, shapes, rootShapeIds } = content
const idMap = new Map<any, TLShapeId>(shapes.map((shape) => [shape.id, createShapeId()]))
// By default, the paste parent will be the current page.
let pasteParentId = this.currentPageId as TLPageId | TLShapeId
let lowestDepth = Infinity
let lowestAncestors: TLShape[] = []
// Among the selected shapes, find the shape with the fewest ancestors and use its first ancestor.
for (const shape of this.selectedShapes) {
if (lowestDepth === 0) break
const ancestors = this.getAncestors(shape)
if (shape.type === 'frame') ancestors.push(shape)
const depth = shape.type === 'frame' ? ancestors.length + 1 : ancestors.length
if (depth < lowestDepth) {
lowestDepth = depth
lowestAncestors = ancestors
pasteParentId = shape.type === 'frame' ? shape.id : shape.parentId
} else if (depth === lowestDepth) {
if (lowestAncestors.length !== ancestors.length) {
throw Error(`Ancestors: ${lowestAncestors.length} !== ${ancestors.length}`)
}
if (lowestAncestors.length === 0) {
pasteParentId = currentPageId
break
} else {
pasteParentId = currentPageId
for (let i = 0; i < lowestAncestors.length; i++) {
if (ancestors[i] !== lowestAncestors[i]) break
pasteParentId = ancestors[i].id
}
}
}
}
let isDuplicating = false
if (!isPageId(pasteParentId)) {
2023-04-25 11:01:25 +00:00
const parent = this.getShapeById(pasteParentId)
if (parent) {
if (!this.viewportPageBounds.includes(this.getPageBounds(parent)!)) {
pasteParentId = currentPageId
} else {
if (rootShapeIds.length === 1) {
const rootShape = shapes.find((s) => s.id === rootShapeIds[0])!
if (
this.isShapeOfType(parent, TLFrameUtil) &&
this.isShapeOfType(rootShape, TLFrameUtil) &&
2023-04-25 11:01:25 +00:00
rootShape.props.w === parent?.props.w &&
rootShape.props.h === parent?.props.h
) {
isDuplicating = true
}
}
}
} else {
pasteParentId = currentPageId
}
}
if (!isDuplicating) {
isDuplicating = idMap.has(pasteParentId)
}
if (isDuplicating) {
pasteParentId = this.getShapeById(pasteParentId)!.parentId
}
let index = this.getHighestIndexForParent(pasteParentId)
const rootShapes: TLShape[] = []
const newShapes: TLShape[] = shapes.map((shape): TLShape => {
2023-04-25 11:01:25 +00:00
let newShape: TLShape
if (preserveIds) {
newShape = deepCopy(shape)
idMap.set(shape.id, shape.id)
} else {
const id = idMap.get(shape.id)!
// Create the new shape (new except for the id)
newShape = deepCopy({ ...shape, id })
}
if (rootShapeIds.includes(shape.id)) {
newShape.parentId = currentPageId
rootShapes.push(newShape)
}
// Assign the child to its new parent.
// If the child's parent is among the putting shapes, then assign
// it to the new parent's id.
if (idMap.has(newShape.parentId)) {
newShape.parentId = idMap.get(shape.parentId)!
} else {
rootShapeIds.push(newShape.id)
// newShape.parentId = pasteParentId
newShape.index = index
index = getIndexAbove(index)
}
if (this.isShapeOfType(newShape, TLArrowUtil)) {
2023-04-25 11:01:25 +00:00
if (newShape.props.start.type === 'binding') {
const mappedId = idMap.get(newShape.props.start.boundShapeId)
newShape.props.start = mappedId
? { ...newShape.props.start, boundShapeId: mappedId }
: // this shouldn't happen, if you copy an arrow but not it's bound shape it should
// convert the binding to a point at the time of copying
{ type: 'point', x: 0, y: 0 }
}
if (newShape.props.end.type === 'binding') {
const mappedId = idMap.get(newShape.props.end.boundShapeId)
newShape.props.end = mappedId
? { ...newShape.props.end, boundShapeId: mappedId }
: // this shouldn't happen, if you copy an arrow but not it's bound shape it should
// convert the binding to a point at the time of copying
{ type: 'point', x: 0, y: 0 }
}
}
return newShape
})
if (newShapes.length + this.shapeIds.size > MAX_SHAPES_PER_PAGE) {
// There's some complexity here involving children
// that might be created without their parents, so
// if we're going over the limit then just don't paste.
alertMaxShapes(this)
return this
}
// Migrate the new shapes
let assetsToCreate: TLAsset[] = []
if (assets) {
for (let i = 0; i < assets.length; i++) {
const asset = assets[i]
const result = this.store.schema.migratePersistedRecord(asset, content.schema)
if (result.type === 'success') {
assets[i] = result.value as TLAsset
} else {
throw Error(
`Could not put content: could not migrate content for asset:\n${JSON.stringify(
asset,
null,
2
)}`
)
}
}
const assetsToUpdate: (TLImageAsset | TLVideoAsset)[] = []
assetsToCreate = assets
.filter((asset) => !this.store.has(asset.id))
.map((asset) => {
if (asset.type === 'image' || asset.type === 'video') {
if (asset.props.src && asset.props.src?.startsWith('data:image')) {
assetsToUpdate.push(structuredClone(asset))
asset.props.src = null
} else {
assetsToUpdate.push(structuredClone(asset))
}
}
return asset
})
Promise.allSettled(
assetsToUpdate.map(async (asset) => {
const file = await dataUrlToFile(
asset.props.src!,
asset.props.name,
asset.props.mimeType ?? 'image/png'
)
const newAsset = await this.onCreateAssetFromFile(file)
return [asset, newAsset] as const
})
).then((assets) => {
this.updateAssets(
compact(
assets.map((result) =>
result.status === 'fulfilled'
? { ...result.value[1], id: result.value[0].id }
: undefined
)
)
)
})
}
for (let i = 0; i < newShapes.length; i++) {
const shape = newShapes[i]
2023-04-25 11:01:25 +00:00
const result = this.store.schema.migratePersistedRecord(shape, content.schema)
if (result.type === 'success') {
newShapes[i] = result.value as TLShape
} else {
throw Error(
`Could not put content: could not migrate content for shape:\n${JSON.stringify(
shape,
null,
2
)}`
)
}
}
this.batch(() => {
// Create any assets that need to be created
if (assetsToCreate.length > 0) {
this.createAssets(assetsToCreate)
}
// Create the shapes with root shapes as children of the page
this.createShapes(newShapes, select)
// And then, if needed, reparent the root shapes to the paste parent
if (pasteParentId !== currentPageId) {
this.reparentShapesById(
rootShapes.map((s) => s.id),
pasteParentId
)
}
const newCreatedShapes = newShapes.map((s) => this.getShapeById(s.id)!)
const bounds = Box2d.Common(newCreatedShapes.map((s) => this.getPageBounds(s)!))
if (point === undefined) {
if (!isPageId(pasteParentId)) {
2023-04-25 11:01:25 +00:00
// Put the shapes in the middle of the (on screen) parent
const shape = this.getShapeById(pasteParentId)!
const util = this.getShapeUtil(shape)
point = util.center(shape)
} else {
const { viewportPageBounds } = this
if (preservePosition || viewportPageBounds.includes(Box2d.From(bounds))) {
// Otherwise, put shapes where they used to be
point = bounds.center
} else {
// If the old bounds are outside of the viewport...
// put the shapes in the middle of the viewport
point = viewportPageBounds.center
}
}
}
if (rootShapes.length === 1) {
const onlyRoot = rootShapes[0] as TLFrameShape
// If the old bounds are in the viewport...
if (onlyRoot.type === 'frame') {
while (
this.getShapesAtPoint(point).some(
(shape) =>
this.isShapeOfType(shape, TLFrameUtil) &&
2023-04-25 11:01:25 +00:00
shape.props.w === onlyRoot.props.w &&
shape.props.h === onlyRoot.props.h
)
) {
point.x += bounds.w + 16
}
}
}
this.updateShapes(
rootShapes.map((s) => {
const delta = {
x: (s.x ?? 0) - (bounds.x + bounds.w / 2),
y: (s.y ?? 0) - (bounds.y + bounds.h / 2),
}
return { id: s.id, type: s.type, x: point!.x + delta.x, y: point!.y + delta.y }
})
)
})
return this
}
/* --------------------- Shapes --------------------- */
/**
* Get a unique id for a shape.
*
* @example
*
* ```ts
* app.createShapeId()
* app.createShapeId('box1')
* ```
*
* @param id - The id to use.
* @public
*/
createShapeId(id?: string) {
return id ? createCustomShapeId(id) : createShapeId()
}
getHighestIndexForParent(parentId: TLShapeId | TLPageId) {
const children = this._parentIdsToChildIds.value[parentId]
if (!children || children.length === 0) {
return 'a1'
}
return getIndexAbove(children[children.length - 1][1])
}
/**
* Create shapes.
*
* @example
*
* ```ts
* app.createShapes([{ id: 'box1', type: 'box' }])
* ```
*
* @param partials - The shape partials to create.
* @param select - Whether to select the created shapes. Defaults to false.
* @public
*/
createShapes(partials: TLShapePartial[], select = false) {
this._createShapes(partials, select)
return this
}
/** @internal */
private _createShapes = this.history.createCommand(
'createShapes',
(partials: TLShapePartial[], select = false) => {
if (this.isReadOnly) return null
if (partials.length <= 0) return null
const { shapeIds, selectedIds } = this
const prevSelectedIds = select ? selectedIds : undefined
const maxShapesReached = partials.length + shapeIds.size > MAX_SHAPES_PER_PAGE
if (maxShapesReached) {
alertMaxShapes(this)
}
const partialsToCreate = maxShapesReached
? partials.slice(0, MAX_SHAPES_PER_PAGE - shapeIds.size)
: partials
if (partialsToCreate.length === 0) return null
return {
data: {
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
currentPageId: this.currentPageId,
createdIds: partials.map((p) => p.id),
2023-04-25 11:01:25 +00:00
prevSelectedIds,
partials: partialsToCreate,
select,
},
}
},
{
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
do: ({ createdIds, partials, select }) => {
2023-04-25 11:01:25 +00:00
const { focusLayerId } = this
// 1. Parents
// Make sure that each partial will become the child of either the
// page or another shape that exists (or that will exist) in this page.
partials = partials.map((partial) => {
if (
// No parentId provided
!partial.parentId ||
// A parentId is proved but the parent is neither a) in the store
// or b) among the other creating shape partials
(!this.store.get(partial.parentId) && !partials.find((p) => p.id === partial.parentId))
) {
partial = { ...partial }
const parentId = this.getParentIdForNewShapeAtPoint(
{ x: partial.x ?? 0, y: partial.y ?? 0 },
partial.type
)
partial.parentId = parentId
// If the parent is a shape (rather than a page) then insert the
// shapes into the shape's children. Ajust the point and page rotation to be
// preserved relative to the parent.
if (isShapeId(parentId)) {
const point = this.getPointInShapeSpace(this.getShapeById(parentId)!, {
x: partial.x ?? 0,
y: partial.y ?? 0,
})
partial.x = point.x
partial.y = point.y
partial.rotation = -this.getPageRotationById(parentId) + (partial.rotation ?? 0)
}
return partial
}
return partial
})
// 2. Indices
// Get the highest index among the parents of each of the
// the shapes being created; we'll increment from there.
const parentIndices = new Map<string, string>()
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
const shapeRecordsToCreate: TLShape[] = []
2023-04-25 11:01:25 +00:00
for (const partial of partials) {
const util = this.getShapeUtil(partial)
2023-04-25 11:01:25 +00:00
// If an index is not explicitly provided, then add the
// shapes to the top of their parents' children; using the
// value in parentsMappedToIndex, get the index above, use it,
// and set it back to parentsMappedToIndex for next time.
let index = partial.index
if (!index) {
const parentId = partial.parentId ?? focusLayerId
if (!parentIndices.has(parentId)) {
parentIndices.set(parentId, this.getHighestIndexForParent(parentId))
}
index = parentIndices.get(parentId)!
parentIndices.set(parentId, getIndexAbove(index))
}
// The initial props starts as the shape utility's default props
const initialProps = util.defaultProps()
// We then look up each key in the tab state's props; and if it's there,
// we use the value from the tab state's props instead of the default.
// Note that props will never include opacity.
const { propsForNextShape } = this.instanceState
for (const key in initialProps) {
if (key in propsForNextShape) {
if (key === 'url') continue
;(initialProps as any)[key] = (propsForNextShape as any)[key]
}
}
// When we create the shape, take in the partial (the props coming into the
// function) and merge it with the default props.
[refactor] User-facing APIs (#1478) This PR updates our user-facing APIs for the Tldraw and TldrawEditor components, as well as the Editor (App). It mainly incorporates surface changes from #1450 without any changes to validators or migrators, incorporating feedback / discussion with @SomeHats and @ds300. Here we: - remove the TldrawEditorConfig - bring back a loose version of shape definitions - make a separation between "core" shapes and "default" shapes - do not allow custom shapes, migrators or validators to overwrite core shapes - but _do_ allow new shapes ## `<Tldraw>` component In this PR, the `Tldraw` component wraps both the `TldrawEditor` component and our `TldrawUi` component. It accepts a union of props for both components. Previously, this component also added local syncing via a `useLocalSyncClient` hook call, however that has been pushed down to the `TldrawEditor` component. ## `<TldrawEditor>` component The `TldrawEditor` component now more neatly wraps up the different ways that the editor can be configured. ## The store prop (`TldrawEditorProps.store`) There are three main ways for the `TldrawEditor` component to be run: 1. with an externally defined store 2. with an externally defined syncing store (local or remote) 3. with an internally defined store 4. with an internally defined locally syncing store The `store` prop allows for these configurations. If the `store` prop is defined, it may be defined either as a `TLStore` or as a `SyncedStore`. If the store is a `TLStore`, then the Editor will assume that the store is ready to go; if it is defined as a SyncedStore, then the component will display the loading / error screens as needed, or the final editor once the store's status is "synced". When the store is left undefined, then the `TldrawEditor` will create its own internal store using the optional `instanceId`, `initialData`, or `shapes` props to define the store / store schema. If the `persistenceKey` prop is left undefined, then the store will not be synced. If the `persistenceKey` is defined, then the store will be synced locally. In the future, we may also here accept the API key / roomId / etc for creating a remotely synced store. The `SyncedStore` type has been expanded to also include types used for remote syncing, e.g. with `ConnectionStatus`. ## Tools By default, the App has two "baked-in" tools: the select tool and the zoom tool. These cannot (for now) be replaced or removed. The default tools are used by default, but may be replaced by other tools if provided. ## Shapes By default, the App has a set of "core" shapes: - group - embed - bookmark - image - video - text That cannot by overwritten because they're created by the app at different moments, such as when double clicking on the canvas or via a copy and paste event. In follow up PRs, we'll split these out so that users can replace parts of the code where these shapes are created. ### Change Type - [x] `major` — Breaking Change ### Test Plan - [x] Unit Tests
2023-06-01 15:47:34 +00:00
let shapeRecordToCreate = (
this.store.schema.types.shape as RecordType<
TLShape,
'type' | 'props' | 'index' | 'parentId'
>
).create({
2023-04-25 11:01:25 +00:00
...partial,
index,
parentId: partial.parentId ?? focusLayerId,
props: 'props' in partial ? { ...initialProps, ...partial.props } : initialProps,
})
if (shapeRecordToCreate.index === undefined) {
throw Error('no index!')
}
const next = this.getShapeUtil(shapeRecordToCreate).onBeforeCreate?.(shapeRecordToCreate)
if (next) {
shapeRecordToCreate = next
}
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
shapeRecordsToCreate.push(shapeRecordToCreate)
2023-04-25 11:01:25 +00:00
}
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
this.store.put(shapeRecordsToCreate)
2023-04-25 11:01:25 +00:00
// If we're also selecting the newly created shapes, attempt to select all of them;
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
2023-04-25 11:01:25 +00:00
// the engine will filter out any shapes that are descendants of other new shapes.
if (select) {
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
this.store.update(this.pageState.id, (state) => ({
...state,
selectedIds: createdIds,
}))
2023-04-25 11:01:25 +00:00
}
},
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
undo: ({ createdIds, prevSelectedIds }) => {
this.store.remove(createdIds)
2023-04-25 11:01:25 +00:00
if (prevSelectedIds) {
this.store.update(this.pageState.id, (state) => ({
...state,
selectedIds: prevSelectedIds,
}))
}
},
}
)
private animatingShapes = new Map<TLShapeId, string>()
/**
* Animate shapes.
*
* @example
*
* ```ts
* app.animateShapes([{ id: 'box1', type: 'box', x: 100, y: 100 }])
* ```
*
* @param partials - The shape partials to update.
* @public
*/
animateShapes(
partials: (TLShapePartial | null | undefined)[],
options: {
/** The animation's duration in milliseconds. */
duration?: number
/** The animation's easing function. */
ease?: (t: number) => number
} = {}
) {
const { duration = 500, ease = EASINGS.linear } = options
const animationId = uniqueId()
let remaining = duration
let t: number
type FromTo = { prop: string; from: number; to: number }
type ShapeAnimation = { partial: TLShapePartial; values: FromTo[] }
const animations: ShapeAnimation[] = []
partials.forEach((partial) => {
if (!partial) return
const result: ShapeAnimation = {
partial,
values: [],
}
const shape = this.getShapeById(partial.id)!
if (!shape) return
for (const key of ['x', 'y', 'rotation'] as const) {
if (partial[key] !== undefined && shape[key] !== partial[key]) {
result.values.push({ prop: key, from: shape[key], to: partial[key] as number })
}
}
animations.push(result)
this.animatingShapes.set(shape.id, animationId)
})
let value: ShapeAnimation
const handleTick = (elapsed: number) => {
remaining -= elapsed
if (remaining < 0) {
const { animatingShapes } = this
const partialsToUpdate = partials.filter(
(p) => p && animatingShapes.get(p.id) === animationId
)
if (partialsToUpdate.length) {
this.updateShapes(partialsToUpdate, false)
// update shapes also removes the shape from animating shapes
}
this.removeListener('tick', handleTick)
return
}
t = ease(1 - remaining / duration)
const { animatingShapes } = this
try {
const tPartials: TLShapePartial[] = []
for (let i = 0; i < animations.length; i++) {
value = animations[i]
if (animatingShapes.get(value.partial.id) === animationId) {
tPartials.push({
id: value.partial.id,
type: value.partial.type,
...value.values.reduce((acc, { prop, from, to }) => {
acc[prop] = from + (to - from) * t
return acc
}, {} as any),
})
}
}
this._updateShapes(tPartials, true)
} catch (e) {
// noop
}
}
this.addListener('tick', handleTick)
return this
}
/**
* Update shapes.
*
* @example
*
* ```ts
* app.updateShapes([{ id: 'box1', type: 'box', x: 100, y: 100 }])
* ```
*
* @param partials - The shape partials to update.
* @param squashing - Whether the change is ephemeral.
* @public
*/
updateShapes(partials: (TLShapePartial | null | undefined)[], squashing = false) {
let compactedPartials = compact(partials)
2023-04-25 11:01:25 +00:00
if (this.animatingShapes.size > 0) {
compactedPartials.forEach((p) => this.animatingShapes.delete(p.id))
2023-04-25 11:01:25 +00:00
}
compactedPartials = compactedPartials.filter((p) => {
const shape = this.getShapeById(p.id)
if (!shape) return false
// Only allow changes to unlocked shapes or changes to the isLocked property (otherwise we cannot unlock a shape)
if (this.isShapeOrAncestorLocked(shape) && !Object.hasOwn(p, 'isLocked')) return false
return true
})
this._updateShapes(compactedPartials, squashing)
2023-04-25 11:01:25 +00:00
return this
}
/** @internal */
private _updateShapes = this.history.createCommand(
'updateShapes',
(_partials: (TLShapePartial | null | undefined)[], squashing = false) => {
if (this.isReadOnly) return null
const partials = compact(_partials)
const snapshots = Object.fromEntries(
compact(partials.map(({ id }) => this.getShapeById(id))).map((shape) => {
return [shape.id, shape]
})
)
if (partials.length <= 0) return null
const updated = compact(
partials.map((partial) => {
const prev = snapshots[partial.id]
if (!prev) return null
let newRecord = null as null | TLShape
for (const [k, v] of Object.entries(partial)) {
if (v === undefined) continue
2023-04-25 11:01:25 +00:00
switch (k) {
case 'id':
case 'type':
case 'typeName': {
continue
}
default: {
if (v !== (prev as any)[k]) {
if (!newRecord) {
newRecord = { ...prev }
}
if (k === 'props') {
const nextProps = { ...prev.props } as Record<string, unknown>
for (const [propKey, propValue] of Object.entries(v as object)) {
if (propValue === undefined) continue
nextProps[propKey] = propValue
}
newRecord!.props = nextProps
2023-04-25 11:01:25 +00:00
} else {
;(newRecord as any)[k] = v
}
}
}
}
}
return newRecord ?? prev
})
)
2023-04-25 11:01:25 +00:00
const updates = Object.fromEntries(updated.map((shape) => [shape.id, shape]))
return { data: { snapshots, updates }, squashing }
},
{
do: ({ updates }) => {
// Iterate through array; if any shape has an onUpdate handler, call it
// and, if the handler returns a new shape, replace the old shape with
// the new one. This is used for example when repositioning a text shape
// based on its new text content.
const result = Object.values(updates)
for (let i = 0; i < result.length; i++) {
const shape = result[i]
const current = this.store.get(shape.id)
if (!current) continue
const next = this.getShapeUtil(shape).onBeforeUpdate?.(current, shape)
2023-04-25 11:01:25 +00:00
if (next) {
result[i] = next
2023-04-25 11:01:25 +00:00
}
}
this.store.put(result)
2023-04-25 11:01:25 +00:00
},
undo: ({ snapshots }) => {
this.store.put(Object.values(snapshots))
},
squash(prevData, nextData) {
return {
// keep the oldest snapshots
snapshots: { ...nextData.snapshots, ...prevData.snapshots },
// keep the newest updates
updates: { ...prevData.updates, ...nextData.updates },
}
},
}
)
/** @internal */
private _getUnlockedShapeIds(ids: TLShapeId[]): TLShapeId[] {
return ids.filter((id) => !this.getShapeById(id)?.isLocked)
}
2023-04-25 11:01:25 +00:00
/**
* Delete shapes.
*
* @example
*
* ```ts
* app.deleteShapes()
* app.deleteShapes(['box1', 'box2'])
* ```
*
* @param ids - The ids of the shapes to delete. Defaults to the selected shapes.
* @public
*/
deleteShapes(ids: TLShapeId[] = this.selectedIds) {
this._deleteShapes(this._getUnlockedShapeIds(ids))
2023-04-25 11:01:25 +00:00
return this
}
/** @internal */
private _deleteShapes = this.history.createCommand(
'delete_shapes',
(ids: TLShapeId[]) => {
if (this.isReadOnly) return null
if (ids.length === 0) return null
const prevSelectedIds = [...this.pageState.selectedIds]
const allIds = new Set(ids)
for (const id of ids) {
this.visitDescendants(id, (childId) => {
allIds.add(childId)
})
}
const deletedIds = [...allIds]
const arrowBindings = this._arrowBindingsIndex.value
const snapshots = compact(
deletedIds.flatMap((id) => {
const shape = this.getShapeById(id)
// Add any bound arrows to the snapshots, so that we can restore the bindings on undo
const bindings = arrowBindings[id]
if (bindings && bindings.length > 0) {
return bindings.map(({ arrowId }) => this.getShapeById(arrowId)).concat(shape)
}
return shape
})
)
const postSelectedIds = prevSelectedIds.filter((id) => !allIds.has(id))
return { data: { deletedIds, snapshots, prevSelectedIds, postSelectedIds } }
},
{
do: ({ deletedIds, postSelectedIds }) => {
this.store.remove(deletedIds)
this.store.update(this.pageState.id, (state) => ({
...state,
selectedIds: postSelectedIds,
}))
},
undo: ({ snapshots, prevSelectedIds }) => {
this.store.put(snapshots)
this.store.update(this.pageState.id, (state) => ({
...state,
selectedIds: prevSelectedIds,
}))
},
}
)
/**
* Update user document settings
*
* @example
*
* ```ts
* app.updateUserDocumentSettings({ isGridMode: true })
* ```
*
* @public
*/
updateUserDocumentSettings(partial: Partial<TLUserDocument>, ephemeral = false) {
this._updateUserDocumentSettings(partial, ephemeral)
return this
}
/** @internal */
private _updateUserDocumentSettings = this.history.createCommand(
'updateUserDocumentSettings',
(partial: Partial<TLUserDocument>, ephemeral = false) => {
const prev = this.userDocumentSettings
const next = { ...prev, ...partial }
return { data: { prev, next }, ephemeral }
},
{
do: ({ next }) => {
this.store.put([next])
},
undo: ({ prev }) => {
this.store.put([prev])
},
}
)
/**
* Get the editor's locale.
* @public
*/
get locale() {
return this.user.locale
}
/**
* Update the editor's locale. This affects which translations are used when rendering UI elements.
*
* @example
*
* ```ts
* app.setLocale('fr')
* ```
*/
setLocale(locale: string) {
this.user.updateUserPreferences({ locale })
}
2023-04-25 11:01:25 +00:00
/**
* Update a page.
*
* @example
*
* ```ts
* app.updatePage({ id: 'page2', name: 'Page 2' })
* ```
*
* @param partial - The partial of the shape to update.
* @public
*/
updatePage(partial: RequiredKeys<TLPage, 'id'>, squashing = false) {
this._updatePage(partial, squashing)
return this
}
/** @internal */
private _updatePage = this.history.createCommand(
'updatePage',
(partial: RequiredKeys<TLPage, 'id'>, squashing = false) => {
if (this.isReadOnly) return null
const prev = this.getPageById(partial.id)
if (!prev) return null
return { data: { prev, partial }, squashing }
},
{
do: ({ partial }) => {
this.store.update(partial.id, (page) => ({ ...page, ...partial }))
},
undo: ({ prev, partial }) => {
this.store.update(partial.id, () => prev)
},
squash(prevData, nextData) {
return {
prev: { ...prevData.prev, ...nextData.prev },
partial: nextData.partial,
}
},
}
)
/**
* Create a page.
*
* @example
*
* ```ts
* app.createPage('New Page')
* app.createPage('New Page', 'page1')
* ```
*
* @param id - The new page's id.
* @param title - The new page's title.
* @public
*/
createPage(title: string, id: TLPageId = PageRecordType.createId(), belowPageIndex?: string) {
2023-04-25 11:01:25 +00:00
this._createPage(title, id, belowPageIndex)
return this
}
/** @internal */
private _createPage = this.history.createCommand(
'createPage',
(title: string, id: TLPageId = PageRecordType.createId(), belowPageIndex?: string) => {
2023-04-25 11:01:25 +00:00
if (this.isReadOnly) return null
if (this.pages.length >= MAX_PAGES) return null
const pageInfo = this.pages
const topIndex = belowPageIndex ?? pageInfo[pageInfo.length - 1]?.index ?? 'a1'
const bottomIndex = pageInfo[pageInfo.findIndex((p) => p.index === topIndex) + 1]?.index
const prevPageState = { ...this.pageState }
const prevInstanceState = { ...this.instanceState }
title = getIncrementedName(
title,
pageInfo.map((p) => p.name)
)
const newPage = PageRecordType.create({
2023-04-25 11:01:25 +00:00
id,
name: title,
index:
bottomIndex && topIndex !== bottomIndex
? getIndexBetween(topIndex, bottomIndex)
: getIndexAbove(topIndex),
2023-04-25 11:01:25 +00:00
})
const newCamera = CameraRecordType.create({})
2023-04-25 11:01:25 +00:00
const newTabPageState = InstancePageStateRecordType.create({
2023-04-25 11:01:25 +00:00
pageId: newPage.id,
instanceId: this.instanceId,
cameraId: newCamera.id,
})
return {
data: {
prevPageState,
prevTabState: prevInstanceState,
newPage,
newTabPageState,
newCamera,
},
}
},
{
do: ({ newPage, newTabPageState, newCamera }) => {
this.store.put([
newPage,
newCamera,
newTabPageState,
{ ...this.instanceState, currentPageId: newPage.id },
])
this.updateCullingBounds()
},
undo: ({ newPage, prevPageState, prevTabState, newTabPageState }) => {
this.store.put([prevPageState, prevTabState])
this.store.remove([newTabPageState.id, newPage.id, newTabPageState.cameraId])
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
2023-04-25 11:01:25 +00:00
this.updateCullingBounds()
},
}
)
duplicatePage(id: TLPageId = this.currentPageId, createId: TLPageId = PageRecordType.createId()) {
2023-04-25 11:01:25 +00:00
if (this.pages.length >= MAX_PAGES) return
const page = this.getPageById(id)
if (!page) return
const camera = { ...this.camera }
const content = this.getContent(this.getSortedChildIds(page.id))
this.batch(() => {
this.createPage(page.name + ' Copy', createId, page.index)
this.setCurrentPageId(createId)
this.setCamera(camera.x, camera.y, camera.z)
// will change page automatically
if (content) {
return this.putContent(content)
}
})
}
/**
* Delete a page.
*
* @example
*
* ```ts
* app.deletePage('page1')
* ```
*
* @param id - The id of the page to delete.
* @public
*/
deletePage(id: TLPageId) {
this._deletePage(id)
}
/** @internal */
private _deletePage = this.history.createCommand(
'delete_page',
(id: TLPageId) => {
if (this.isReadOnly) return null
const { pages } = this
if (pages.length === 1) return null
const deletedPage = this.getPageById(id)
const deletedPageStates = this._pageStates.value.filter((s) => s.pageId === id)
if (!deletedPage) return null
if (id === this.currentPageId) {
const index = pages.findIndex((page) => page.id === id)
const next = pages[index - 1] ?? pages[index + 1]
this.setCurrentPageId(next.id)
}
return { data: { id, deletedPage, deletedPageStates } }
},
{
do: ({ deletedPage, deletedPageStates }) => {
const { pages } = this
if (pages.length === 1) return
if (deletedPage.id === this.currentPageId) {
const index = pages.findIndex((page) => page.id === deletedPage.id)
const next = pages[index - 1] ?? pages[index + 1]
this.setCurrentPageId(next.id)
}
2023-04-25 11:01:25 +00:00
this.store.remove(deletedPageStates.map((s) => s.id)) // remove the page state
this.store.remove([deletedPage.id]) // remove the page
this.updateCullingBounds()
},
undo: ({ deletedPage, deletedPageStates }) => {
this.store.put([deletedPage])
this.store.put(deletedPageStates)
this.updateCullingBounds()
},
}
)
/**
* Update a page state.
*
* @example
*
* ```ts
* app.setInstancePageState({ id: 'page1', editingId: 'shape:123' })
* app.setInstancePageState({ id: 'page1', editingId: 'shape:123' }, true)
* ```
*
* @param partial - The partial of the page state object containing the changes.
* @param ephemeral - Whether the command is ephemeral.
* @public
*/
setInstancePageState(partial: Partial<TLInstancePageState>, ephemeral = false) {
this._setInstancePageState(partial, ephemeral)
}
/** @internal */
private _setInstancePageState = this.history.createCommand(
'setInstancePageState',
(partial: Partial<TLInstancePageState>, ephemeral = false) => {
const prev = this.store.get(partial.id ?? this.pageState.id)!
return { data: { prev, partial }, ephemeral }
},
{
do: ({ prev, partial }) => {
this.store.update(prev.id, (state) => ({ ...state, ...partial }))
},
undo: ({ prev }) => {
this.store.update(prev.id, () => prev)
},
}
)
/**
* Select one or more shapes.
*
* @example
*
* ```ts
* app.setSelectedIds(['id1'])
* app.setSelectedIds(['id1', 'id2'])
* ```
*
* @param ids - The ids to select.
* @param squashing - Whether the change should create a new history entry or combine with the
* previous (if the previous is the same type).
* @public
*/
setSelectedIds(ids: TLShapeId[], squashing = false) {
this._setSelectedIds(ids, squashing)
return this
}
/** @internal */
private _setSelectedIds = this.history.createCommand(
'setSelectedIds',
(ids: TLShapeId[], squashing = false) => {
const prevSelectedIds = this.pageState.selectedIds
const prevSet = new Set(this.pageState.selectedIds)
if (ids.length === prevSet.size && ids.every((id) => prevSet.has(id))) return null
return { data: { ids, prevSelectedIds }, squashing, preservesRedoStack: true }
},
{
do: ({ ids }) => {
this.store.update(this.pageState.id, (state) => ({ ...state, selectedIds: ids }))
},
undo: ({ prevSelectedIds }) => {
this.store.update(this.pageState.id, () => ({
...this.pageState,
selectedIds: prevSelectedIds,
}))
},
squash(prev, next) {
return { ids: next.ids, prevSelectedIds: prev.prevSelectedIds }
},
}
)
/**
* Determine whether or not a shape is selected
*
* @example
*
* ```ts
* app.isSelected('id1')
* ```
*
* @param id - The id of the shape to check.
* @public
*/
isSelected(id: TLShapeId) {
return this.selectedIdsSet.has(id)
}
/**
* Determine whether a not a shape is within the current selection. A shape is within the
* selection if it or any of its parents is selected.
*
* @param id - The id of the shape to check.
* @public
*/
isWithinSelection(id: TLShapeId) {
const shape = this.getShapeById(id)
if (!shape) return false
if (this.isSelected(id)) return true
return !!this.findAncestor(shape, (parent) => this.isSelected(parent.id))
}
/* --------------------- Assets --------------------- */
/** @internal */
@computed private get _assets() {
return this.store.query.records('asset')
}
/** Get all assets in the app. */
get assets() {
return this._assets.value
}
/**
* Create one or more assets.
*
* @example
*
* ```ts
* app.createAssets([...myAssets])
* ```
*
* @param assets - The assets to create.
* @public
*/
createAssets(assets: TLAsset[]) {
this._createAssets(assets)
return this
}
/** @internal */
private _createAssets = this.history.createCommand(
'createAssets',
(assets: TLAsset[]) => {
if (this.isReadOnly) return null
if (assets.length <= 0) return null
return { data: { assets } }
},
{
do: ({ assets }) => {
this.store.put(assets)
},
undo: ({ assets }) => {
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
// todo: should we actually remove assets here? or on cleanup elsewhere?
2023-04-25 11:01:25 +00:00
this.store.remove(assets.map((a) => a.id))
},
}
)
/**
* Delete one or more assets.
*
* @example
*
* ```ts
* app.deleteAssets(['asset1', 'asset2'])
* ```
*
* @param ids - The assets to delete.
* @public
*/
deleteAssets(ids: TLAssetId[]) {
this._deleteAssets(ids)
return this
}
/** @internal */
private _deleteAssets = this.history.createCommand(
'deleteAssets',
(ids: TLAssetId[]) => {
if (this.isReadOnly) return
if (ids.length <= 0) return
const prev = compact(ids.map((id) => this.store.get(id)))
return { data: { ids, prev } }
},
{
do: ({ ids }) => {
this.store.remove(ids)
},
undo: ({ prev }) => {
this.store.put(prev)
},
}
)
/**
* Update one or more assets.
*
* @example
*
* ```ts
* app.updateAssets([{ id: 'asset1', name: 'New name' }])
* ```
*
* @param assets - The assets to update.
* @public
*/
updateAssets(assets: TLAssetPartial[]) {
this._updateAssets(assets)
return this
}
/** @internal */
private _updateAssets = this.history.createCommand(
'updateAssets',
(assets: TLAssetPartial[]) => {
if (this.isReadOnly) return
if (assets.length <= 0) return
const snapshots: Record<string, TLAsset> = {}
return { data: { snapshots, assets } }
},
{
do: ({ assets, snapshots }) => {
this.store.put(
assets.map((a) => {
const asset = this.store.get(a.id)!
snapshots[a.id] = asset
return {
...asset,
...a,
}
})
)
},
undo: ({ snapshots }) => {
this.store.put(Object.values(snapshots))
},
}
)
/**
* Get an asset by its src property.
*
* @example
*
* ```ts
* app.getAssetBySource('https://example.com/image.png')
* ```
*
* @param src - The source value of the asset.
* @public
*/
getAssetBySrc(src: string) {
return this.assets.find((a) => a.props.src === src)
}
/**
* Get an asset by its id.
*
* @example
*
* ```ts
* app.getAssetById('asset1')
* ```
*
* @param id - The id of the asset.
* @public
*/
getAssetById(id: TLAssetId): TLAsset | undefined {
return this.store.get(id) as TLAsset | undefined
}
/* ------------------- SubCommands ------------------ */
async getSvg(
ids: TLShapeId[] = this.selectedIds.length
2023-04-25 11:01:25 +00:00
? this.selectedIds
: (Object.keys(this.shapeIds) as TLShapeId[]),
2023-04-25 11:01:25 +00:00
opts = {} as Partial<{
scale: number
background: boolean
padding: number
darkMode?: boolean
preserveAspectRatio: React.SVGAttributes<SVGSVGElement>['preserveAspectRatio']
}>
) {
if (ids.length === 0) return
if (!window.document) throw Error('No document')
const {
scale = 1,
background = false,
padding = SVG_PADDING,
darkMode = this.isDarkMode,
2023-04-25 11:01:25 +00:00
preserveAspectRatio = false,
} = opts
const realContainerEl = this.getContainer()
const realContainerStyle = getComputedStyle(realContainerEl)
// Get the styles from the container. We'll use these to pull out colors etc.
// NOTE: We can force force a light theme here becasue we don't want export
const fakeContainerEl = document.createElement('div')
[3/3] Highlighter styling (#1490) This PR finalises the highlighter shape with new colors, sizing, and perfect freehand options. The colors are based on our existing colour palette, but take advantage of wide-gamut displays to make the highlighter highlightier. I used my [oklch color palette tool to pick the palette](https://alex.dytry.ch/toys/palette/?palette=%7B%22families%22:%5B%22black%22,%22grey%22,%22white%22,%22green%22,%22light-green%22,%22blue%22,%22light-blue%22,%22violet%22,%22light-violet%22,%22red%22,%22light-red%22,%22orange%22,%22yellow%22%5D,%22shades%22:%5B%22light-mode%22,%22dark-mode%22,%22hl-light%22,%22hl-dark%22%5D,%22colors%22:%5B%5B%5B0.2308,0,null%5D,%5B0.9097,0,null%5D,%5B0.2308,0,null%5D,%5B0.2308,0,null%5D%5D,%5B%5B0.7692,0.0145,248.02%5D,%5B0.6778,0.0118,256.72%5D,%5B0.7692,0.0145,248.02%5D,%5B0.7692,0.0145,248.02%5D%5D,%5B%5B1,0,null%5D,%5B0.2308,0,null%5D,%5B1,0,null%5D,%5B1,0,null%5D%5D,%5B%5B0.5851,0.1227,164.1%5D,%5B0.5319,0.0811,162.23%5D,%5B0.8729,0.2083,173.3%5D,%5B0.5851,0.152,173.3%5D%5D,%5B%5B0.7146,0.1835,146.44%5D,%5B0.6384,0.1262,143.36%5D,%5B0.8603,0.2438,140.11%5D,%5B0.6082,0.2286,140.11%5D%5D,%5B%5B0.5566,0.2082,268.35%5D,%5B0.4961,0.1644,270.65%5D,%5B0.7158,0.173,243.85%5D,%5B0.5573,0.178,243.85%5D%5D,%5B%5B0.718,0.1422,246.06%5D,%5B0.6366,0.1055,250.98%5D,%5B0.8615,0.1896,200.03%5D,%5B0.707,0.161,200.03%5D%5D,%5B%5B0.5783,0.2186,319.15%5D,%5B0.5043,0.1647,315.37%5D,%5B0.728,0.2001,307.45%5D,%5B0.5433,0.2927,307.45%5D%5D,%5B%5B0.7904,0.1516,319.77%5D,%5B0.6841,0.1139,315.99%5D,%5B0.812,0.21,327.8%5D,%5B0.5668,0.281,327.8%5D%5D,%5B%5B0.5928,0.2106,26.53%5D,%5B0.5112,0.1455,26.18%5D,%5B0.7326,0.21,20.59%5D,%5B0.554,0.2461,20.59%5D%5D,%5B%5B0.7563,0.146,21.1%5D,%5B0.6561,0.0982,20.86%5D,%5B0.7749,0.178,6.8%5D,%5B0.5565,0.2454,6.8%5D%5D,%5B%5B0.6851,0.1954,44.57%5D,%5B0.5958,0.1366,46.6%5D,%5B0.8207,0.175,68.62%5D,%5B0.6567,0.164,68.61%5D%5D,%5B%5B0.8503,0.1149,68.95%5D,%5B0.7404,0.0813,72.25%5D,%5B0.8939,0.2137,100.36%5D,%5B0.7776,0.186,100.36%5D%5D%5D%7D&selected=3). I'm not sure happy about these colors as they are right now - in particular, i think dark mode looks a bit rubbish and there are a few colors where the highlight and original version are much too similar (light-violet & light-red). Black uses yellow (like note shape) and grey uses light-blue. Exports are forced into srgb color space rather than P3 for maximum compatibility. ![image](https://github.com/tldraw/tldraw/assets/1489520/e3de762b-6ef7-4d17-87db-3e2b71dd8de1) ![image](https://github.com/tldraw/tldraw/assets/1489520/3bd90aa9-bdbc-4a2b-9e56-e3a83a2a877b) The size of a highlighter stroke is now based on the text size which works nicely for making the highlighter play well with text: ![image](https://github.com/tldraw/tldraw/assets/1489520/dd3184fc-decd-4db5-90ce-e9cc75edd3d6) Perfect freehands settings are very similar to the draw tool, but with the thinning turned way down. There is still some, but it's pretty minimal. ### The plan 1. initial highlighter shape/tool #1401 2. sandwich rendering for highlighter shapes #1418 3. shape styling - new colours and sizes, lightweight perfect freehand changes #1490 **>you are here<** ### Change Type - [x] `minor` — New Feature ### Test Plan 1. You can find the highlighter tool in the extended toolbar 2. You can activate the highlighter tool by pressing shift-D 3. Highlighter draws nice and vibrantly when over the page background or frame background 4. Highlighter is less vibrant but still visible when drawn over images / other fills 5. Highlighter size should nicely match the corresponding unscaled text size 6. Exports with highlighter look as expected ### Release Notes Highlighter pen is here! 🎉🎉🎉 --------- Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
2023-06-01 15:34:59 +00:00
fakeContainerEl.className = `tl-container tl-theme__${
darkMode ? 'dark' : 'light'
} tl-theme__force-sRGB`
2023-04-25 11:01:25 +00:00
document.body.appendChild(fakeContainerEl)
const containerStyle = getComputedStyle(fakeContainerEl)
const fontsUsedInExport = new Map<string, string>()
const colors: TLExportColors = {
fill: Object.fromEntries(
STYLES.color.map((color) => [
color.id,
containerStyle.getPropertyValue(`--palette-${color.id}`),
])
) as Record<TLColorType, string>,
pattern: Object.fromEntries(
STYLES.color.map((color) => [
color.id,
containerStyle.getPropertyValue(`--palette-${color.id}-pattern`),
])
) as Record<TLColorType, string>,
semi: Object.fromEntries(
STYLES.color.map((color) => [
color.id,
containerStyle.getPropertyValue(`--palette-${color.id}-semi`),
])
) as Record<TLColorType, string>,
[3/3] Highlighter styling (#1490) This PR finalises the highlighter shape with new colors, sizing, and perfect freehand options. The colors are based on our existing colour palette, but take advantage of wide-gamut displays to make the highlighter highlightier. I used my [oklch color palette tool to pick the palette](https://alex.dytry.ch/toys/palette/?palette=%7B%22families%22:%5B%22black%22,%22grey%22,%22white%22,%22green%22,%22light-green%22,%22blue%22,%22light-blue%22,%22violet%22,%22light-violet%22,%22red%22,%22light-red%22,%22orange%22,%22yellow%22%5D,%22shades%22:%5B%22light-mode%22,%22dark-mode%22,%22hl-light%22,%22hl-dark%22%5D,%22colors%22:%5B%5B%5B0.2308,0,null%5D,%5B0.9097,0,null%5D,%5B0.2308,0,null%5D,%5B0.2308,0,null%5D%5D,%5B%5B0.7692,0.0145,248.02%5D,%5B0.6778,0.0118,256.72%5D,%5B0.7692,0.0145,248.02%5D,%5B0.7692,0.0145,248.02%5D%5D,%5B%5B1,0,null%5D,%5B0.2308,0,null%5D,%5B1,0,null%5D,%5B1,0,null%5D%5D,%5B%5B0.5851,0.1227,164.1%5D,%5B0.5319,0.0811,162.23%5D,%5B0.8729,0.2083,173.3%5D,%5B0.5851,0.152,173.3%5D%5D,%5B%5B0.7146,0.1835,146.44%5D,%5B0.6384,0.1262,143.36%5D,%5B0.8603,0.2438,140.11%5D,%5B0.6082,0.2286,140.11%5D%5D,%5B%5B0.5566,0.2082,268.35%5D,%5B0.4961,0.1644,270.65%5D,%5B0.7158,0.173,243.85%5D,%5B0.5573,0.178,243.85%5D%5D,%5B%5B0.718,0.1422,246.06%5D,%5B0.6366,0.1055,250.98%5D,%5B0.8615,0.1896,200.03%5D,%5B0.707,0.161,200.03%5D%5D,%5B%5B0.5783,0.2186,319.15%5D,%5B0.5043,0.1647,315.37%5D,%5B0.728,0.2001,307.45%5D,%5B0.5433,0.2927,307.45%5D%5D,%5B%5B0.7904,0.1516,319.77%5D,%5B0.6841,0.1139,315.99%5D,%5B0.812,0.21,327.8%5D,%5B0.5668,0.281,327.8%5D%5D,%5B%5B0.5928,0.2106,26.53%5D,%5B0.5112,0.1455,26.18%5D,%5B0.7326,0.21,20.59%5D,%5B0.554,0.2461,20.59%5D%5D,%5B%5B0.7563,0.146,21.1%5D,%5B0.6561,0.0982,20.86%5D,%5B0.7749,0.178,6.8%5D,%5B0.5565,0.2454,6.8%5D%5D,%5B%5B0.6851,0.1954,44.57%5D,%5B0.5958,0.1366,46.6%5D,%5B0.8207,0.175,68.62%5D,%5B0.6567,0.164,68.61%5D%5D,%5B%5B0.8503,0.1149,68.95%5D,%5B0.7404,0.0813,72.25%5D,%5B0.8939,0.2137,100.36%5D,%5B0.7776,0.186,100.36%5D%5D%5D%7D&selected=3). I'm not sure happy about these colors as they are right now - in particular, i think dark mode looks a bit rubbish and there are a few colors where the highlight and original version are much too similar (light-violet & light-red). Black uses yellow (like note shape) and grey uses light-blue. Exports are forced into srgb color space rather than P3 for maximum compatibility. ![image](https://github.com/tldraw/tldraw/assets/1489520/e3de762b-6ef7-4d17-87db-3e2b71dd8de1) ![image](https://github.com/tldraw/tldraw/assets/1489520/3bd90aa9-bdbc-4a2b-9e56-e3a83a2a877b) The size of a highlighter stroke is now based on the text size which works nicely for making the highlighter play well with text: ![image](https://github.com/tldraw/tldraw/assets/1489520/dd3184fc-decd-4db5-90ce-e9cc75edd3d6) Perfect freehands settings are very similar to the draw tool, but with the thinning turned way down. There is still some, but it's pretty minimal. ### The plan 1. initial highlighter shape/tool #1401 2. sandwich rendering for highlighter shapes #1418 3. shape styling - new colours and sizes, lightweight perfect freehand changes #1490 **>you are here<** ### Change Type - [x] `minor` — New Feature ### Test Plan 1. You can find the highlighter tool in the extended toolbar 2. You can activate the highlighter tool by pressing shift-D 3. Highlighter draws nice and vibrantly when over the page background or frame background 4. Highlighter is less vibrant but still visible when drawn over images / other fills 5. Highlighter size should nicely match the corresponding unscaled text size 6. Exports with highlighter look as expected ### Release Notes Highlighter pen is here! 🎉🎉🎉 --------- Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
2023-06-01 15:34:59 +00:00
highlight: Object.fromEntries(
STYLES.color.map((color) => [
color.id,
containerStyle.getPropertyValue(`--palette-${color.id}-highlight`),
])
) as Record<TLColorType, string>,
2023-04-25 11:01:25 +00:00
text: containerStyle.getPropertyValue(`--color-text`),
background: containerStyle.getPropertyValue(`--color-background`),
solid: containerStyle.getPropertyValue(`--palette-solid`),
}
// Remove containerEl from DOM (temp DOM node)
document.body.removeChild(fakeContainerEl)
// ---Figure out which shapes we need to include
const shapeIdsToInclude = this.getShapeAndDescendantIds(ids)
const renderingShapes = this.computeUnorderedRenderingShapes([this.currentPageId]).filter(
({ id }) => shapeIdsToInclude.has(id)
)
2023-04-25 11:01:25 +00:00
// --- Common bounding box of all shapes
let bbox = null
for (const { maskedPageBounds } of renderingShapes) {
if (!maskedPageBounds) continue
if (bbox) {
bbox.union(maskedPageBounds)
} else {
bbox = maskedPageBounds.clone()
}
}
2023-04-25 11:01:25 +00:00
// no unmasked shapes to export
if (!bbox) return
2023-04-25 11:01:25 +00:00
const singleFrameShapeId =
ids.length === 1 && this.getShapeById(ids[0])?.type === 'frame' ? ids[0] : null
if (!singleFrameShapeId) {
2023-04-25 11:01:25 +00:00
// Expand by an extra 32 pixels
bbox.expandBy(padding)
}
// We want the svg image to be BIGGER THAN USUAL to account for image quality
const w = bbox.width * scale
const h = bbox.height * scale
// --- Create the SVG
// Embed our custom fonts
const svg = window.document.createElementNS('http://www.w3.org/2000/svg', 'svg')
if (preserveAspectRatio) {
svg.setAttribute('preserveAspectRatio', preserveAspectRatio)
}
svg.setAttribute('direction', 'ltr')
svg.setAttribute('width', w + '')
svg.setAttribute('height', h + '')
svg.setAttribute('viewBox', `${bbox.minX} ${bbox.minY} ${bbox.width} ${bbox.height}`)
svg.setAttribute('stroke-linecap', 'round')
svg.setAttribute('stroke-linejoin', 'round')
// Add current background color, or else background will be transparent
if (background) {
if (singleFrameShapeId) {
2023-04-25 11:01:25 +00:00
svg.style.setProperty('background', colors.solid)
} else {
svg.style.setProperty('background-color', colors.background)
}
} else {
svg.style.setProperty('background-color', 'transparent')
}
// Add the defs to the svg
const defs = window.document.createElementNS('http://www.w3.org/2000/svg', 'defs')
for (const element of Array.from(exportPatternSvgDefs(colors.solid))) {
defs.appendChild(element)
}
try {
document.body.focus?.() // weird but necessary
} catch (e) {
// not implemented
}
svg.append(defs)
const unorderedShapeElements = (
await Promise.all(
renderingShapes.map(async ({ id, opacity, index, backgroundIndex }) => {
// Don't render the frame if we're only exporting a single frame
if (id === singleFrameShapeId) return []
2023-04-25 11:01:25 +00:00
const shape = this.getShapeById(id)!
const util = this.getShapeUtil(shape)
2023-04-25 11:01:25 +00:00
let font: string | undefined
if ('font' in shape.props) {
if (shape.props.font) {
if (fontsUsedInExport.has(shape.props.font)) {
font = fontsUsedInExport.get(shape.props.font)!
} else {
// For some reason these styles aren't present in the fake element
// so we need to get them from the real element
font = realContainerStyle.getPropertyValue(`--tl-font-${shape.props.font}`)
fontsUsedInExport.set(shape.props.font, font)
}
}
}
2023-04-25 11:01:25 +00:00
let shapeSvgElement = await util.toSvg?.(shape, font, colors)
let backgroundSvgElement = await util.toBackgroundSvg?.(shape, font, colors)
2023-04-25 11:01:25 +00:00
// wrap the shapes in groups so we can apply properties without overwriting ones from the shape util
if (shapeSvgElement) {
const outerElement = document.createElementNS('http://www.w3.org/2000/svg', 'g')
outerElement.appendChild(shapeSvgElement)
shapeSvgElement = outerElement
}
if (backgroundSvgElement) {
const outerElement = document.createElementNS('http://www.w3.org/2000/svg', 'g')
outerElement.appendChild(backgroundSvgElement)
backgroundSvgElement = outerElement
2023-04-25 11:01:25 +00:00
}
if (!shapeSvgElement && !backgroundSvgElement) {
const bounds = this.getPageBounds(shape)!
const elm = window.document.createElementNS('http://www.w3.org/2000/svg', 'rect')
elm.setAttribute('width', bounds.width + '')
elm.setAttribute('height', bounds.height + '')
elm.setAttribute('fill', colors.solid)
elm.setAttribute('stroke', colors.pattern.grey)
elm.setAttribute('stroke-width', '1')
shapeSvgElement = elm
}
2023-04-25 11:01:25 +00:00
let pageTransform = this.getPageTransform(shape)!.toCssString()
if ('scale' in shape.props) {
if (shape.props.scale !== 1) {
pageTransform = `${pageTransform} scale(${shape.props.scale}, ${shape.props.scale})`
}
}
2023-04-25 11:01:25 +00:00
shapeSvgElement?.setAttribute('transform', pageTransform)
backgroundSvgElement?.setAttribute('transform', pageTransform)
shapeSvgElement?.setAttribute('opacity', opacity + '')
backgroundSvgElement?.setAttribute('opacity', opacity + '')
2023-04-25 11:01:25 +00:00
// Create svg mask if shape has a frame as parent
const pageMask = this.getPageMaskById(shape.id)
if (pageMask) {
// Create a clip path and add it to defs
const clipPathEl = document.createElementNS('http://www.w3.org/2000/svg', 'clipPath')
defs.appendChild(clipPathEl)
const id = nanoid()
clipPathEl.id = id
// Create a polyline mask that does the clipping
const mask = document.createElementNS('http://www.w3.org/2000/svg', 'path')
mask.setAttribute('d', `M${pageMask.map(({ x, y }) => `${x},${y}`).join('L')}Z`)
clipPathEl.appendChild(mask)
// Create group that uses the clip path and wraps the shape elements
if (shapeSvgElement) {
const outerElement = document.createElementNS('http://www.w3.org/2000/svg', 'g')
outerElement.setAttribute('clip-path', `url(#${id})`)
outerElement.appendChild(shapeSvgElement)
shapeSvgElement = outerElement
}
2023-04-25 11:01:25 +00:00
if (backgroundSvgElement) {
const outerElement = document.createElementNS('http://www.w3.org/2000/svg', 'g')
outerElement.setAttribute('clip-path', `url(#${id})`)
outerElement.appendChild(backgroundSvgElement)
backgroundSvgElement = outerElement
}
}
2023-04-25 11:01:25 +00:00
const elements = []
if (shapeSvgElement) {
elements.push({ zIndex: index, element: shapeSvgElement })
}
if (backgroundSvgElement) {
elements.push({ zIndex: backgroundIndex, element: backgroundSvgElement })
}
return elements
})
)
).flat()
for (const { element } of unorderedShapeElements.sort((a, b) => a.zIndex - b.zIndex)) {
svg.appendChild(element)
2023-04-25 11:01:25 +00:00
}
// Add styles to the defs
let styles = ``
const style = window.document.createElementNS('http://www.w3.org/2000/svg', 'style')
// Insert fonts into app
const fontInstances: FontFace[] = []
2023-04-25 11:01:25 +00:00
if ('fonts' in document) {
document.fonts.forEach((font) => fontInstances.push(font))
}
await Promise.all(
fontInstances.map(async (font) => {
const fileReader = new FileReader()
2023-04-25 11:01:25 +00:00
let isUsed = false
2023-04-25 11:01:25 +00:00
fontsUsedInExport.forEach((fontName) => {
if (fontName.includes(font.family)) {
isUsed = true
}
})
2023-04-25 11:01:25 +00:00
if (!isUsed) return
2023-04-25 11:01:25 +00:00
const url = (font as any).$$_url
2023-04-25 11:01:25 +00:00
const fontFaceRule = (font as any).$$_fontface
2023-04-25 11:01:25 +00:00
if (url) {
const fontFile = await (await fetch(url)).blob()
2023-04-25 11:01:25 +00:00
const base64Font = await new Promise<string>((resolve, reject) => {
fileReader.onload = () => resolve(fileReader.result as string)
fileReader.onerror = () => reject(fileReader.error)
fileReader.readAsDataURL(fontFile)
})
2023-04-25 11:01:25 +00:00
const newFontFaceRule = '\n' + fontFaceRule.replaceAll(url, base64Font)
styles += newFontFaceRule
}
})
)
2023-04-25 11:01:25 +00:00
style.textContent = styles
defs.append(style)
return svg
}
/**
* Rename a page.
*
* @example
*
* ```ts
* app.renamePage('page1', 'My Page')
* ```
*
* @param id - The id of the page to rename.
* @param name - The new name.
* @public
*/
renamePage(id: TLPageId, name: string, squashing = false) {
if (this.isReadOnly) return this
this.updatePage({ id, name }, squashing)
return this
}
/**
* Move shapes to page.
*
* @example
*
* ```ts
* app.moveShapesToPage(['box1', 'box2'], 'page1')
* ```
*
* @param ids - The ids of the shapes to move.
* @param pageId - The id of the page where the shapes will be moved.
* @public
*/
moveShapesToPage(ids: TLShapeId[], pageId: TLPageId): this {
if (ids.length === 0) return this
if (this.isReadOnly) return this
const { currentPageId } = this
if (pageId === currentPageId) return this
if (!this.store.has(pageId)) return this
// Basically copy the shapes
const content = this.getContent(ids)
// Just to be sure
if (!content) return this
// If there is no space on pageId, or if the selected shapes
// would take the new page above the limit, don't move the shapes
if (this.getShapeIdsInPage(pageId).size + content.shapes.length > MAX_SHAPES_PER_PAGE) {
2023-04-25 11:01:25 +00:00
alertMaxShapes(this, pageId)
return this
}
const fromPageZ = this.camera.z
this.history.batch(() => {
// Delete the shapes on the current page
this.deleteShapes(ids)
// Move to the next page
this.setCurrentPageId(pageId)
// Put the shape content onto the new page; parents and indices will
// be taken care of by the putContent method; make sure to pop any focus
// layers so that the content will be put onto the page.
this.setFocusLayer(null)
this.selectNone()
this.putContent(content, { select: true, preserveIds: true, preservePosition: true })
// Force the new page's camera to be at the same zoom level as the
// "from" page's camera, then center the "to" page's camera on the
// pasted shapes
const {
center: { x, y },
} = this.selectionBounds!
this.setCamera(this.camera.x, this.camera.y, fromPageZ)
this.centerOnPoint(x, y)
})
return this
}
toggleLock(ids: TLShapeId[] = this.selectedIds): this {
if (this.isReadOnly || ids.length === 0) return this
let allLocked = true,
allUnlocked = true
const shapes: TLShape[] = []
for (const id of ids) {
const shape = this.getShapeById(id)
if (shape) {
shapes.push(shape)
if (shape.isLocked) {
allUnlocked = false
} else {
allLocked = false
}
}
}
if (allUnlocked) {
this.updateShapes(shapes.map((shape) => ({ id: shape.id, type: shape.type, isLocked: true })))
this.setSelectedIds([])
} else if (allLocked) {
this.updateShapes(
shapes.map((shape) => ({ id: shape.id, type: shape.type, isLocked: false }))
)
} else {
this.updateShapes(shapes.map((shape) => ({ id: shape.id, type: shape.type, isLocked: true })))
}
2023-04-25 11:01:25 +00:00
return this
}
/**
* Reorder shapes.
*
* @param operation - The operation to perform.
* @param ids - The ids to reorder.
* @public
*/
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
reorderShapes(operation: 'toBack' | 'toFront' | 'forward' | 'backward', ids: TLShapeId[]) {
2023-04-25 11:01:25 +00:00
if (this.isReadOnly) return this
if (ids.length === 0) return this
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
// this.emit('reorder-shapes', { pageId: this.currentPageId, ids, operation })
2023-04-25 11:01:25 +00:00
const parents = this.getParentsMappedToChildren(ids)
const changes: TLShapePartial[] = []
switch (operation) {
case 'toBack': {
parents.forEach((movingSet, parentId) => {
const siblings = compact(
this.getSortedChildIds(parentId).map((id) => this.getShapeById(id))
)
if (movingSet.size === siblings.length) return
let below: string | undefined
let above: string | undefined
for (const shape of siblings) {
if (!movingSet.has(shape)) {
above = shape.index
break
}
movingSet.delete(shape)
below = shape.index
}
if (movingSet.size === 0) return
const indices = getIndicesBetween(below, above, movingSet.size)
Array.from(movingSet.values())
.sort(sortByIndex)
.forEach((node, i) =>
changes.push({ id: node.id as any, type: node.type, index: indices[i] })
)
})
break
}
case 'toFront': {
parents.forEach((movingSet, parentId) => {
const siblings = compact(
this.getSortedChildIds(parentId).map((id) => this.getShapeById(id))
)
const len = siblings.length
if (movingSet.size === len) return
let below: string | undefined
let above: string | undefined
for (let i = len - 1; i > -1; i--) {
const shape = siblings[i]
if (!movingSet.has(shape)) {
below = shape.index
break
}
movingSet.delete(shape)
above = shape.index
}
if (movingSet.size === 0) return
const indices = getIndicesBetween(below, above, movingSet.size)
Array.from(movingSet.values())
.sort(sortByIndex)
.forEach((node, i) =>
changes.push({ id: node.id as any, type: node.type, index: indices[i] })
)
})
break
}
case 'forward': {
parents.forEach((movingSet, parentId) => {
const siblings = compact(
this.getSortedChildIds(parentId).map((id) => this.getShapeById(id))
)
const len = siblings.length
if (movingSet.size === len) return
const movingIndices = new Set(Array.from(movingSet).map((n) => siblings.indexOf(n)))
let selectIndex = -1
let isSelecting = false
let below: string | undefined
let above: string | undefined
let count: number
for (let i = 0; i < len; i++) {
const isMoving = movingIndices.has(i)
if (!isSelecting && isMoving) {
isSelecting = true
selectIndex = i
above = undefined
} else if (isSelecting && !isMoving) {
isSelecting = false
count = i - selectIndex
below = siblings[i].index
above = siblings[i + 1]?.index
const indices = getIndicesBetween(below, above, count)
for (let k = 0; k < count; k++) {
const node = siblings[selectIndex + k]
changes.push({ id: node.id as any, type: node.type, index: indices[k] })
}
}
}
})
break
}
case 'backward': {
parents.forEach((movingSet, parentId) => {
const siblings = compact(
this.getSortedChildIds(parentId).map((id) => this.getShapeById(id))
)
const len = siblings.length
if (movingSet.size === len) return
const movingIndices = new Set(Array.from(movingSet).map((n) => siblings.indexOf(n)))
let selectIndex = -1
let isSelecting = false
let count: number
for (let i = len - 1; i > -1; i--) {
const isMoving = movingIndices.has(i)
if (!isSelecting && isMoving) {
isSelecting = true
selectIndex = i
} else if (isSelecting && !isMoving) {
isSelecting = false
count = selectIndex - i
const indices = getIndicesBetween(siblings[i - 1]?.index, siblings[i].index, count)
for (let k = 0; k < count; k++) {
const node = siblings[i + k + 1]
changes.push({ id: node.id as any, type: node.type, index: indices[k] })
}
}
}
})
break
}
}
this.updateShapes(changes)
return this
}
/**
* Send shapes to the back of the page's object list.
*
* @example
*
* ```ts
* app.sendToBack()
* app.sendToBack(['id1', 'id2'])
* ```
*
* @param ids - The ids of the shapes to move. Defaults to the ids of the selected shapes.
* @public
*/
sendToBack(ids = this.pageState.selectedIds) {
this.reorderShapes('toBack', ids)
return this
}
/**
* Send shapes backward in the page's object list.
*
* @example
*
* ```ts
* app.sendBackward()
* app.sendBackward(['id1', 'id2'])
* ```
*
* @param ids - The ids of the shapes to move. Defaults to the ids of the selected shapes.
* @public
*/
sendBackward(ids = this.pageState.selectedIds) {
this.reorderShapes('backward', ids)
return this
}
/**
* Bring shapes forward in the page's object list.
*
* @example
*
* ```ts
* app.bringForward()
* app.bringForward(['id1', 'id2'])
* ```
*
* @param ids - The ids of the shapes to move. Defaults to the ids of the selected shapes.
* @public
*/
bringForward(ids = this.pageState.selectedIds) {
this.reorderShapes('forward', ids)
return this
}
/**
* Bring shapes to the front of the page's object list.
*
* @example
*
* ```ts
* app.bringToFront()
* app.bringToFront(['id1', 'id2'])
* ```
*
* @param ids - The ids of the shapes to move. Defaults to the ids of the selected shapes.
* @public
*/
bringToFront(ids = this.pageState.selectedIds) {
this.reorderShapes('toFront', ids)
return this
}
/**
* Flip shape positions.
*
* @example
*
* ```ts
* app.flipShapes('horizontal')
* app.flipShapes('horizontal', ['box1', 'box2'])
* ```
*
* @param operation - Whether to flip horizontally or vertically.
* @param ids - The ids of the shapes to flip. Defaults to selected shapes.
* @public
*/
flipShapes(operation: 'horizontal' | 'vertical', ids: TLShapeId[] = this.selectedIds) {
if (this.isReadOnly) return this
let shapes = compact(ids.map((id) => this.getShapeById(id)))
if (!shapes.length) return this
shapes = compact(
shapes
.map((shape) => {
if (shape.type === 'group') {
return this.getSortedChildIds(shape.id).map((id) => this.getShapeById(id))
}
2023-04-25 11:01:25 +00:00
return shape
})
.flat()
)
2023-04-25 11:01:25 +00:00
const scaleOriginPage = Box2d.Common(compact(shapes.map((id) => this.getPageBounds(id)))).center
this.batch(() => {
for (const shape of shapes) {
const util = this.getShapeUtil(shape)
const bounds = util.bounds(shape)
const initialPageTransform = this.getPageTransformById(shape.id)
if (!initialPageTransform) continue
this.resizeShape(
shape.id,
{ x: operation === 'horizontal' ? -1 : 1, y: operation === 'vertical' ? -1 : 1 },
{
initialBounds: bounds,
initialPageTransform,
initialShape: shape,
mode: 'scale_shape',
scaleOrigin: scaleOriginPage,
scaleAxisRotation: 0,
}
)
}
})
return this
}
/**
* Stack shape.
*
* @example
*
* ```ts
* app.stackShapes('horizontal')
* app.stackShapes('horizontal', ['box1', 'box2'])
* app.stackShapes('horizontal', ['box1', 'box2'], 20)
* ```
*
* @param operation - Whether to stack horizontally or vertically.
* @param ids - The ids of the shapes to stack. Defaults to selected shapes.
* @param gap - A specific gap to use when stacking.
* @public
*/
stackShapes(
operation: 'horizontal' | 'vertical',
ids: TLShapeId[] = this.pageState.selectedIds,
gap?: number
) {
if (this.isReadOnly) return this
const shapes = compact(ids.map((id) => this.getShapeById(id))).filter((shape) => {
if (!shape) return false
if (this.isShapeOfType(shape, TLArrowUtil)) {
2023-04-25 11:01:25 +00:00
if (shape.props.start.type === 'binding' || shape.props.end.type === 'binding') {
return false
}
}
return true
})
const len = shapes.length
if ((gap === undefined && len < 3) || len < 2) return this
const pageBounds = Object.fromEntries(
shapes.map((shape) => [shape.id, this.getPageBounds(shape)!])
)
let val: 'x' | 'y'
let min: 'minX' | 'minY'
let max: 'maxX' | 'maxY'
let dim: 'width' | 'height'
if (operation === 'horizontal') {
val = 'x'
min = 'minX'
max = 'maxX'
dim = 'width'
} else {
val = 'y'
min = 'minY'
max = 'maxY'
dim = 'height'
}
let shapeGap: number
if (gap === undefined) {
const gaps: { gap: number; count: number }[] = []
shapes.sort((a, b) => pageBounds[a.id][min] - pageBounds[b.id][min])
// Collect all of the gaps between shapes. We want to find
// patterns (equal gaps between shapes) and use the most common
// one as the gap for all of the shapes.
for (let i = 0; i < len - 1; i++) {
const shape = shapes[i]
const nextShape = shapes[i + 1]
const bounds = pageBounds[shape.id]
const nextBounds = pageBounds[nextShape.id]
const gap = nextBounds[min] - bounds[max]
const current = gaps.find((g) => g.gap === gap)
if (current) {
current.count++
} else {
gaps.push({ gap, count: 1 })
}
}
// Which gap is the most common?
let maxCount = 0
gaps.forEach((g) => {
if (g.count > maxCount) {
maxCount = g.count
shapeGap = g.gap
}
})
// If there is no most-common gap, use the average gap.
if (maxCount === 1) {
shapeGap = Math.max(0, gaps.reduce((a, c) => a + c.gap * c.count, 0) / (len - 1))
}
} else {
// If a gap was provided, then use that instead.
shapeGap = gap
}
const changes: TLShapePartial[] = []
let v = pageBounds[shapes[0].id][max]
shapes.forEach((shape, i) => {
if (i === 0) return
const delta = { x: 0, y: 0 }
delta[val] = v + shapeGap - pageBounds[shape.id][val]
const parent = this.getParentShape(shape)
const localDelta = parent ? Vec2d.Rot(delta, -this.getPageRotation(parent)) : delta
const translateStartChanges = this.getShapeUtil(shape).onTranslateStart?.(shape)
changes.push(
translateStartChanges
? {
...translateStartChanges,
[val]: shape[val] + localDelta[val],
}
: {
id: shape.id as any,
type: shape.type,
[val]: shape[val] + localDelta[val],
}
)
v += pageBounds[shape.id][dim] + shapeGap
})
this.updateShapes(changes)
return this
}
/**
* Pack shapes into a grid centered on their current position. Based on potpack
* (https://github.com/mapbox/potpack)
*
* @param ids - The ids of the shapes to pack. Defaults to selected shapes.
* @param padding - The padding to apply to the packed shapes.
*/
packShapes(ids: TLShapeId[] = this.pageState.selectedIds, padding = 16) {
if (this.isReadOnly) return this
if (ids.length < 2) return this
const shapes = compact(
ids
.map((id) => this.getShapeById(id))
.filter((shape) => {
if (!shape) return false
if (this.isShapeOfType(shape, TLArrowUtil)) {
2023-04-25 11:01:25 +00:00
if (shape.props.start.type === 'binding' || shape.props.end.type === 'binding') {
return false
}
}
return true
})
)
const shapePageBounds: Record<string, Box2d> = {}
const nextShapePageBounds: Record<string, Box2d> = {}
let shape: TLShape,
bounds: Box2d,
area = 0
for (let i = 0; i < shapes.length; i++) {
shape = shapes[i]
bounds = this.getPageBounds(shape)!
shapePageBounds[shape.id] = bounds
nextShapePageBounds[shape.id] = bounds.clone()
area += bounds.width * bounds.height
}
const commonBounds = Box2d.Common(compact(Object.values(shapePageBounds)))
const maxWidth = commonBounds.width
// sort the shapes by height, descending
shapes.sort((a, b) => shapePageBounds[b.id].height - shapePageBounds[a.id].height)
// Start with is (sort of) the square of the area
const startWidth = Math.max(Math.ceil(Math.sqrt(area / 0.95)), maxWidth)
// first shape fills the width and is infinitely tall
const spaces: Box2d[] = [new Box2d(commonBounds.x, commonBounds.y, startWidth, Infinity)]
let width = 0
let height = 0
let space: Box2d
let last: Box2d
for (let i = 0; i < shapes.length; i++) {
shape = shapes[i]
bounds = nextShapePageBounds[shape.id]
// starting at the back (smaller shapes)
for (let i = spaces.length - 1; i >= 0; i--) {
space = spaces[i]
// find a space that is big enough to contain the shape
if (bounds.width > space.width || bounds.height > space.height) continue
// add the shape to its top-left corner
bounds.x = space.x
bounds.y = space.y
height = Math.max(height, bounds.maxY)
width = Math.max(width, bounds.maxX)
if (bounds.width === space.width && bounds.height === space.height) {
// remove the space on a perfect fit
last = spaces.pop()!
if (i < spaces.length) spaces[i] = last
} else if (bounds.height === space.height) {
// fit the shape into the space (width)
space.x += bounds.width + padding
space.width -= bounds.width + padding
} else if (bounds.width === space.width) {
// fit the shape into the space (height)
space.y += bounds.height + padding
space.height -= bounds.height + padding
} else {
// split the space into two spaces
spaces.push(
new Box2d(
space.x + (bounds.width + padding),
space.y,
space.width - (bounds.width + padding),
bounds.height
)
)
space.y += bounds.height + padding
space.height -= bounds.height + padding
}
break
}
}
const commonAfter = Box2d.Common(Object.values(nextShapePageBounds))
const centerDelta = Vec2d.Sub(commonBounds.center, commonAfter.center)
let nextBounds: Box2d
const changes: TLShapePartial<any>[] = []
for (let i = 0; i < shapes.length; i++) {
shape = shapes[i]
bounds = shapePageBounds[shape.id]
nextBounds = nextShapePageBounds[shape.id]
const delta = this.getDeltaInParentSpace(
shape,
Vec2d.Sub(nextBounds.point, bounds.point).add(centerDelta)
)
const change: TLShapePartial = {
id: shape.id,
type: shape.type,
x: shape.x + delta.x,
y: shape.y + delta.y,
}
const translateStartChange = this.getShapeUtil(shape).onTranslateStart?.({
...shape,
...change,
})
2023-04-25 11:01:25 +00:00
if (translateStartChange) {
changes.push({ ...change, ...translateStartChange })
} else {
changes.push(change)
}
}
if (changes.length) {
this.updateShapes(changes)
}
return this
}
/**
* Align shape positions.
*
* @example
*
* ```ts
* app.alignShapes('left')
* app.alignShapes('left', ['box1', 'box2'])
* ```
*
* @param operation - The align operation to apply.
* @param ids - The ids of the shapes to align. Defaults to selected shapes.
* @public
*/
alignShapes(
operation: 'left' | 'center-horizontal' | 'right' | 'top' | 'center-vertical' | 'bottom',
ids: TLShapeId[] = this.pageState.selectedIds
) {
if (this.isReadOnly) return this
if (ids.length < 2) return this
const shapes = compact(ids.map((id) => this.getShapeById(id)))
const shapePageBounds = Object.fromEntries(
shapes.map((shape) => [shape.id, this.getPageBounds(shape)])
)
const commonBounds = Box2d.Common(compact(Object.values(shapePageBounds)))
const changes: TLShapePartial[] = []
shapes.forEach((shape) => {
const pageBounds = shapePageBounds[shape.id]
if (!pageBounds) return
const delta = { x: 0, y: 0 }
switch (operation) {
case 'top': {
delta.y = commonBounds.minY - pageBounds.minY
break
}
case 'center-vertical': {
delta.y = commonBounds.midY - pageBounds.minY - pageBounds.height / 2
break
}
case 'bottom': {
delta.y = commonBounds.maxY - pageBounds.minY - pageBounds.height
break
}
case 'left': {
delta.x = commonBounds.minX - pageBounds.minX
break
}
case 'center-horizontal': {
delta.x = commonBounds.midX - pageBounds.minX - pageBounds.width / 2
break
}
case 'right': {
delta.x = commonBounds.maxX - pageBounds.minX - pageBounds.width
break
}
}
const parent = this.getParentShape(shape)
const localDelta = parent ? Vec2d.Rot(delta, -this.getPageRotation(parent)) : delta
const translateChanges = this.getShapeUtil(shape).onTranslateStart?.(shape)
changes.push(
translateChanges
? {
...translateChanges,
x: shape.x + localDelta.x,
y: shape.y + localDelta.y,
}
: {
id: shape.id,
type: shape.type,
x: shape.x + localDelta.x,
y: shape.y + localDelta.y,
}
)
})
this.updateShapes(changes)
return this
}
/**
* Distribute shape positions.
*
* @example
*
* ```ts
* app.distributeShapes('left')
* app.distributeShapes('left', ['box1', 'box2'])
* ```
*
* @param operation - Whether to distribute shapes horizontally or vertically.
* @param ids - The ids of the shapes to distribute. Defaults to selected shapes.
* @public
*/
distributeShapes(
operation: 'horizontal' | 'vertical',
ids: TLShapeId[] = this.pageState.selectedIds
) {
if (this.isReadOnly) return this
if (ids.length < 3) return this
const len = ids.length
const shapes = compact(ids.map((id) => this.getShapeById(id)))
const pageBounds = Object.fromEntries(
shapes.map((shape) => [shape.id, this.getPageBounds(shape)!])
)
let val: 'x' | 'y'
let min: 'minX' | 'minY'
let max: 'maxX' | 'maxY'
let mid: 'midX' | 'midY'
let dim: 'width' | 'height'
if (operation === 'horizontal') {
val = 'x'
min = 'minX'
max = 'maxX'
mid = 'midX'
dim = 'width'
} else {
val = 'y'
min = 'minY'
max = 'maxY'
mid = 'midY'
dim = 'height'
}
const changes: TLShapePartial[] = []
// Clustered
const first = shapes.sort((a, b) => pageBounds[a.id][min] - pageBounds[b.id][min])[0]
const last = shapes.sort((a, b) => pageBounds[b.id][max] - pageBounds[a.id][max])[0]
const midFirst = pageBounds[first.id][mid]
const step = (pageBounds[last.id][mid] - midFirst) / (len - 1)
const v = midFirst + step
shapes
.filter((shape) => shape !== first && shape !== last)
.sort((a, b) => pageBounds[a.id][mid] - pageBounds[b.id][mid])
.forEach((shape, i) => {
const delta = { x: 0, y: 0 }
delta[val] = v + step * i - pageBounds[shape.id][dim] / 2 - pageBounds[shape.id][val]
const parent = this.getParentShape(shape)
const localDelta = parent ? Vec2d.Rot(delta, -this.getPageRotation(parent)) : delta
const translateStartChanges = this.getShapeUtil(shape).onTranslateStart?.(shape)
changes.push(
translateStartChanges
? {
...translateStartChanges,
[val]: shape[val] + localDelta[val],
}
: {
id: shape.id,
type: shape.type,
[val]: shape[val] + localDelta[val],
}
)
})
this.updateShapes(changes)
return this
}
/** @internal */
private _resizeUnalignedShape(
id: TLShapeId,
scale: VecLike,
options: {
initialBounds: Box2d
scaleOrigin: VecLike
scaleAxisRotation: number
initialShape: TLShape
initialPageTransform: MatLike
}
) {
const { type } = options.initialShape
// If a shape is not aligned with the scale axis we need to treat it differently to avoid skewing.
// Instead of skewing we normalise the scale aspect ratio (i.e. keep the same scale magnitude in both axes)
// and then after applying the scale to the shape we also rotate it if required and translate it so that it's center
// point ends up in the right place.
const shapeScale = new Vec2d(scale.x, scale.y)
// // make sure we are contraining aspect ratio, and using the smallest scale axis to avoid shapes getting bigger
// // than the selection bounding box
if (Math.abs(scale.x) > Math.abs(scale.y)) {
shapeScale.x = Math.sign(scale.x) * Math.abs(scale.y)
} else {
shapeScale.y = Math.sign(scale.y) * Math.abs(scale.x)
}
// first we can scale the shape about its center point
this.resizeShape(id, shapeScale, {
initialShape: options.initialShape,
initialBounds: options.initialBounds,
})
// then if the shape is flipped in one axis only, we need to apply an extra rotation
// to make sure the shape is mirrored correctly
if (Math.sign(scale.x) * Math.sign(scale.y) < 0) {
let { rotation } = Matrix2d.Decompose(options.initialPageTransform)
rotation -= 2 * rotation
this.updateShapes([{ id, type, rotation }], true)
}
// Next we need to translate the shape so that it's center point ends up in the right place.
// To do that we first need to calculate the center point of the shape in page space before the scale was applied.
const preScaleShapePageCenter = Matrix2d.applyToPoint(
options.initialPageTransform,
options.initialBounds.center
)
// And now we scale the center point by the original scale factor
const postScaleShapePageCenter = this._scalePagePoint(
preScaleShapePageCenter,
options.scaleOrigin,
scale,
options.scaleAxisRotation
)
// now caculate how far away the shape is from where it needs to be
const currentPageCenter = this.getPageCenterById(id)
const currentPagePoint = this.getPagePointById(id)
if (!currentPageCenter || !currentPagePoint) return this
const pageDelta = Vec2d.Sub(postScaleShapePageCenter, currentPageCenter)
// and finally figure out what the shape's new position should be
const postScaleShapePagePoint = Vec2d.Add(currentPagePoint, pageDelta)
const { x, y } = this.getPointInParentSpace(id, postScaleShapePagePoint)
this.updateShapes([{ id, type, x, y }], true)
return this
}
/** @internal */
private _scalePagePoint(
point: VecLike,
scaleOrigin: VecLike,
scale: VecLike,
scaleAxisRotation: number
) {
const relativePoint = Vec2d.RotWith(point, scaleOrigin, -scaleAxisRotation).sub(scaleOrigin)
// calculate the new point position relative to the scale origin
const newRelativePagePoint = Vec2d.MulV(relativePoint, scale)
// and rotate it back to page coords to get the new page point of the resized shape
const destination = Vec2d.Add(newRelativePagePoint, scaleOrigin).rotWith(
scaleOrigin,
scaleAxisRotation
)
return destination
}
resizeShape(
id: TLShapeId,
scale: VecLike,
options?: {
initialBounds?: Box2d
scaleOrigin?: VecLike
scaleAxisRotation?: number
initialShape?: TLShape
initialPageTransform?: MatLike
dragHandle?: TLResizeHandle
mode?: TLResizeMode
}
) {
if (this.isReadOnly) return this
if (!Number.isFinite(scale.x)) scale = new Vec2d(1, scale.y)
if (!Number.isFinite(scale.y)) scale = new Vec2d(scale.x, 1)
const initialShape = options?.initialShape ?? this.getShapeById(id)
if (!initialShape) return this
const scaleOrigin = options?.scaleOrigin ?? this.getPageBoundsById(id)?.center
if (!scaleOrigin) return this
const pageRotation = this.getPageRotationById(id)
if (pageRotation == null) return this
const scaleAxisRotation = options?.scaleAxisRotation ?? pageRotation
const pageTransform = options?.initialPageTransform ?? this.getPageTransformById(id)
if (!pageTransform) return this
const initialBounds = options?.initialBounds ?? this.getBoundsById(id)
if (!initialBounds) return this
if (!areAnglesCompatible(pageRotation, scaleAxisRotation)) {
// shape is awkwardly rotated, keep the aspect ratio locked and adopt the scale factor
// from whichever axis is being scaled the least, to avoid the shape getting bigger
// than the bounds of the selection
// const minScale = Math.min(Math.abs(scale.x), Math.abs(scale.y))
return this._resizeUnalignedShape(id, scale, {
...options,
initialBounds,
scaleOrigin,
scaleAxisRotation,
initialPageTransform: pageTransform,
initialShape,
})
}
const util = this.getShapeUtil(initialShape)
if (util.isAspectRatioLocked(initialShape)) {
if (Math.abs(scale.x) > Math.abs(scale.y)) {
scale = new Vec2d(scale.x, Math.sign(scale.y) * Math.abs(scale.x))
} else {
scale = new Vec2d(Math.sign(scale.x) * Math.abs(scale.y), scale.y)
}
}
if (util.onResize && util.canResize(initialShape)) {
// get the model changes from the shape util
const newPagePoint = this._scalePagePoint(
Matrix2d.applyToPoint(pageTransform, new Vec2d(0, 0)),
scaleOrigin,
scale,
scaleAxisRotation
)
const newLocalPoint = this.getPointInParentSpace(initialShape.id, newPagePoint)
// resize the shape's local bounding box
const myScale = new Vec2d(scale.x, scale.y)
// the shape is algined with the rest of the shpaes in the selection, but may be
// 90deg offset from the main rotation of the selection, in which case
// we need to flip the width and height scale factors
const areWidthAndHeightAlignedWithCorrectAxis = approximately(
(pageRotation - scaleAxisRotation) % Math.PI,
0
)
myScale.x = areWidthAndHeightAlignedWithCorrectAxis ? scale.x : scale.y
myScale.y = areWidthAndHeightAlignedWithCorrectAxis ? scale.y : scale.x
// adjust initial model for situations where the parent has moved during the resize
// e.g. groups
const initialPagePoint = Matrix2d.applyToPoint(pageTransform, new Vec2d())
// need to adjust the shape's x and y points in case the parent has moved since start of resizing
const { x, y } = this.getPointInParentSpace(initialShape.id, initialPagePoint)
this.updateShapes(
[
{
id,
type: initialShape.type as any,
x: newLocalPoint.x,
y: newLocalPoint.y,
...util.onResize(
{ ...initialShape, x, y },
{
newPoint: newLocalPoint,
handle: options?.dragHandle ?? 'bottom_right',
// don't set isSingle to true for children
mode: options?.mode ?? 'scale_shape',
scaleX: myScale.x,
scaleY: myScale.y,
initialBounds,
initialShape,
}
),
},
],
true
)
} else {
const initialPageCenter = Matrix2d.applyToPoint(pageTransform, initialBounds.center)
// get the model changes from the shape util
const newPageCenter = this._scalePagePoint(
initialPageCenter,
scaleOrigin,
scale,
scaleAxisRotation
)
const initialPageCenterInParentSpace = this.getPointInParentSpace(
initialShape.id,
initialPageCenter
)
const newPageCenterInParentSpace = this.getPointInParentSpace(initialShape.id, newPageCenter)
const delta = Vec2d.Sub(newPageCenterInParentSpace, initialPageCenterInParentSpace)
// apply the changes to the model
this.updateShapes(
[
{
id,
type: initialShape.type as any,
x: initialShape.x + delta.x,
y: initialShape.y + delta.y,
},
],
true
)
}
return this
}
/**
* Stretch shape sizes and positions to fill their common bounding box.
*
* @example
*
* ```ts
* app.stretchShapes('horizontal')
* app.stretchShapes('horizontal', ['box1', 'box2'])
* ```
*
* @param operation - Whether to stretch shapes horizontally or vertically.
* @param ids - The ids of the shapes to stretch. Defaults to selected shapes.
* @public
*/
stretchShapes(
operation: 'horizontal' | 'vertical',
ids: TLShapeId[] = this.pageState.selectedIds
) {
if (this.isReadOnly) return this
if (ids.length < 2) return this
const shapes = compact(ids.map((id) => this.getShapeById(id)))
const shapeBounds = Object.fromEntries(shapes.map((shape) => [shape.id, this.getBounds(shape)]))
const shapePageBounds = Object.fromEntries(
shapes.map((shape) => [shape.id, this.getPageBounds(shape)!])
)
const commonBounds = Box2d.Common(compact(Object.values(shapePageBounds)))
const changes: TLShapePartial[] = []
switch (operation) {
case 'vertical': {
this.batch(() => {
for (const shape of shapes) {
const pageRotation = this.getPageRotation(shape)
if (pageRotation % PI2) continue
const bounds = shapeBounds[shape.id]
const pageBounds = shapePageBounds[shape.id]
const localOffset = this.getDeltaInParentSpace(
shape,
new Vec2d(0, commonBounds.minY - pageBounds.minY)
)
const { x, y } = Vec2d.Add(localOffset, shape)
this.updateShapes([{ id: shape.id, type: shape.type, x, y }], true)
const scale = new Vec2d(1, commonBounds.height / pageBounds.height)
this.resizeShape(shape.id, scale, {
initialBounds: bounds,
scaleOrigin: new Vec2d(pageBounds.center.x, commonBounds.minY),
scaleAxisRotation: 0,
})
}
})
break
}
case 'horizontal': {
this.batch(() => {
for (const shape of shapes) {
const bounds = shapeBounds[shape.id]
const pageBounds = shapePageBounds[shape.id]
const pageRotation = this.getPageRotation(shape)
if (pageRotation % PI2) continue
const localOffset = this.getDeltaInParentSpace(
shape,
new Vec2d(commonBounds.minX - pageBounds.minX, 0)
)
const { x, y } = Vec2d.Add(localOffset, shape)
this.updateShapes([{ id: shape.id, type: shape.type, x, y }], true)
const scale = new Vec2d(commonBounds.width / pageBounds.width, 1)
this.resizeShape(shape.id, scale, {
initialBounds: bounds,
scaleOrigin: new Vec2d(commonBounds.minX, pageBounds.center.y),
scaleAxisRotation: 0,
})
}
})
break
}
}
this.updateShapes(changes)
return this
}
/**
* Reparent shapes to a new parent. This operation preserves the shape's current page positions /
* rotations.
*
* @example
*
* ```ts
* app.reparentShapesById(['box1', 'box2'], 'frame1')
* ```
*
* @param ids - The ids of the shapes to reparent.
* @param parentId - The id of the new parent shape.
* @param insertIndex - The index to insert the children.
* @public
*/
reparentShapesById(ids: TLShapeId[], parentId: TLParentId, insertIndex?: string) {
const changes: TLShapePartial[] = []
const parentTransform = isPageId(parentId)
2023-04-25 11:01:25 +00:00
? Matrix2d.Identity()
: this.getPageTransformById(parentId)!
const parentPageRotation = parentTransform.decompose().rotation
let indices: string[] = []
const sibs = compact(this.getSortedChildIds(parentId).map((id) => this.getShapeById(id)))
if (insertIndex) {
const sibWithInsertIndex = sibs.find((s) => s.index === insertIndex)
if (sibWithInsertIndex) {
// If there's a sibling with the same index as the insert index...
const sibAbove = sibs[sibs.indexOf(sibWithInsertIndex) + 1]
if (sibAbove) {
// If the sibling has a sibling above it, insert the shapes
// between the sibling and its sibling above it.
indices = getIndicesBetween(insertIndex, sibAbove.index, ids.length)
} else {
// Or if the sibling is the top sibling, insert the shapes
// above the sibling
indices = getIndicesAbove(insertIndex, ids.length)
}
} else {
// If there's no collision, then we can start at the insert index
const sibAbove = sibs.sort(sortByIndex).find((s) => s.index > insertIndex)
if (sibAbove) {
// If the siblings include a sibling with a higher index, insert the shapes
// between the insert index and the sibling with the higher index.
indices = getIndicesBetween(insertIndex, sibAbove.index, ids.length)
} else {
// Otherwise, we're at the top of the order, so insert the shapes above
// the insert index.
indices = getIndicesAbove(insertIndex, ids.length)
}
}
} else {
// If insert index is not specified, start the index at the top.
const sib = sibs.length && sibs[sibs.length - 1]
indices = sib ? getIndicesAbove(sib.index, ids.length) : getIndices(ids.length)
}
let id: TLShapeId
for (let i = 0; i < ids.length; i++) {
id = ids[i]
const shape = this.getShapeById(id)
const pagePoint = this.getPagePointById(id)
if (!shape || !pagePoint) continue
const newPoint = Matrix2d.applyToPoint(Matrix2d.Inverse(parentTransform), pagePoint)
const newRotation = this.getPageRotation(shape) - parentPageRotation
changes.push({
id: shape.id,
type: shape.type,
parentId: parentId,
x: newPoint.x,
y: newPoint.y,
rotation: newRotation,
index: indices[i],
})
}
this.updateShapes(changes)
return this
}
/**
* Select one or more shapes.
*
* @example
*
* ```ts
* app.select('id1')
* app.select('id1', 'id2')
* ```
*
* @param ids - The ids to select.
* @public
*/
select(...ids: TLShapeId[]) {
this.setSelectedIds(ids)
return this
}
/**
* Remove a shpae from the existing set of selected shapes.
*
* @example
*
* ```ts
* app.deselect(shape.id)
* ```
*
* @public
*/
deselect(...ids: TLShapeId[]) {
const { selectedIds } = this
if (selectedIds.length > 0 && ids.length > 0) {
this.setSelectedIds(selectedIds.filter((id) => !ids.includes(id)))
}
return this
}
/**
* Select all direct children of the current page.
*
* @example
*
* ```ts
* app.selectAll()
* ```
*
* @public
*/
selectAll() {
const ids = this.getSortedChildIds(this.currentPageId)
// page might have no shapes
if (ids.length <= 0) return this
this.setSelectedIds(this._getUnlockedShapeIds(ids))
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
2023-04-25 11:01:25 +00:00
return this
}
getShapeAndDescendantIds(ids: TLShapeId[]): Set<TLShapeId> {
const idsToInclude = new Set<TLShapeId>()
2023-04-25 11:01:25 +00:00
const idsToCheck = [...ids]
while (idsToCheck.length > 0) {
const id = idsToCheck.pop()
if (!id) break
if (idsToInclude.has(id)) continue
idsToInclude.add(id)
2023-04-25 11:01:25 +00:00
this.getSortedChildIds(id).forEach((id) => {
idsToCheck.push(id)
})
}
return idsToInclude
2023-04-25 11:01:25 +00:00
}
/**
* Clear the selection.
*
* @example
*
* ```ts
* app.selectNone()
* ```
*
* @public
*/
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
selectNone(): this {
2023-04-25 11:01:25 +00:00
if (this.selectedIds.length > 0) {
this.setSelectedIds([])
}
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
2023-04-25 11:01:25 +00:00
return this
}
/**
* Set the current page.
*
* @example
*
* ```ts
* app.setCurrentPageId('page1')
* ```
*
* @param pageId - The id of the page to set as the current page.
* @param options - Options for setting the current page.
* @public
*/
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
setCurrentPageId(pageId: TLPageId, { stopFollowing = true }: ViewportOptions = {}): this {
2023-04-25 11:01:25 +00:00
this._setCurrentPageId(pageId, { stopFollowing })
return this
}
/** @internal */
private _setCurrentPageId = this.history.createCommand(
'setCurrentPage',
(pageId: TLPageId, { stopFollowing = true }: ViewportOptions = {}) => {
if (!this.store.has(pageId)) {
console.error("Tried to set the current page id to a page that doesn't exist.")
return
}
if (stopFollowing && this.instanceState.followingUserId) {
this.stopFollowingUser()
}
return {
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
data: { toId: pageId, fromId: this.currentPageId },
2023-04-25 11:01:25 +00:00
squashing: true,
preservesRedoStack: true,
}
},
{
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
do: ({ toId }) => {
if (!this.getPageStateByPageId(toId)) {
const camera = CameraRecordType.create({})
2023-04-25 11:01:25 +00:00
this.store.put([
camera,
InstancePageStateRecordType.create({
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
pageId: toId,
2023-04-25 11:01:25 +00:00
instanceId: this.instanceId,
cameraId: camera.id,
}),
])
}
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
this.store.put([{ ...this.instanceState, currentPageId: toId }])
2023-04-25 11:01:25 +00:00
this.updateCullingBounds()
},
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
undo: ({ fromId }) => {
this.store.put([{ ...this.instanceState, currentPageId: fromId }])
2023-04-25 11:01:25 +00:00
this.updateCullingBounds()
},
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
squash: ({ fromId }, { toId }) => {
return { toId, fromId }
2023-04-25 11:01:25 +00:00
},
}
)
/** Set the current user tab state */
updateInstanceState(
partial: Partial<Omit<TLInstance, 'documentId' | 'userId' | 'currentPageId'>>,
ephemeral = false,
squashing = false
) {
this._updateInstanceState(partial, ephemeral, squashing)
return this
}
/** @internal */
private _updateInstanceState = this.history.createCommand(
'updateTabState',
(
partial: Partial<Omit<TLInstance, 'documentId' | 'userId' | 'currentPageId'>>,
ephemeral = false,
squashing = false
) => {
const prev = this.instanceState
const next = { ...prev, ...partial }
return {
data: { prev, next },
squashing,
ephemeral,
}
},
{
do: ({ next }) => {
this.store.put([next])
},
undo: ({ prev }) => {
this.store.put([prev])
},
squash({ prev }, { next }) {
return { prev, next }
},
}
)
@computed get hoveredId() {
return this.pageState.hoveredId
}
@computed get hoveredShape() {
if (!this.hoveredId) return null
return this.getShapeById(this.hoveredId) ?? null
}
/**
* Set the current hovered shape.
*
* @example
*
* ```ts
* app.setHoveredId('box1')
* app.setHoveredId() // Clears the hovered shape.
* ```
*
* @param id - The id of the page to set as the current page
* @public
*/
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
setHoveredId(id: TLShapeId | null = null): this {
2023-04-25 11:01:25 +00:00
if (id === this.pageState.hoveredId) return this
this.setInstancePageState({ hoveredId: id }, true)
return this
}
/**
* Set the current erasing shapes.
*
* @example
*
* ```ts
* app.setErasingIds(['box1', 'box2'])
* app.setErasingIds() // Clears the erasing set
* ```
*
* @param ids - The ids of shapes to set as erasing.
* @public
*/
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
setErasingIds(ids: TLShapeId[] = []): this {
2023-04-25 11:01:25 +00:00
const erasingIds = this.erasingIdsSet
if (ids.length === erasingIds.size && ids.every((id) => erasingIds.has(id))) return this
this.setInstancePageState({ erasingIds: ids }, true)
return this
}
/**
* Set the current cursor.
*
* @example
*
* ```ts
* app.setCursor({ type: 'default' })
* app.setCursor({ type: 'default', rotation: Math.PI / 2, color: 'red' })
* ```
*
* @param cursor - A partial of the cursor object.
* @public
*/
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
setCursor(cursor: Partial<TLCursor>): this {
2023-04-25 11:01:25 +00:00
const current = this.cursor
const next = {
...current,
rotation: 0,
...cursor,
}
if (
!(
current.type === next.type &&
current.rotation === next.rotation &&
current.color === next.color
)
) {
this.updateInstanceState({ cursor: next }, true)
}
return this
}
/**
* Set the current scribble.
*
* @example
*
* ```ts
* app.setScribble(nextScribble)
* app.setScribble() // clears the scribble
* ```
*
* @param scribble - The new scribble object.
* @public
*/
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
setScribble(scribble: TLScribble | null = null): this {
2023-04-25 11:01:25 +00:00
this.updateInstanceState({ scribble }, true)
return this
}
/**
* Set the current brush.
*
* @example
*
* ```ts
* app.setBrush({ x: 0, y: 0, w: 100, h: 100 })
* app.setBrush() // Clears the brush
* ```
*
* @param brush - The brush box model to set, or null for no brush model.
* @public
*/
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
setBrush(brush: Box2dModel | null = null): this {
2023-04-25 11:01:25 +00:00
if (!brush && !this.brush) return this
this.updateInstanceState({ brush }, true)
return this
}
/**
* Set the current zoom brush.
*
* @example
*
* ```ts
* app.setZoomBrush({ x: 0, y: 0, w: 100, h: 100 })
* app.setZoomBrush() // Clears the zoom
* ```
*
* @param zoomBrush - The zoom box model to set, or null for no zoom model.
* @public
*/
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
setZoomBrush(zoomBrush: Box2dModel | null = null): this {
2023-04-25 11:01:25 +00:00
if (!zoomBrush && !this.zoomBrush) return this
this.updateInstanceState({ zoomBrush }, true)
return this
}
/**
* Rotate shapes by a delta in radians.
*
* @example
*
* ```ts
* app.rotateShapesBy(['box1', 'box2'], Math.PI)
* app.rotateShapesBy(['box1', 'box2'], Math.PI / 2)
* ```
*
* @param ids - The ids of the shapes to move.
* @param delta - The delta in radians to apply to the selection rotation.
*/
rotateShapesBy(ids: TLShapeId[], delta: number): this {
if (ids.length <= 0) return this
const snapshot = getRotationSnapshot({ app: this })
applyRotationToSnapshotShapes({ delta, snapshot, app: this, stage: 'one-off' })
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
2023-04-25 11:01:25 +00:00
return this
}
/**
* Move shapes by a delta.
*
* @example
*
* ```ts
* app.nudgeShapes(['box1', 'box2'], { x: 0, y: 1 })
* app.nudgeShapes(['box1', 'box2'], { x: 0, y: 1 }, true)
* ```
*
* @param ids - The ids of the shapes to move.
* @param direction - The direction in which to move the shapes.
* @param major - Whether this is a major nudge, e.g. a shift + arrow nudge.
*/
nudgeShapes(ids: TLShapeId[], direction: Vec2dModel, major = false, ephemeral = false) {
if (ids.length <= 0) return this
const step = this.isGridMode
? major
? this.gridSize * GRID_INCREMENT
: this.gridSize
: major
? MAJOR_NUDGE_FACTOR
: MINOR_NUDGE_FACTOR
const steppedDelta = Vec2d.Mul(direction, step)
const changes: TLShapePartial[] = []
for (const id of ids) {
const shape = this.getShapeById(id)
if (!shape) {
throw Error(`Could not find a shape with the id ${id}.`)
}
const localDelta = this.getDeltaInParentSpace(shape, steppedDelta)
const translateStartChanges = this.getShapeUtil(shape).onTranslateStart?.(shape)
changes.push(
translateStartChanges
? {
...translateStartChanges,
x: shape.x + localDelta.x,
y: shape.y + localDelta.y,
}
: {
id,
x: shape.x + localDelta.x,
y: shape.y + localDelta.y,
type: shape.type,
}
)
}
this.updateShapes(changes, ephemeral)
return this
}
/**
* Duplicate shapes.
*
* @example
*
* ```ts
* app.duplicateShapes()
* app.duplicateShapes(['id1', 'id2'])
* app.duplicateShapes(['id1', 'id2'], { x: 8, y: 8 })
* ```
*
* @param ids - The ids of the shapes to duplicate. Defaults to the ids of the selected shapes.
* @param offset - The offset (in pixels) to apply to the duplicated shapes.
* @public
*/
duplicateShapes(ids: TLShapeId[] = this.selectedIds, offset?: VecLike) {
if (ids.length <= 0) return this
const initialIds = new Set(ids)
const idsToCreate: TLShapeId[] = []
const idsToCheck = [...ids]
while (idsToCheck.length > 0) {
const id = idsToCheck.pop()
if (!id) break
idsToCreate.push(id)
this.getSortedChildIds(id).forEach((childId) => idsToCheck.push(childId))
}
idsToCreate.reverse()
const idsMap = new Map<any, TLShapeId>(idsToCreate.map((id) => [id, this.createShapeId()]))
const shapesToCreate = compact(
idsToCreate.map((id) => {
const shape = this.getShapeById(id)
if (!shape) {
return null
}
const createId = idsMap.get(id)!
let ox = 0
let oy = 0
if (offset && initialIds.has(id)) {
const parentTransform = this.getParentTransform(shape)
const vec = new Vec2d(offset.x, offset.y).rot(
-Matrix2d.Decompose(parentTransform).rotation
)
ox = vec.x
oy = vec.y
}
const parentId = shape.parentId ?? this.currentPageId
const siblings = this.getSortedChildIds(parentId)
const currentIndex = siblings.indexOf(shape.id)
const siblingAboveId = siblings[currentIndex + 1]
const siblingAbove = siblingAboveId ? this.getShapeById(siblingAboveId) : null
const index = siblingAbove
? getIndexBetween(shape.index, siblingAbove.index)
: getIndexAbove(shape.index)
let newShape: TLShape = deepCopy(shape)
if (this.isShapeOfType(shape, TLArrowUtil) && this.isShapeOfType(newShape, TLArrowUtil)) {
const info = this.getShapeUtil(TLArrowUtil).getArrowInfo(shape)
2023-04-25 11:01:25 +00:00
let newStartShapeId: TLShapeId | undefined = undefined
let newEndShapeId: TLShapeId | undefined = undefined
if (shape.props.start.type === 'binding') {
newStartShapeId = idsMap.get(shape.props.start.boundShapeId)
if (!newStartShapeId) {
if (info?.isValid) {
const { x, y } = info.start.point
newShape.props.start = {
type: 'point',
x,
y,
}
} else {
const { start } = getArrowTerminalsInArrowSpace(this, shape)
newShape.props.start = {
type: 'point',
x: start.x,
y: start.y,
}
}
}
}
if (shape.props.end.type === 'binding') {
newEndShapeId = idsMap.get(shape.props.end.boundShapeId)
if (!newEndShapeId) {
if (info?.isValid) {
const { x, y } = info.end.point
newShape.props.end = {
type: 'point',
x,
y,
}
} else {
const { end } = getArrowTerminalsInArrowSpace(this, shape)
newShape.props.start = {
type: 'point',
x: end.x,
y: end.y,
}
}
}
}
const infoAfter = getIsArrowStraight(newShape)
? getStraightArrowInfo(this, newShape)
: getCurvedArrowInfo(this, newShape)
if (info?.isValid && infoAfter?.isValid && !getIsArrowStraight(shape)) {
const mpA = Vec2d.Med(info.start.handle, info.end.handle)
const distA = Vec2d.Dist(info.middle, mpA)
const distB = Vec2d.Dist(infoAfter.middle, mpA)
if (newShape.props.bend < 0) {
newShape.props.bend += distB - distA
} else {
newShape.props.bend -= distB - distA
}
}
if (newShape.props.start.type === 'binding' && newStartShapeId) {
newShape.props.start.boundShapeId = newStartShapeId
}
if (newShape.props.end.type === 'binding' && newEndShapeId) {
newShape.props.end.boundShapeId = newEndShapeId
}
}
newShape = { ...newShape, id: createId, x: shape.x + ox, y: shape.y + oy, index }
return newShape
})
)
shapesToCreate.forEach((shape) => {
if (isShapeId(shape.parentId)) {
if (idsMap.has(shape.parentId)) {
shape.parentId = idsMap.get(shape.parentId)!
}
}
})
this.history.batch(() => {
const maxShapesReached = shapesToCreate.length + this.shapeIds.size > MAX_SHAPES_PER_PAGE
if (maxShapesReached) {
alertMaxShapes(this)
}
const newShapes = maxShapesReached
? shapesToCreate.slice(0, MAX_SHAPES_PER_PAGE - this.shapeIds.size)
: shapesToCreate
const ids = newShapes.map((s) => s.id)
this.createShapes(newShapes)
this.setSelectedIds(ids)
if (offset !== undefined) {
// If we've offset the duplicated shapes, check to see whether their new bounds is entirely
// contained in the current viewport. If not, then animate the camera to be centered on the
// new shapes.
const { viewportPageBounds, selectedPageBounds } = this
if (selectedPageBounds && !viewportPageBounds.contains(selectedPageBounds)) {
this.centerOnPoint(selectedPageBounds.center.x, selectedPageBounds.center.y, {
duration: ANIMATION_MEDIUM_MS,
})
}
}
})
return this
}
/**
* Set the current props (generally styles).
*
* @example
*
* ```ts
* app.setProp('color', 'red')
* app.setProp('color', 'red', true)
* ```
*
* @param key - The key to set.
* @param value - The value to set.
* @param ephemeral - Whether the style is ephemeral. Defaults to false.
* @public
*/
setProp(key: TLShapeProp, value: any, ephemeral = false, squashing = false) {
const children: (TLShape | undefined)[] = []
// We can have many deep levels of grouped shape
// Making a recursive function to look through all the levels
const getChildProp = (id: TLShape['id']) => {
const childIds = this.getSortedChildIds(id)
for (const childId of childIds) {
const childShape = this.getShapeById(childId)
if (childShape?.type === 'group') {
getChildProp(childShape.id)
}
children.push(childShape)
}
}
this.history.batch(() => {
this.updateInstanceState(
{
propsForNextShape: setPropsForNextShape(this.instanceState.propsForNextShape, {
[key]: value,
}),
},
ephemeral,
squashing
)
if (this.isIn('select')) {
const {
pageState: { selectedIds },
} = this
if (selectedIds.length > 0) {
const shapes = compact(
selectedIds.map((id) => {
const shape = this.getShapeById(id)
if (shape?.type === 'group') {
const childIds = this.getSortedChildIds(shape.id)
for (const childId of childIds) {
const childShape = this.getShapeById(childId)
if (childShape?.type === 'group') {
getChildProp(childShape.id)
}
children.push(childShape)
}
return children
} else {
return shape
}
})
)
.flat()
.filter(
(shape) =>
shape!.props[key as keyof TLShape['props']] !== undefined && shape?.type !== 'group'
) as TLShape[]
this.updateShapes(
shapes.map((shape) => {
const props = { ...shape.props, [key]: value }
if (key === 'color' && 'labelColor' in props) {
props.labelColor = 'black'
}
return {
id: shape.id,
type: shape.type,
props,
}
2023-04-25 11:01:25 +00:00
}),
ephemeral
)
if (key !== 'color' && key !== 'opacity') {
const changes: TLShapePartial[] = []
for (const shape of shapes) {
const currentShape = this.getShapeById(shape.id)
if (!currentShape) continue
const util = this.getShapeUtil(currentShape)
const boundsA = util.bounds(shape)
const boundsB = util.bounds(currentShape)
const change: TLShapePartial = { id: shape.id, type: shape.type }
let didChange = false
if (boundsA.width !== boundsB.width) {
didChange = true
if (this.isShapeOfType(shape, TLTextUtil)) {
2023-04-25 11:01:25 +00:00
switch (shape.props.align) {
case 'middle': {
change.x = currentShape.x + (boundsA.width - boundsB.width) / 2
break
}
case 'end': {
change.x = currentShape.x + boundsA.width - boundsB.width
break
}
}
} else {
change.x = currentShape.x + (boundsA.width - boundsB.width) / 2
}
}
if (boundsA.height !== boundsB.height) {
didChange = true
change.y = currentShape.y + (boundsA.height - boundsB.height) / 2
}
if (didChange) {
changes.push(change)
}
}
if (changes.length) {
this.updateShapes(changes, ephemeral)
}
}
}
}
this.updateInstanceState(
{
propsForNextShape: setPropsForNextShape(this.instanceState.propsForNextShape, {
[key]: value,
}),
},
ephemeral,
squashing
)
})
return this
}
/** @internal */
private _willSetInitialBounds = true
/** @internal */
private _setCamera(x: number, y: number, z = this.camera.z) {
const currentCamera = this.camera
if (currentCamera.x === x && currentCamera.y === y && currentCamera.z === z) return this
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
const nextCamera = { ...currentCamera, x, y, z }
2023-04-25 11:01:25 +00:00
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
this.batch(() => {
this.store.put([nextCamera])
const { currentScreenPoint } = this.inputs
this.dispatch({
type: 'pointer',
target: 'canvas',
name: 'pointer_move',
point: currentScreenPoint,
pointerId: 0,
ctrlKey: this.inputs.ctrlKey,
altKey: this.inputs.altKey,
shiftKey: this.inputs.shiftKey,
button: 0,
isPen: this.isPenMode ?? false,
})
2023-04-25 11:01:25 +00:00
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
this._cameraManager.tick()
2023-04-25 11:01:25 +00:00
})
return this
}
/**
* Set the current camera.
*
* @example
*
* ```ts
* app.setCamera(0, 0)
* app.setCamera(0, 0, 1)
* ```
*
* @param x - The camera's x position.
* @param y - The camera's y position.
* @param z - The camera's z position. Defaults to the current zoom.
* @param options - Options for the camera change.
* @public
*/
setCamera(
x: number,
y: number,
z = this.camera.z,
{ stopFollowing = true }: ViewportOptions = {}
) {
this.stopCameraAnimation()
if (stopFollowing && this.instanceState.followingUserId) {
this.stopFollowingUser()
}
x = Number.isNaN(x) ? 0 : x
y = Number.isNaN(y) ? 0 : y
z = Number.isNaN(z) ? 1 : z
this._setCamera(x, y, z)
return this
}
enableAnimations = true
2023-04-25 11:01:25 +00:00
/**
* Animate the camera.
*
* @example
*
* ```ts
* app.animateCamera(0, 0)
* app.animateCamera(0, 0, 1)
* app.animateCamera(0, 0, 1, { duration: 1000, easing: (t) => t * t })
* ```
*
* @param x - The camera's x position.
* @param y - The camera's y position.
* @param z - The camera's z position. Defaults to the current zoom.
* @param opts - Options for the animation.
* @public
*/
animateCamera(
x: number,
y: number,
z = this.camera.z,
opts: AnimationOptions = DEFAULT_ANIMATION_OPTIONS
) {
x = Number.isNaN(x) ? 0 : x
y = Number.isNaN(y) ? 0 : y
z = Number.isNaN(z) ? 1 : z
const { width, height } = this.viewportScreenBounds
const w = width / z
const h = height / z
const targetViewport = new Box2d(-x, -y, w, h)
if (!this.enableAnimations) {
return this.setCamera(x, y, this.viewportScreenBounds.width / w)
}
2023-04-25 11:01:25 +00:00
return this._animateToViewport(targetViewport, opts)
}
/**
* Center the camera on a point (in page space).
*
* @example
*
* ```ts
* app.centerOnPoint(100, 100)
* ```
*
* @param x - The x position of the point.
* @param y - The y position of the point.
* @param opts - The options for an animation.
* @public
*/
centerOnPoint(x: number, y: number, opts?: AnimationOptions): this {
if (!this.canMoveCamera) return this
const {
viewportPageBounds: { width: pw, height: ph },
camera,
} = this
if (opts?.duration) {
this.animateCamera(-(x - pw / 2), -(y - ph / 2), camera.z, opts)
} else {
this.setCamera(-(x - pw / 2), -(y - ph / 2), camera.z)
}
return this
}
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
/**
* Move the camera to the nearest content.
*
* @public
*/
zoomToContent() {
const bounds = this.selectedPageBounds ?? this.allShapesCommonBounds
if (bounds) {
this.zoomToBounds(
bounds.minX,
bounds.minY,
bounds.width,
bounds.height,
Math.min(1, this.zoomLevel),
{ duration: 220 }
)
}
return this
}
2023-04-25 11:01:25 +00:00
/**
* Zoom the camera to fit the current page's content in the viewport.
*
* @example
*
* ```ts
* app.zoomToFit()
* ```
*
* @public
*/
zoomToFit(opts?: AnimationOptions): this {
if (!this.canMoveCamera) return this
const ids = [...this.shapeIds]
if (ids.length <= 0) return this
const pageBounds = Box2d.Common(compact(ids.map((id) => this.getPageBoundsById(id))))
this.zoomToBounds(
pageBounds.minX,
pageBounds.minY,
pageBounds.width,
pageBounds.height,
undefined,
opts
)
return this
}
/**
* Set the zoom back to 100%.
*
* @example
*
* ```ts
* app.resetZoom()
* ```
*
* @param opts - The options for an animation.
* @public
*/
resetZoom(point = this.viewportScreenCenter, opts?: AnimationOptions): this {
if (!this.canMoveCamera) return this
const { x: cx, y: cy, z: cz } = this.camera
const { x, y } = point
if (opts?.duration) {
this.animateCamera(cx + (x / 1 - x) - (x / cz - x), cy + (y / 1 - y) - (y / cz - y), 1, opts)
} else {
this.setCamera(cx + (x / 1 - x) - (x / cz - x), cy + (y / 1 - y) - (y / cz - y), 1)
}
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
2023-04-25 11:01:25 +00:00
return this
}
/**
* Zoom the camera in.
*
* @example
*
* ```ts
* app.zoomIn()
* app.zoomIn(app.viewportScreenCenter, { duration: 120 })
* app.zoomIn(app.inputs.currentScreenPoint, { duration: 120 })
* ```
*
* @param opts - The options for an animation.
* @public
*/
zoomIn(point = this.viewportScreenCenter, opts?: AnimationOptions): this {
if (!this.canMoveCamera) return this
const { x: cx, y: cy, z: cz } = this.camera
let zoom = MAX_ZOOM
for (let i = 1; i < ZOOMS.length; i++) {
const z1 = ZOOMS[i - 1]
const z2 = ZOOMS[i]
if (z2 - cz <= (z2 - z1) / 2) continue
zoom = z2
break
}
const { x, y } = point
if (opts?.duration) {
this.animateCamera(
cx + (x / zoom - x) - (x / cz - x),
cy + (y / zoom - y) - (y / cz - y),
zoom,
opts
)
} else {
this.setCamera(cx + (x / zoom - x) - (x / cz - x), cy + (y / zoom - y) - (y / cz - y), zoom)
}
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
2023-04-25 11:01:25 +00:00
return this
}
/**
* Zoom the camera out.
*
* @example
*
* ```ts
* app.zoomOut()
* app.zoomOut(app.viewportScreenCenter, { duration: 120 })
* app.zoomOut(app.inputs.currentScreenPoint, { duration: 120 })
* ```
*
* @param opts - The options for an animation.
* @public
*/
zoomOut(point = this.viewportScreenCenter, opts?: AnimationOptions): this {
if (!this.canMoveCamera) return this
const { x: cx, y: cy, z: cz } = this.camera
let zoom = MIN_ZOOM
for (let i = ZOOMS.length - 1; i > 0; i--) {
const z1 = ZOOMS[i - 1]
const z2 = ZOOMS[i]
if (z2 - cz >= (z2 - z1) / 2) continue
zoom = z1
break
}
const { x, y } = point
if (opts?.duration) {
this.animateCamera(
cx + (x / zoom - x) - (x / cz - x),
cy + (y / zoom - y) - (y / cz - y),
zoom,
opts
)
} else {
this.setCamera(cx + (x / zoom - x) - (x / cz - x), cy + (y / zoom - y) - (y / cz - y), zoom)
}
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
2023-04-25 11:01:25 +00:00
return this
}
/**
* Zoom the camera to fit the current selection in the viewport.
*
* @example
*
* ```ts
* app.zoomToSelection()
* ```
*
* @param opts - The options for an animation.
* @public
*/
zoomToSelection(opts?: AnimationOptions): this {
if (!this.canMoveCamera) return this
const ids = this.selectedIds
if (ids.length <= 0) return this
const selectedBounds = Box2d.Common(compact(ids.map((id) => this.getPageBoundsById(id))))
this.zoomToBounds(
selectedBounds.minX,
selectedBounds.minY,
selectedBounds.width,
selectedBounds.height,
Math.max(1, this.camera.z),
opts
)
[feature] ui events (#1326) This PR updates the editor events: - adds types to the events emitted by the app (by `app.emit`) - removes a few events emitted by the app (e.g. `move-to-page`, `change-camera`) - adds `onEvent` prop to the <TldrawUi> / <Tldraw> components - call the `onEvent` when actions occur or tools are selected - does some superficial cleanup on editor app APIs ### Release Note - Fix layout bug in error dialog - (ui) Add `TLEventMap` for types emitted from editor app - (editor) Update `crash` event emitted from editor app to include error - (editor) Update `change-history` event emitted from editor app - (editor) Remove `change-camera` event from editor app - (editor) Remove `move-to-page` event from editor app - (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi> - (editor) Replace `app.openMenus` plain Set with computed value - (editor) Add `addOpenMenu` method - (editor) Add `removeOpenMenu` method - (editor) Add `setFocusMode` method - (editor) Add `setToolLocked` method - (editor) Add `setSnapMode` method - (editor) Add `isSnapMode` method - (editor) Update `setGridMode` method return type to editor app - (editor) Update `setReadOnly` method return type to editor app - (editor) Update `setPenMode` method return type to editor app - (editor) Update `selectNone` method return type to editor app - (editor) Rename `backToContent` to `zoomToContent` - (editor) Remove `TLReorderOperation` type --------- Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
2023-05-11 22:14:58 +00:00
2023-04-25 11:01:25 +00:00
return this
}
/**
* Pan or pan/zoom the selected ids into view. This method tries to not change the zoom if
* possible.
*
* @param ids - The ids of the shapes to pan and zoom into view.
* @param opts - The options for an animation.
* @public
*/
panZoomIntoView(ids: TLShapeId[], opts?: AnimationOptions): this {
if (!this.canMoveCamera) return this
if (ids.length <= 0) return this
const selectedBounds = Box2d.Common(compact(ids.map((id) => this.getPageBoundsById(id))))
const { viewportPageBounds } = this
if (viewportPageBounds.h < selectedBounds.h || viewportPageBounds.w < selectedBounds.w) {
this.zoomToBounds(
selectedBounds.minX,
selectedBounds.minY,
selectedBounds.width,
selectedBounds.height,
this.camera.z,
opts
)
return this
} else {
// TODO: This buffer should calculate the 'active area' of the UI
const bufferOffsets = this._activeAreaManager.offsets.value
const pageTop = viewportPageBounds.y + bufferOffsets.top
const pageRight = viewportPageBounds.maxY - bufferOffsets.right
const pageBottom = viewportPageBounds.maxY - bufferOffsets.bottom
const pageLeft = viewportPageBounds.x + bufferOffsets.left
const selectedTop = selectedBounds.y
const selectedRight = selectedBounds.maxX
const selectedBottom = selectedBounds.maxY
const selectedLeft = selectedBounds.x
let offsetX = 0
let offsetY = 0
if (pageBottom < selectedBottom) {
// off bottom
offsetY = pageBottom - selectedBottom
} else if (pageTop > selectedTop) {
// off top
offsetY = pageTop - selectedTop
} else {
// inside y-bounds
}
if (pageRight < selectedRight) {
// off right
offsetX = pageRight - selectedRight
} else if (pageLeft > selectedLeft) {
// off left
offsetX = pageLeft - selectedLeft
} else {
// inside x-bounds
}
const { camera } = this
if (opts?.duration) {
this.animateCamera(camera.x + offsetX, camera.y + offsetY, camera.z, opts)
} else {
this.setCamera(camera.x + offsetX, camera.y + offsetY, camera.z)
}
}
return this
}
/**
* Zoom the camera to fit a bounding box (in page space).
*
* @example
*
* ```ts
* app.zoomToBounds(0, 0, 100, 100)
* ```
*
* @param x - The bounding box's x position.
* @param y - The bounding box's y position.
* @param width - The bounding box's width.
* @param height - The bounding box's height.
* @param targetZoom - The desired zoom level. Defaults to 0.1.
* @public
*/
zoomToBounds(
x: number,
y: number,
width: number,
height: number,
targetZoom?: number,
opts?: AnimationOptions
): this {
if (!this.canMoveCamera) return this
const { viewportScreenBounds } = this
const inset = Math.min(256, viewportScreenBounds.width * 0.28)
let zoom = clamp(
Math.min(
(viewportScreenBounds.width - inset) / width,
(viewportScreenBounds.height - inset) / height
),
MIN_ZOOM,
MAX_ZOOM
)
if (targetZoom !== undefined) {
zoom = Math.min(targetZoom, zoom)
}
if (opts?.duration) {
this.animateCamera(
-x + (viewportScreenBounds.width - width * zoom) / 2 / zoom,
-y + (viewportScreenBounds.height - height * zoom) / 2 / zoom,
zoom,
opts
)
} else {
this.setCamera(
-x + (viewportScreenBounds.width - width * zoom) / 2 / zoom,
-y + (viewportScreenBounds.height - height * zoom) / 2 / zoom,
zoom
)
}
return this
}
/**
* Pan the camera.
*
* @example
*
* ```ts
* app.pan(100, 100)
* app.pan(100, 100, { duration: 1000 })
* ```
*
* @param dx - The amount to pan on the x axis.
* @param dy - The amount to pan on the y axis.
* @param opts - The animation options
*/
pan(dx: number, dy: number, opts?: AnimationOptions): this {
if (!this.canMoveCamera) return this
const { camera } = this
const { x: cx, y: cy, z: cz } = camera
const d = new Vec2d(dx, dy).div(cz)
if (opts?.duration ?? 0 > 0) {
return this.animateCamera(cx + d.x, cy + d.y, cz, opts)
} else {
this.setCamera(cx + d.x, cy + d.y, cz)
}
return this
}
/**
* Stop the current camera animation, if any.
*
* @public
*/
stopCameraAnimation() {
this.emit('stop-camera-animation')
return this
}
/** @internal */
private _viewportAnimation = null as null | {
elapsed: number
duration: number
easing: (t: number) => number
start: Box2d
end: Box2d
}
/** @internal */
private _animateViewport(ms: number) {
if (!this._viewportAnimation) return
const cancel = () => {
this.removeListener('tick', this._animateViewport)
this.removeListener('stop-camera-animation', cancel)
this._viewportAnimation = null
}
this.once('stop-camera-animation', cancel)
this._viewportAnimation.elapsed += ms
const { elapsed, easing, duration, start, end } = this._viewportAnimation
if (elapsed > duration) {
const z = this.viewportScreenBounds.width / end.width
const x = -end.x
const y = -end.y
this._setCamera(x, y, z)
cancel()
return
}
const remaining = duration - elapsed
const t = easing(1 - remaining / duration)
const left = start.minX + (end.minX - start.minX) * t
const top = start.minY + (end.minY - start.minY) * t
const right = start.maxX + (end.maxX - start.maxX) * t
const bottom = start.maxY + (end.maxY - start.maxY) * t
const easedViewport = new Box2d(left, top, right - left, bottom - top)
const z = this.viewportScreenBounds.width / easedViewport.width
const x = -easedViewport.x
const y = -easedViewport.y
this._setCamera(x, y, z)
}
/** @internal */
private _animateToViewport(targetViewportPage: Box2d, opts = {} as AnimationOptions) {
const { duration = 0, easing = EASINGS.easeInOutCubic } = opts
const { animationSpeed, viewportPageBounds } = this
2023-04-25 11:01:25 +00:00
// If we have an existing animation, then stop it; also stop following any user
2023-04-25 11:01:25 +00:00
this.stopCameraAnimation()
if (this.instanceState.followingUserId) {
this.stopFollowingUser()
}
if (duration === 0 || animationSpeed === 0) {
// If we have no animation, then skip the animation and just set the camera
return this._setCamera(
-targetViewportPage.x,
-targetViewportPage.y,
this.viewportScreenBounds.width / targetViewportPage.width
)
}
// Set our viewport animation
2023-04-25 11:01:25 +00:00
this._viewportAnimation = {
elapsed: 0,
duration: duration / animationSpeed,
2023-04-25 11:01:25 +00:00
easing,
start: viewportPageBounds.clone(),
2023-04-25 11:01:25 +00:00
end: targetViewportPage,
}
// On each tick, animate the viewport
2023-04-25 11:01:25 +00:00
this.addListener('tick', this._animateViewport)
return this
}
slideCamera(
opts = {} as {
speed: number
direction: Vec2d
friction: number
speedThreshold?: number
}
) {
if (!this.canMoveCamera) return this
this.stopCameraAnimation()
const { animationSpeed } = this
if (animationSpeed === 0) return
const { speed, friction, direction, speedThreshold = 0.01 } = opts
let currentSpeed = Math.min(speed, 1)
2023-04-25 11:01:25 +00:00
const cancel = () => {
this.removeListener('tick', moveCamera)
this.removeListener('stop-camera-animation', cancel)
}
this.once('stop-camera-animation', cancel)
const moveCamera = (elapsed: number) => {
const { x: cx, y: cy, z: cz } = this.camera
const movementVec = direction.clone().mul((currentSpeed * elapsed) / cz)
// Apply friction
currentSpeed *= 1 - friction
if (currentSpeed < speedThreshold) {
cancel()
} else {
this._setCamera(cx + movementVec.x, cy + movementVec.y, cz)
}
}
this.addListener('tick', moveCamera)
return this
}
/**
* Start viewport-following a user.
*
* @param userId - The id of the user to follow.
* @public
*/
startFollowingUser = (userId: string) => {
2023-04-25 11:01:25 +00:00
// Currently, we get the leader's viewport page bounds from their user presence.
// This is a placeholder until the ephemeral PR lands.
// After that, we'll be able to get the required data from their instance presence instead.
const leaderPresences = this.store.query.records('instance_presence', () => ({
2023-04-25 11:01:25 +00:00
userId: { eq: userId },
}))
const thisUserId = this.user.id
if (!thisUserId) {
console.warn('You should set the userId for the current instance before following a user')
}
2023-04-25 11:01:25 +00:00
// If the leader is following us, then we can't follow them
if (leaderPresences.value.some((p) => p.followingUserId === thisUserId)) {
2023-04-25 11:01:25 +00:00
return
}
transact(() => {
this.stopFollowingUser()
this.updateInstanceState({
followingUserId: userId,
})
})
const cancel = () => {
Fix new wobble (#1431) This PR stops collaborator cursors wobbling while viewport-following. It's a **new wobble** that we haven't seen before! (It crept in at some point) It happens when these three things happen at the same time: * You're following someone * They're panning * They're not moving their pointer eg: This happens when they're trackpad-panning. So this is *not* the **old wobble** that we fixed before! That one is still fixed. --- The **new wobble** looks like this: ![2023-05-22 at 12 08 51 - Magenta Urial](https://github.com/tldraw/tldraw/assets/15892272/4b738766-cde3-4a9c-9169-76d622bec3bf) It's sometimes hard-to-spot because of the _smoothing_ that we do. When we drastically increase the strength of smoothing... the **new wobble** is less noticeable: ![2023-05-22 at 12 12 40 - Rose Goat](https://github.com/tldraw/tldraw/assets/15892272/4ece229a-60e8-4923-89f8-4a0f9b702491) But we can do better! So for demonstration purposes... let's turn off _smoothing_ to let us see the **new wobble** more clearly. ![2023-05-22 at 12 16 02 - Gold Macaw](https://github.com/tldraw/tldraw/assets/15892272/9030cf2a-bdf3-47f0-87f0-a1195ab2fcbf) Now we can clearly see what's going on... The cursor is updating every animation **frame**. The camera is updating every **tick**. Depending on your screen's refresh rate, these _might be different_! Let's test that theory by throttling the **tick** further. As expected, it increases the **new wobble**: ![2023-05-22 at 14 16 21 - Blush Caterpillar](https://github.com/tldraw/tldraw/assets/15892272/c70ee08c-8fd3-40ae-a4b3-95637c08acc7) Let's test the theory again by trying on a screen where the _tick_ and _frame_ are in sync. As expected, the **new wobble** doesn't happen _most of the time_. However, _frame_ and _tick_ can still get out of sync! Which causes the occasional wobble: ![2023-05-22 at 14 38 21 - Lime Eagle](https://github.com/tldraw/tldraw/assets/15892272/2a9d8c98-194f-4b73-a7ea-ee85ac1fa28f) So let's fix both cases... Instead of making the following-camera update every _tick_... Let's make it update every _frame_! ![2023-05-22 at 15 28 47 - Salmon Smelt](https://github.com/tldraw/tldraw/assets/15892272/e9f5c10c-d421-4611-b049-7a961218c087) The perceptive among you might notice a slight wobble that's still there... This is an entirely **different wobble** caused by different things. We can get to it in the future! But we're back at our original wobble-quota for now. ![2023-05-22 at 14 32 18 - Brown Tern](https://github.com/tldraw/tldraw/assets/15892272/e1250715-0bf2-4b87-b6e7-a357bccf4106) When we turn smoothing back on, things look better than before 👍 ![2023-05-22 at 14 42 48 - Cyan Roundworm](https://github.com/tldraw/tldraw/assets/15892272/f3616c6f-7969-4a8d-80b1-26ee44e6f451) ### Change Type - [x] `patch` — Bug Fix ### Test Plan 1. Open a single shared project in two different browser sessions. 2. Make one session's user follow the other. 3. Trackpad-pan the leader's camera around. 4. Check that the wobble has reduced.
2023-05-23 08:04:07 +00:00
this.removeListener('frame', moveTowardsUser)
2023-04-25 11:01:25 +00:00
this.removeListener('stop-following', cancel)
}
let isCaughtUp = false
const moveTowardsUser = () => {
// Stop following if we can't find the user
const leaderPresence = [...leaderPresences.value]
.sort((a, b) => {
return a.lastActivityTimestamp - b.lastActivityTimestamp
})
.pop()
if (!leaderPresence) {
2023-04-25 11:01:25 +00:00
this.stopFollowingUser()
return
}
// Change page if leader is on a different page
const isOnSamePage = leaderPresence.currentPageId === this.currentPageId
2023-04-25 11:01:25 +00:00
const chaseProportion = isOnSamePage ? FOLLOW_CHASE_PROPORTION : 1
if (!isOnSamePage) {
this.setCurrentPageId(leaderPresence.currentPageId, { stopFollowing: false })
2023-04-25 11:01:25 +00:00
}
// Get the bounds of the follower (me) and the leader (them)
const { center, width, height } = this.viewportPageBounds
const leaderScreen = Box2d.From(leaderPresence.screenBounds)
const leaderWidth = leaderScreen.width / leaderPresence.camera.z
const leaderHeight = leaderScreen.height / leaderPresence.camera.z
const leaderCenter = new Vec2d(
leaderWidth / 2 - leaderPresence.camera.x,
leaderHeight / 2 - leaderPresence.camera.y
)
2023-04-25 11:01:25 +00:00
// 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
2023-04-25 11:01:25 +00:00
// Figure out how much to zoom
const desiredWidth = width + (leaderWidth - width) * chaseProportion
const desiredHeight = height + (leaderHeight - height) * chaseProportion
const ratio = !isFollowingFollower
? Math.min(width / desiredWidth, height / desiredHeight)
: height / desiredHeight
const targetZoom = clamp(this.camera.z * ratio, MIN_ZOOM, MAX_ZOOM)
const targetWidth = this.viewportScreenBounds.w / targetZoom
const targetHeight = this.viewportScreenBounds.h / targetZoom
// Figure out where to move the camera
const displacement = leaderCenter.sub(center)
const targetCenter = Vec2d.Add(center, Vec2d.Mul(displacement, chaseProportion))
// Now let's assess whether we've caught up to the leader or not
const distance = Vec2d.Sub(targetCenter, center).len()
const zoomChange = Math.abs(targetZoom - this.camera.z)
// If we're chasing the leader...
// Stop chasing if we're close enough
if (distance < FOLLOW_CHASE_PAN_SNAP && zoomChange < FOLLOW_CHASE_ZOOM_SNAP) {
isCaughtUp = true
return
}
// If we're already caught up with the leader...
// Only start moving again if we're far enough away
if (
isCaughtUp &&
distance < FOLLOW_CHASE_PAN_UNSNAP &&
zoomChange < FOLLOW_CHASE_ZOOM_UNSNAP
) {
return
}
// Update the camera!
isCaughtUp = false
this.stopCameraAnimation()
this.setCamera(
-(targetCenter.x - targetWidth / 2),
-(targetCenter.y - targetHeight / 2),
targetZoom,
{ stopFollowing: false }
)
}
this.once('stop-following', cancel)
Fix new wobble (#1431) This PR stops collaborator cursors wobbling while viewport-following. It's a **new wobble** that we haven't seen before! (It crept in at some point) It happens when these three things happen at the same time: * You're following someone * They're panning * They're not moving their pointer eg: This happens when they're trackpad-panning. So this is *not* the **old wobble** that we fixed before! That one is still fixed. --- The **new wobble** looks like this: ![2023-05-22 at 12 08 51 - Magenta Urial](https://github.com/tldraw/tldraw/assets/15892272/4b738766-cde3-4a9c-9169-76d622bec3bf) It's sometimes hard-to-spot because of the _smoothing_ that we do. When we drastically increase the strength of smoothing... the **new wobble** is less noticeable: ![2023-05-22 at 12 12 40 - Rose Goat](https://github.com/tldraw/tldraw/assets/15892272/4ece229a-60e8-4923-89f8-4a0f9b702491) But we can do better! So for demonstration purposes... let's turn off _smoothing_ to let us see the **new wobble** more clearly. ![2023-05-22 at 12 16 02 - Gold Macaw](https://github.com/tldraw/tldraw/assets/15892272/9030cf2a-bdf3-47f0-87f0-a1195ab2fcbf) Now we can clearly see what's going on... The cursor is updating every animation **frame**. The camera is updating every **tick**. Depending on your screen's refresh rate, these _might be different_! Let's test that theory by throttling the **tick** further. As expected, it increases the **new wobble**: ![2023-05-22 at 14 16 21 - Blush Caterpillar](https://github.com/tldraw/tldraw/assets/15892272/c70ee08c-8fd3-40ae-a4b3-95637c08acc7) Let's test the theory again by trying on a screen where the _tick_ and _frame_ are in sync. As expected, the **new wobble** doesn't happen _most of the time_. However, _frame_ and _tick_ can still get out of sync! Which causes the occasional wobble: ![2023-05-22 at 14 38 21 - Lime Eagle](https://github.com/tldraw/tldraw/assets/15892272/2a9d8c98-194f-4b73-a7ea-ee85ac1fa28f) So let's fix both cases... Instead of making the following-camera update every _tick_... Let's make it update every _frame_! ![2023-05-22 at 15 28 47 - Salmon Smelt](https://github.com/tldraw/tldraw/assets/15892272/e9f5c10c-d421-4611-b049-7a961218c087) The perceptive among you might notice a slight wobble that's still there... This is an entirely **different wobble** caused by different things. We can get to it in the future! But we're back at our original wobble-quota for now. ![2023-05-22 at 14 32 18 - Brown Tern](https://github.com/tldraw/tldraw/assets/15892272/e1250715-0bf2-4b87-b6e7-a357bccf4106) When we turn smoothing back on, things look better than before 👍 ![2023-05-22 at 14 42 48 - Cyan Roundworm](https://github.com/tldraw/tldraw/assets/15892272/f3616c6f-7969-4a8d-80b1-26ee44e6f451) ### Change Type - [x] `patch` — Bug Fix ### Test Plan 1. Open a single shared project in two different browser sessions. 2. Make one session's user follow the other. 3. Trackpad-pan the leader's camera around. 4. Check that the wobble has reduced.
2023-05-23 08:04:07 +00:00
this.addListener('frame', moveTowardsUser)
2023-04-25 11:01:25 +00:00
return this
}
/**
* Stop viewport-following a user.
*
* @public
*/
stopFollowingUser = () => {
this.updateInstanceState({
followingUserId: null,
})
this.emit('stop-following')
return this
}
animateToShape(shapeId: TLShapeId, opts: AnimationOptions = DEFAULT_ANIMATION_OPTIONS): this {
if (!this.canMoveCamera) return this
const activeArea = getActiveAreaScreenSpace(this)
const viewportAspectRatio = activeArea.width / activeArea.height
const shapePageBounds = this.getPageBoundsById(shapeId)
if (!shapePageBounds) return this
const shapeAspectRatio = shapePageBounds.width / shapePageBounds.height
const targetViewportPage = shapePageBounds.clone()
const z = shapePageBounds.width / activeArea.width
targetViewportPage.width += (activeArea.left + activeArea.right) * z
targetViewportPage.height += (activeArea.top + activeArea.bottom) * z
targetViewportPage.x -= activeArea.left * z
targetViewportPage.y -= activeArea.top * z
if (shapeAspectRatio > viewportAspectRatio) {
targetViewportPage.height = shapePageBounds.width / viewportAspectRatio
targetViewportPage.y -= (targetViewportPage.height - shapePageBounds.height) / 2
} else {
targetViewportPage.width = shapePageBounds.height * viewportAspectRatio
targetViewportPage.x -= (targetViewportPage.width - shapePageBounds.width) / 2
}
return this._animateToViewport(targetViewportPage, opts)
}
/**
* Blur the app, cancelling any interaction state.
*
* @example
*
* ```ts
* app.blur()
* ```
*
* @public
*/
blur() {
this.complete()
this.getContainer().blur()
this._isFocused.set(false)
return this
}
/**
* Focus the app.
*
* @example
*
* ```ts
* app.focus()
* ```
*
* @public
*/
focus() {
this.getContainer().focus()
this._isFocused.set(true)
return this
}
/**
* Dispatch a cancel event.
*
* @example
*
* ```ts
* app.cancel()
* ```
*
* @public
*/
cancel() {
this.dispatch({ type: 'misc', name: 'cancel' })
return this
}
/**
* Dispatch an interrupt event.
*
* @example
*
* ```ts
* app.interrupt()
* ```
*
* @public
*/
interrupt() {
this.dispatch({ type: 'misc', name: 'interrupt' })
return this
}
/**
* Dispatch a complete event.
*
* @example
*
* ```ts
* app.complete()
* ```
*
* @public
*/
complete() {
this.dispatch({ type: 'misc', name: 'complete' })
return this
}
/* -------------------- Callbacks ------------------- */
/**
* A callback fired when a file is converted to an asset. This callback should return the asset
* partial.
*
* @example
*
* ```ts
* app.onCreateAssetFromFile(myFile)
* ```
*
* @param file - The file to upload.
* @public
*/
async onCreateAssetFromFile(file: File): Promise<TLAsset> {
return await getMediaAssetFromFile(file)
}
/**
* A callback fired when a URL is converted to a bookmark. This callback should return the
* metadata for the bookmark.
*
* @example
*
* ```ts
* app.onCreateBookmarkFromUrl(url, id)
* ```
*
* @param url - The url that was created.
* @public
*/
async onCreateBookmarkFromUrl(
url: string
): Promise<{ image: string; title: string; description: string }> {
try {
const resp = await fetch(url, { method: 'GET', mode: 'no-cors' })
const html = await resp.text()
const doc = new DOMParser().parseFromString(html, 'text/html')
return {
image: doc.head.querySelector('meta[property="og:image"]')?.getAttribute('content') ?? '',
title: doc.head.querySelector('meta[property="og:title"]')?.getAttribute('content') ?? '',
description:
doc.head.querySelector('meta[property="og:description"]')?.getAttribute('content') ?? '',
}
} catch (error) {
console.error(error)
return { image: '', title: '', description: '' }
}
}
/* ---------------- Text Measurement ---------------- */
/**
* A helper for measuring text.
*
* @public
*/
textMeasure: TextManager
/* --------------------- Groups --------------------- */
groupShapes(ids: TLShapeId[] = this.selectedIds, groupId = createShapeId()) {
if (this.isReadOnly) return this
if (ids.length <= 1) return this
const shapes = compact(this._getUnlockedShapeIds(ids).map((id) => this.getShapeById(id)))
2023-04-25 11:01:25 +00:00
const sortedShapeIds = shapes.sort(sortByIndex).map((s) => s.id)
const pageBounds = Box2d.Common(compact(shapes.map((id) => this.getPageBounds(id))))
const { x, y } = pageBounds.point
const parentId = this.findCommonAncestor(shapes) ?? this.currentPageId
// Only group when the select tool is active
if (this.currentToolId !== 'select') return this
// If not already in idle, cancel the current interaction (get back to idle)
if (!this.isIn('select.idle')) {
this.cancel()
}
// Find all the shapes that have the same parentId, and use the highest index.
const shapesWithRootParent = shapes
.filter((shape) => shape.parentId === parentId)
.sort(sortByIndex)
const highestIndex = shapesWithRootParent[shapesWithRootParent.length - 1]?.index
this.batch(() => {
this.createShapes([
{
id: groupId,
type: 'group',
parentId,
index: highestIndex,
x,
y,
props: {
opacity: '1',
},
},
])
this.reparentShapesById(sortedShapeIds, groupId)
this.select(groupId)
})
return this
}
ungroupShapes(ids: TLShapeId[] = this.selectedIds) {
if (this.isReadOnly) return this
if (ids.length === 0) return this
// Only ungroup when the select tool is active
if (this.currentToolId !== 'select') return this
// If not already in idle, cancel the current interaction (get back to idle)
if (!this.isIn('select.idle')) {
this.cancel()
}
// The ids of the selected shapes after ungrouping;
// these include all of the grouped shapes children,
// plus any shapes that were selected apart from the groups.
const idsToSelect = new Set<TLShapeId>()
// Get all groups in the selection
const shapes = compact(ids.map((id) => this.getShapeById(id)))
const groups: TLGroupShape[] = []
shapes.forEach((shape) => {
if (this.isShapeOfType(shape, TLGroupUtil)) {
2023-04-25 11:01:25 +00:00
groups.push(shape)
} else {
idsToSelect.add(shape.id)
}
})
if (groups.length === 0) return this
this.batch(() => {
let group: TLGroupShape
2023-04-25 11:01:25 +00:00
for (let i = 0, n = groups.length; i < n; i++) {
group = groups[i]
2023-04-25 11:01:25 +00:00
const childIds = this.getSortedChildIds(group.id)
for (let j = 0, n = childIds.length; j < n; j++) {
idsToSelect.add(childIds[j])
}
this.reparentShapesById(childIds, group.parentId, group.index)
}
this.deleteShapes(groups.map((group) => group.id))
this.select(...idsToSelect)
})
return this
}
}
function alertMaxShapes(app: App, pageId = app.currentPageId) {
const name = app.getPageById(pageId)!.name
app.emit('max-shapes', { name, pageId, count: MAX_SHAPES_PER_PAGE })
}