@tldraw/editor
Version:
tldraw infinite canvas SDK (editor).
1,587 lines • 286 kB
JavaScript
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __decorateClass = (decorators, target, key, kind) => {
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
for (var i = decorators.length - 1, decorator; i >= 0; i--)
if (decorator = decorators[i])
result = (kind ? decorator(target, key, result) : decorator(result)) || result;
if (kind && result) __defProp(target, key, result);
return result;
};
import {
EMPTY_ARRAY,
atom,
computed,
react,
transact,
unsafe__withoutCapture
} from "@tldraw/state";
import {
reverseRecordsDiff
} from "@tldraw/store";
import {
CameraRecordType,
InstancePageStateRecordType,
PageRecordType,
TLDOCUMENT_ID,
TLINSTANCE_ID,
UserRecordType,
createBindingId,
createShapeId,
createUserId,
getShapePropKeysByStyle,
isPageId,
isShapeId
} from "@tldraw/tlschema";
import {
FileHelpers,
PerformanceTracker,
Result,
ZERO_INDEX_KEY,
annotateError,
assert,
assertExists,
bind,
compact,
debounce,
dedupe,
exhaustiveSwitchError,
fetch,
getIndexAbove,
getIndexBetween,
getIndices,
getIndicesAbove,
getIndicesBetween,
getOwnProperty,
hasOwnProperty,
last,
lerp,
minBy,
sortById,
sortByIndex,
structuredClone,
uniqueId
} from "@tldraw/utils";
import EventEmitter from "eventemitter3";
import { createTLCurrentUser } from "../config/createTLCurrentUser.mjs";
import { checkAssets } from "../config/defaultAssets.mjs";
import { checkBindings } from "../config/defaultBindings.mjs";
import { checkShapesAndAddCore } from "../config/defaultShapes.mjs";
import {
getSnapshot,
loadSnapshot
} from "../config/TLEditorSnapshot.mjs";
import {
DEFAULT_ANIMATION_OPTIONS,
DEFAULT_CAMERA_OPTIONS,
INTERNAL_POINTER_IDS,
LEFT_MOUSE_BUTTON,
MIDDLE_MOUSE_BUTTON,
RIGHT_MOUSE_BUTTON,
STYLUS_ERASER_BUTTON
} from "../constants.mjs";
import { getOwnerWindow } from "../exports/domUtils.mjs";
import { exportToSvg } from "../exports/exportToSvg.mjs";
import { getSvgAsImageWithOptions, trimSvgToContent } from "../exports/getSvgAsImage.mjs";
import { tlmenus } from "../globals/menus.mjs";
import { tltime } from "../globals/time.mjs";
import { defaultTldrawOptions } from "../options.mjs";
import { Box } from "../primitives/Box.mjs";
import { EASINGS } from "../primitives/easings.mjs";
import { Group2d } from "../primitives/geometry/Group2d.mjs";
import { intersectPolygonPolygon } from "../primitives/intersect.mjs";
import { Mat } from "../primitives/Mat.mjs";
import { PI, approximately, areAnglesCompatible, clamp, pointInPolygon } from "../primitives/utils.mjs";
import { Vec } from "../primitives/Vec.mjs";
import { areShapesContentEqual } from "../utils/areShapesContentEqual.mjs";
import { dataUrlToFile } from "../utils/assets.mjs";
import { debugFlags } from "../utils/debug-flags.mjs";
import {
createDeepLinkString,
parseDeepLinkString
} from "../utils/deepLinks.mjs";
import { getIncrementedName } from "../utils/getIncrementedName.mjs";
import { getReorderingShapesChanges } from "../utils/reorderShapes.mjs";
import { getDroppedShapesToNewParents, kickoutOccludedShapes } from "../utils/reparenting.mjs";
import { applyRotationToSnapshotShapes, getRotationSnapshot } from "../utils/rotation.mjs";
import { SharedStyleMap } from "../utils/SharedStylesMap.mjs";
import { bindingsIndex } from "./derivations/bindingsIndex.mjs";
import { notVisibleShapes } from "./derivations/notVisibleShapes.mjs";
import { parentsToChildren } from "./derivations/parentsToChildren.mjs";
import { deriveShapeIdsInCurrentPage } from "./derivations/shapeIdsInCurrentPage.mjs";
import { ClickManager } from "./managers/ClickManager/ClickManager.mjs";
import { CollaboratorsManager } from "./managers/CollaboratorsManager/CollaboratorsManager.mjs";
import { EdgeScrollManager } from "./managers/EdgeScrollManager/EdgeScrollManager.mjs";
import { FocusManager } from "./managers/FocusManager/FocusManager.mjs";
import { FontManager } from "./managers/FontManager/FontManager.mjs";
import { HistoryManager } from "./managers/HistoryManager/HistoryManager.mjs";
import { InputsManager } from "./managers/InputsManager/InputsManager.mjs";
import { PerformanceManager } from "./managers/PerformanceManager/PerformanceManager.mjs";
import { ScribbleManager } from "./managers/ScribbleManager/ScribbleManager.mjs";
import { SnapManager } from "./managers/SnapManager/SnapManager.mjs";
import { SpatialIndexManager } from "./managers/SpatialIndexManager/SpatialIndexManager.mjs";
import { TextManager } from "./managers/TextManager/TextManager.mjs";
import { ThemeManager, resolveThemes } from "./managers/ThemeManager/ThemeManager.mjs";
import { TickManager } from "./managers/TickManager/TickManager.mjs";
import { UserPreferencesManager } from "./managers/UserPreferencesManager/UserPreferencesManager.mjs";
import { OverlayManager } from "./overlays/OverlayManager.mjs";
import { RootState } from "./tools/RootState.mjs";
class Editor extends EventEmitter {
id = uniqueId();
constructor({
store,
user,
shapeUtils,
bindingUtils,
assetUtils: assetUtilConstructors,
overlayUtils: overlayUtilConstructors,
tools,
getContainer,
// needs to be here for backwards compatibility with TldrawEditor
// eslint-disable-next-line @typescript-eslint/no-deprecated
cameraOptions,
initialState,
autoFocus,
options: _options,
// needs to be here for backwards compatibility with TldrawEditor
// eslint-disable-next-line @typescript-eslint/no-deprecated
textOptions: _textOptions,
getShapeVisibility,
colorScheme,
fontAssetUrls,
themes,
initialTheme
}) {
super();
this._getShapeVisibility = getShapeVisibility;
const options = _textOptions ? { ..._options, text: _options?.text ?? _textOptions } : _options;
this.options = { ...defaultTldrawOptions, ...options };
this.store = store;
this.history = new HistoryManager({
store,
annotateError: (error) => {
this.annotateError(error, { origin: "history.batch", willCrashApp: true });
this.crash(error);
}
});
this.snaps = new SnapManager(this);
this._spatialIndex = new SpatialIndexManager(this);
this.disposables.add(() => this._spatialIndex.dispose());
this.disposables.add(this.timers.dispose);
this._cameraOptions.set({
...DEFAULT_CAMERA_OPTIONS,
...cameraOptions,
...options?.camera
});
this.getContainer = getContainer;
this._textOptions = atom("text options", options?.text ?? null);
this.user = new UserPreferencesManager(user ?? createTLCurrentUser(), colorScheme ?? "light");
this.disposables.add(() => this.user.dispose());
this.textMeasure = new TextManager(this);
this.disposables.add(() => this.textMeasure.dispose());
this._themeManager = new ThemeManager(this, {
themes: resolveThemes(themes),
initial: initialTheme ?? "default"
});
this.disposables.add(() => this._themeManager.dispose());
this._tickManager = new TickManager(this);
this.disposables.add(() => this._tickManager.dispose());
this.disposables.add(() => {
this.off("tick", this._decayCameraStateTimeout);
this._setCameraState("idle");
});
this.fonts = new FontManager(this, fontAssetUrls);
this.inputs = new InputsManager(this);
this.performance = new PerformanceManager(this);
this.disposables.add(() => this.performance.dispose());
this.collaborators = new CollaboratorsManager(this);
class NewRoot extends RootState {
static initial = initialState ?? "";
}
this.root = new NewRoot(this);
this.root.children = {};
this.markEventAsHandled = this.markEventAsHandled.bind(this);
const allShapeUtils = checkShapesAndAddCore(shapeUtils);
const _shapeUtils = {};
const _styleProps = {};
const allStylesById = /* @__PURE__ */ new Map();
for (const Util of allShapeUtils) {
const util = new Util(this);
_shapeUtils[Util.type] = util;
const propKeysByStyle = getShapePropKeysByStyle(Util.props ?? {});
_styleProps[Util.type] = propKeysByStyle;
for (const style of propKeysByStyle.keys()) {
if (!allStylesById.has(style.id)) {
allStylesById.set(style.id, style);
} else if (allStylesById.get(style.id) !== style) {
throw Error(
`Multiple style props with id "${style.id}" in use. Style prop IDs must be unique.`
);
}
}
}
this.shapeUtils = _shapeUtils;
this.styleProps = _styleProps;
const _shapeUtilsByAssetType = {};
for (const Util of allShapeUtils) {
const assetTypes = Util.handledAssetTypes;
if (assetTypes) {
for (const assetType of assetTypes) {
_shapeUtilsByAssetType[assetType] = _shapeUtils[Util.type];
}
}
}
this._shapeUtilsByAssetType = _shapeUtilsByAssetType;
const allBindingUtils = checkBindings(bindingUtils);
const _bindingUtils = {};
for (const Util of allBindingUtils) {
const util = new Util(this);
_bindingUtils[Util.type] = util;
}
this.bindingUtils = _bindingUtils;
if (assetUtilConstructors) {
const allAssetUtils = checkAssets(assetUtilConstructors);
const _assetUtils = {};
for (const Util of allAssetUtils) {
const util = new Util(this);
_assetUtils[Util.type] = util;
}
this.assetUtils = _assetUtils;
}
for (const Tool of [...tools]) {
if (hasOwnProperty(this.root.children, Tool.id)) {
throw Error(`Can't override tool with id "${Tool.id}"`);
}
this.root.children[Tool.id] = new Tool(this, this.root);
}
this.scribbles = new ScribbleManager(this);
this.overlays = new OverlayManager(this);
if (overlayUtilConstructors) {
for (const Util of overlayUtilConstructors) {
const util = new Util(this);
this.overlays.registerUtil(util);
}
}
const cleanupInstancePageState = (prevPageState, shapesNoLongerInPage) => {
let nextPageState = null;
const selectedShapeIds = prevPageState.selectedShapeIds.filter(
(id) => !shapesNoLongerInPage.has(id)
);
if (selectedShapeIds.length !== prevPageState.selectedShapeIds.length) {
if (!nextPageState) nextPageState = { ...prevPageState };
nextPageState.selectedShapeIds = selectedShapeIds;
}
const erasingShapeIds = prevPageState.erasingShapeIds.filter(
(id) => !shapesNoLongerInPage.has(id)
);
if (erasingShapeIds.length !== prevPageState.erasingShapeIds.length) {
if (!nextPageState) nextPageState = { ...prevPageState };
nextPageState.erasingShapeIds = erasingShapeIds;
}
if (prevPageState.hoveredShapeId && shapesNoLongerInPage.has(prevPageState.hoveredShapeId)) {
if (!nextPageState) nextPageState = { ...prevPageState };
nextPageState.hoveredShapeId = null;
}
if (prevPageState.editingShapeId && shapesNoLongerInPage.has(prevPageState.editingShapeId)) {
if (!nextPageState) nextPageState = { ...prevPageState };
nextPageState.editingShapeId = null;
}
const hintingShapeIds = prevPageState.hintingShapeIds.filter(
(id) => !shapesNoLongerInPage.has(id)
);
if (hintingShapeIds.length !== prevPageState.hintingShapeIds.length) {
if (!nextPageState) nextPageState = { ...prevPageState };
nextPageState.hintingShapeIds = hintingShapeIds;
}
if (prevPageState.focusedGroupId && shapesNoLongerInPage.has(prevPageState.focusedGroupId)) {
if (!nextPageState) nextPageState = { ...prevPageState };
nextPageState.focusedGroupId = null;
}
return nextPageState;
};
this.sideEffects = this.store.sideEffects;
let deletedBindings = /* @__PURE__ */ new Map();
const deletedShapeIds = /* @__PURE__ */ new Set();
const invalidParents = /* @__PURE__ */ new Set();
let invalidBindingTypes = /* @__PURE__ */ new Set();
this.disposables.add(
this.sideEffects.registerOperationCompleteHandler(() => {
deletedShapeIds.clear();
for (const parentId of invalidParents) {
invalidParents.delete(parentId);
const parent = this.getShape(parentId);
if (!parent) continue;
const util = this.getShapeUtil(parent);
const changes = util.onChildrenChange?.(parent);
if (changes?.length) {
this.updateShapes(changes);
}
}
if (invalidBindingTypes.size) {
const t = invalidBindingTypes;
invalidBindingTypes = /* @__PURE__ */ new Set();
for (const type of t) {
const util = this.getBindingUtil(type);
util.onOperationComplete?.();
}
}
if (deletedBindings.size) {
const t = deletedBindings;
deletedBindings = /* @__PURE__ */ new Map();
for (const opts of t.values()) {
this.getBindingUtil(opts.binding).onAfterDelete?.(opts);
}
}
this.emit("update");
})
);
this.disposables.add(
this.sideEffects.register({
shape: {
afterChange: (shapeBefore, shapeAfter) => {
for (const binding of this.getBindingsInvolvingShape(shapeAfter)) {
invalidBindingTypes.add(binding.type);
if (binding.fromId === shapeAfter.id) {
this.getBindingUtil(binding).onAfterChangeFromShape?.({
binding,
shapeBefore,
shapeAfter,
reason: "self"
});
}
if (binding.toId === shapeAfter.id) {
this.getBindingUtil(binding).onAfterChangeToShape?.({
binding,
shapeBefore,
shapeAfter,
reason: "self"
});
}
}
if (shapeBefore.parentId !== shapeAfter.parentId) {
const notifyBindingAncestryChange = (id) => {
const descendantShape = this.getShape(id);
if (!descendantShape) return;
for (const binding of this.getBindingsInvolvingShape(descendantShape)) {
invalidBindingTypes.add(binding.type);
if (binding.fromId === descendantShape.id) {
this.getBindingUtil(binding).onAfterChangeFromShape?.({
binding,
shapeBefore: descendantShape,
shapeAfter: descendantShape,
reason: "ancestry"
});
}
if (binding.toId === descendantShape.id) {
this.getBindingUtil(binding).onAfterChangeToShape?.({
binding,
shapeBefore: descendantShape,
shapeAfter: descendantShape,
reason: "ancestry"
});
}
}
};
notifyBindingAncestryChange(shapeAfter.id);
this.visitDescendants(shapeAfter.id, notifyBindingAncestryChange);
}
if (shapeBefore.parentId !== shapeAfter.parentId && isPageId(shapeAfter.parentId)) {
const allMovingIds = /* @__PURE__ */ new Set([shapeBefore.id]);
this.visitDescendants(shapeBefore.id, (id) => {
allMovingIds.add(id);
});
for (const instancePageState of this.getPageStates()) {
if (instancePageState.pageId === shapeAfter.parentId) continue;
const nextPageState = cleanupInstancePageState(instancePageState, allMovingIds);
if (nextPageState) {
this.store.put([nextPageState]);
}
}
}
if (shapeBefore.parentId && isShapeId(shapeBefore.parentId)) {
invalidParents.add(shapeBefore.parentId);
}
if (shapeAfter.parentId !== shapeBefore.parentId && isShapeId(shapeAfter.parentId)) {
invalidParents.add(shapeAfter.parentId);
}
},
beforeDelete: (shape) => {
if (deletedShapeIds.has(shape.id)) return;
if (shape.parentId && isShapeId(shape.parentId)) {
invalidParents.add(shape.parentId);
}
deletedShapeIds.add(shape.id);
const deleteBindingIds = [];
for (const binding of this.getBindingsInvolvingShape(shape)) {
invalidBindingTypes.add(binding.type);
deleteBindingIds.push(binding.id);
const util = this.getBindingUtil(binding);
if (binding.fromId === shape.id) {
util.onBeforeIsolateToShape?.({ binding, removedShape: shape });
util.onBeforeDeleteFromShape?.({ binding, shape });
} else {
util.onBeforeIsolateFromShape?.({ binding, removedShape: shape });
util.onBeforeDeleteToShape?.({ binding, shape });
}
}
if (deleteBindingIds.length) {
this.deleteBindings(deleteBindingIds);
}
const deletedIds = /* @__PURE__ */ new Set([shape.id]);
const updates = compact(
this.getPageStates().map((pageState) => {
return cleanupInstancePageState(pageState, deletedIds);
})
);
if (updates.length) {
this.store.put(updates);
}
}
},
binding: {
beforeCreate: (binding) => {
const next = this.getBindingUtil(binding).onBeforeCreate?.({ binding });
if (next) return next;
return binding;
},
afterCreate: (binding) => {
invalidBindingTypes.add(binding.type);
this.getBindingUtil(binding).onAfterCreate?.({ binding });
},
beforeChange: (bindingBefore, bindingAfter) => {
const updated = this.getBindingUtil(bindingAfter).onBeforeChange?.({
bindingBefore,
bindingAfter
});
if (updated) return updated;
return bindingAfter;
},
afterChange: (bindingBefore, bindingAfter) => {
invalidBindingTypes.add(bindingAfter.type);
this.getBindingUtil(bindingAfter).onAfterChange?.({ bindingBefore, bindingAfter });
},
beforeDelete: (binding) => {
this.getBindingUtil(binding).onBeforeDelete?.({ binding });
},
afterDelete: (binding) => {
this.getBindingUtil(binding).onAfterDelete?.({ binding });
invalidBindingTypes.add(binding.type);
}
},
page: {
afterCreate: (record) => {
const cameraId = CameraRecordType.createId(record.id);
const _pageStateId = InstancePageStateRecordType.createId(record.id);
if (!this.store.has(cameraId)) {
this.store.put([CameraRecordType.create({ id: cameraId })]);
}
if (!this.store.has(_pageStateId)) {
this.store.put([
InstancePageStateRecordType.create({ id: _pageStateId, pageId: record.id })
]);
}
},
afterDelete: (record, source) => {
if (this.getInstanceState()?.currentPageId === record.id) {
const backupPageId = this.getPages().find((p) => p.id !== record.id)?.id;
if (backupPageId) {
this.store.put([{ ...this.getInstanceState(), currentPageId: backupPageId }]);
} else if (source === "user") {
this.store.ensureStoreIsUsable();
}
}
const cameraId = CameraRecordType.createId(record.id);
const instance_PageStateId = InstancePageStateRecordType.createId(record.id);
this.store.remove([cameraId, instance_PageStateId]);
}
},
instance: {
afterChange: (prev, next, source) => {
if (!this.store.has(next.currentPageId)) {
const backupPageId = this.store.has(prev.currentPageId) ? prev.currentPageId : this.getPages()[0]?.id;
if (backupPageId) {
this.store.update(next.id, (instance) => ({
...instance,
currentPageId: backupPageId
}));
} else if (source === "user") {
this.store.ensureStoreIsUsable();
}
}
}
},
instance_page_state: {
afterChange: (prev, next) => {
if (prev?.selectedShapeIds !== next?.selectedShapeIds) {
const filtered = next.selectedShapeIds.filter((id) => {
let parentId = this.getShape(id)?.parentId;
while (isShapeId(parentId)) {
if (next.selectedShapeIds.includes(parentId)) {
return false;
}
parentId = this.getShape(parentId)?.parentId;
}
return true;
});
let nextFocusedGroupId = null;
if (filtered.length > 0) {
const commonGroupAncestor = this.findCommonAncestor(
compact(filtered.map((id) => this.getShape(id))),
(shape) => this.isShapeOfType(shape, "group")
);
if (commonGroupAncestor) {
nextFocusedGroupId = commonGroupAncestor;
}
} else {
if (next?.focusedGroupId) {
nextFocusedGroupId = next.focusedGroupId;
}
}
if (filtered.length !== next.selectedShapeIds.length || nextFocusedGroupId !== next.focusedGroupId) {
this.store.put([
{
...next,
selectedShapeIds: filtered,
focusedGroupId: nextFocusedGroupId ?? null
}
]);
}
}
}
}
})
);
this._currentPageShapeIds = deriveShapeIdsInCurrentPage(
this.store,
() => this.getCurrentPageId()
);
this._parentIdsToChildIds = parentsToChildren(this.store);
this.disposables.add(
this.store.listen((changes) => {
this.emit("change", changes);
})
);
this.disposables.add(this.history.dispose);
this.run(
() => {
this.store.ensureStoreIsUsable();
this._updateCurrentPageState({
editingShapeId: null,
hoveredShapeId: null,
erasingShapeIds: []
});
},
{ history: "ignore" }
);
if (initialState && this.root.children[initialState] === void 0) {
throw Error(`No state found for initialState "${initialState}".`);
}
this.root.enter(void 0, "initial");
this.edgeScrollManager = new EdgeScrollManager(this);
this.focusManager = new FocusManager(this, autoFocus);
this.disposables.add(this.focusManager.dispose.bind(this.focusManager));
if (this.getInstanceState().followingUserId) {
this.stopFollowingUser();
}
this.on("tick", this._flushEventsForTick);
this.timers.requestAnimationFrame(() => {
this._tickManager.start();
});
this.performanceTracker = new PerformanceTracker();
if (this.store.props.collaboration?.mode) {
const mode = this.store.props.collaboration.mode;
this.disposables.add(
react("update collaboration mode", () => {
this.store.put([{ ...this.getInstanceState(), isReadonly: mode.get() === "readonly" }]);
})
);
}
this.disposables.add(
react("sync current user record", () => {
const user2 = this.store.props.users.currentUser.get();
if (user2) {
this._ensureUserRecord(user2);
}
})
);
}
_getShapeVisibility;
getIsShapeHiddenCache() {
if (!this._getShapeVisibility) return null;
return this.store.createComputedCache("isShapeHidden", (shape) => {
const visibility = this._getShapeVisibility(shape, this);
const isParentHidden = PageRecordType.isId(shape.parentId) ? false : this.isShapeHidden(shape.parentId);
if (isParentHidden) return visibility !== "visible";
return visibility === "hidden";
});
}
isShapeHidden(shapeOrId) {
if (!this._getShapeVisibility) return false;
return !!this.getIsShapeHiddenCache().get(
typeof shapeOrId === "string" ? shapeOrId : shapeOrId.id
);
}
options;
contextId = uniqueId();
/**
* The editor's store
*
* @public
*/
store;
/**
* The root state of the statechart.
*
* @public
*/
root;
/**
* Set a tool. Useful if you need to add a tool to the state chart on demand,
* after the editor has already been initialized.
*
* @param Tool - The tool to set.
* @param parent - The parent state node to set the tool on.
*
* @public
*/
setTool(Tool, parent) {
parent ??= this.root;
if (hasOwnProperty(parent.children, Tool.id)) {
throw Error(`Can't override tool with id "${Tool.id}"`);
}
parent.children[Tool.id] = new Tool(this, parent);
}
/**
* Remove a tool. Useful if you need to remove a tool from the state chart on demand,
* after the editor has already been initialized.
*
* @param Tool - The tool to delete.
* @param parent - The parent state node to remove the tool from.
*
* @public
*/
removeTool(Tool, parent) {
parent ??= this.root;
if (hasOwnProperty(parent.children, Tool.id)) {
delete parent.children[Tool.id];
}
}
/**
* A set of functions to call when the editor is disposed.
*
* @public
*/
disposables = /* @__PURE__ */ new Set();
/**
* Whether the editor is disposed.
*
* @public
*/
isDisposed = false;
/**
* A manager for the editor's tick events.
*
* @internal */
_tickManager;
/**
* A manager for the editor's input state.
*
* @public
*/
inputs;
/**
* A manager for the editor's snapping feature.
*
* @public
*/
snaps;
/**
* A manager for performance measurement hooks.
*
* @public
*/
performance;
/**
* A manager for the spatial index, tracking where shapes exist on the canvas.
*
* @internal
*/
_spatialIndex;
/**
* A manager for the any asynchronous events and making sure they're
* cleaned up upon disposal.
*
* @public
*/
timers = tltime.forContext(this.contextId);
/**
* A manager for remote peer collaborators connected to this editor.
*
* @public
*/
collaborators;
/**
* A manager for the user and their preferences.
*
* @public
*/
user;
/**
* A manager for the editor's themes.
*
* @internal
*/
_themeManager;
/**
* A helper for measuring text.
*
* @public
*/
textMeasure;
/**
* A utility for managing the set of fonts that should be rendered in the document.
*
* @public
*/
fonts;
/**
* A manager for the editor's scribbles.
*
* @public
*/
scribbles;
/**
* A manager for canvas overlay UI elements (selection handles, shape handles, etc.).
*
* @public
*/
overlays;
/**
* A manager for side effects and correct state enforcement. See {@link @tldraw/store#StoreSideEffects} for details.
*
* @public
*/
sideEffects;
/**
* A manager for moving the camera when the mouse is at the edge of the screen.
*
* @public
*/
edgeScrollManager;
/**
* A manager for ensuring correct focus. See FocusManager for details.
*
* @internal
*/
focusManager;
/**
* The current HTML element containing the editor.
*
* @example
* ```ts
* const container = editor.getContainer()
* ```
*
* @public
*/
getContainer;
/**
* The document that the editor's container element belongs to.
* Use this instead of the global `document` to support cross-window embedding.
*
* @internal
*/
getContainerDocument() {
return this.getContainer().ownerDocument;
}
/**
* The window that the editor's container element belongs to.
* Use this instead of the global `window` to support cross-window embedding.
*
* @internal
*/
getContainerWindow() {
return getOwnerWindow(this.getContainer());
}
/**
* Dispose the editor.
*
* @public
*/
dispose() {
this.stopCameraAnimation();
if (this.getInstanceState().followingUserId) {
this.stopFollowingUser();
}
this.disposables.forEach((dispose) => dispose());
this.disposables.clear();
this.menus.clearOpenMenus();
this.store.dispose();
this.isDisposed = true;
this.emit("dispose");
}
/* ------------------ Themes (shadowing the theme manager) ------------------ */
/**
* Get the current color mode (`'light'` or `'dark'`), based on the user's dark mode preference.
*
* @public
*/
getColorMode() {
return this._themeManager.getColorMode();
}
/**
* Set the color mode. Note that this is a convenience method that passes the mode to
* `user.updateUserPreferences`, which is the source of truth for the user's color mode preference.
*
* @public
*/
setColorMode(mode) {
this.user.updateUserPreferences({ colorScheme: mode });
return this;
}
/**
* Get the id of the current theme.
*
* @public
*/
getCurrentThemeId() {
return this._themeManager.getCurrentThemeId();
}
/**
* Get the current theme definition.
*
* @public
*/
getCurrentTheme() {
return this._themeManager.getCurrentTheme();
}
/**
* Set the current theme by id.
*
* @public
*/
setCurrentTheme(id) {
this._themeManager.setCurrentTheme(id);
return this;
}
/**
* Get all registered theme definitions.
*
* @public
*/
getThemes() {
return this._themeManager.getThemes();
}
/**
* Get a single theme definition by id.
*
* @public
*/
getTheme(id) {
return this._themeManager.getTheme(id);
}
/**
* Replace all theme definitions, or update them via a callback that receives a deep copy.
* The `'default'` theme must always be present in the result.
*
* @example
* ```ts
* // Replace all themes
* editor.updateThemes({ default: myDefaultTheme, ocean: myOceanTheme })
*
* // Update via callback
* editor.updateThemes((themes) => {
* delete themes.ocean
* return themes
* })
* ```
*
* @public
*/
updateThemes(themes) {
this._themeManager.updateThemes(themes);
return this;
}
/**
* Register or update a single theme definition. The theme is keyed by its `id` property.
*
* @example
* ```ts
* // Override a property on the default theme
* editor.updateTheme({ ...editor.getTheme('default')!, fontSize: 24 })
*
* // Register a new theme
* editor.updateTheme({ id: 'ocean', ...myOceanTheme })
* ```
*
* @public
*/
updateTheme(theme) {
this._themeManager.updateTheme(theme);
return this;
}
/* ------------------- Shape Utils ------------------ */
/**
* A map of shape utility classes (TLShapeUtils) by shape type.
*
* @public
*/
shapeUtils;
/** @internal */
_shapeUtilsByAssetType = {};
styleProps;
getShapeUtil(arg) {
const type = typeof arg === "string" ? arg : arg.type;
const shapeUtil = getOwnProperty(this.shapeUtils, type);
assert(shapeUtil, `No shape util found for type "${type}"`);
return shapeUtil;
}
hasShapeUtil(arg) {
const type = typeof arg === "string" ? arg : arg.type;
return hasOwnProperty(this.shapeUtils, type);
}
/**
* Get the shape util that handles the given asset type.
* Returns the shape util whose {@link ShapeUtil.handledAssetTypes} includes
* the given asset type, or undefined if none matches.
*
* @param assetType - The asset type string.
* @public
*/
getShapeUtilForAssetType(assetType) {
return getOwnProperty(this._shapeUtilsByAssetType, assetType);
}
/* ------------------- Binding Utils ------------------ */
/**
* A map of shape utility classes (TLShapeUtils) by shape type.
*
* @public
*/
bindingUtils;
getBindingUtil(arg) {
const type = typeof arg === "string" ? arg : arg.type;
const bindingUtil = getOwnProperty(this.bindingUtils, type);
assert(bindingUtil, `No binding util found for type "${type}"`);
return bindingUtil;
}
/* ------------------- Asset Utils ------------------ */
/**
* A map of asset utility classes by asset type.
*
* @public
*/
assetUtils = {};
getAssetUtil(arg) {
const type = typeof arg === "string" ? arg : arg.type;
const assetUtil = getOwnProperty(this.assetUtils, type);
assert(assetUtil, `No asset util found for type "${type}"`);
return assetUtil;
}
/**
* Returns true if the editor has an asset util for the given asset type.
*
* @public
*/
hasAssetUtil(arg) {
const type = typeof arg === "string" ? arg : arg.type;
return hasOwnProperty(this.assetUtils, type);
}
/**
* Get the asset util that accepts the given MIME type.
* Returns null if no registered asset util accepts the MIME type.
*
* @public
*/
getAssetUtilForMimeType(mimeType) {
for (const util of Object.values(this.assetUtils)) {
if (util && util.acceptsMimeType(mimeType)) {
return util;
}
}
return null;
}
/* --------------------- History -------------------- */
/**
* A manager for the editor's history.
*
* @readonly
*/
history;
/**
* Undo to the last mark.
*
* @example
* ```ts
* editor.undo()
* ```
*
* @public
*/
undo() {
this._flushEventsForTick(0);
this.complete();
this.history.undo();
this.performance._notifyUndoRedo("undo", this.history.getNumUndos(), this.history.getNumRedos());
return this;
}
canUndo() {
return this.history.getNumUndos() > 0;
}
getCanUndo() {
return this.canUndo();
}
/**
* Redo to the next mark.
*
* @example
* ```ts
* editor.redo()
* ```
*
* @public
*/
redo() {
this._flushEventsForTick(0);
this.complete();
this.history.redo();
this.performance._notifyUndoRedo("redo", this.history.getNumUndos(), this.history.getNumRedos());
return this;
}
canRedo() {
return this.history.getNumRedos() > 0;
}
getCanRedo() {
return this.canRedo();
}
clearHistory() {
this.history.clear();
return this;
}
/**
* Create a new "mark", or stopping point, in the undo redo history. Creating a mark will clear
* any redos. You typically want to do this just before a user interaction begins or is handled.
*
* @example
* ```ts
* editor.markHistoryStoppingPoint()
* editor.flipShapes(editor.getSelectedShapes())
* ```
* @example
* ```ts
* const beginRotateMark = editor.markHistoryStoppingPoint()
* // if the use cancels the rotation, you can bail back to this mark
* editor.bailToMark(beginRotateMark)
* ```
*
* @public
* @param name - The name of the mark, useful for debugging the undo/redo stacks
* @returns a unique id for the mark that can be used with `squashToMark` or `bailToMark`.
*/
markHistoryStoppingPoint(name) {
const id = `[${name ?? "stop"}]_${uniqueId()}`;
this.history._mark(id);
return id;
}
/**
* @internal this is only used to implement some backwards-compatibility logic. Should be fine to delete after 6 months or whatever.
*/
getMarkIdMatching(idSubstring) {
return this.history.getMarkIdMatching(idSubstring);
}
/**
* Coalesces all changes since the given mark into a single change, removing any intermediate marks.
*
* This is useful if you need to 'compress' the recent history to simplify the undo/redo experience of a complex interaction.
*
* @example
* ```ts
* const bumpShapesMark = editor.markHistoryStoppingPoint()
* // ... some changes
* editor.squashToMark(bumpShapesMark)
* ```
*
* @param markId - The mark id to squash to.
*/
squashToMark(markId) {
this.history.squashToMark(markId);
return this;
}
/**
* Undo to the closest mark, discarding the changes so they cannot be redone.
*
* @example
* ```ts
* editor.bail()
* ```
*
* @public
*/
bail() {
this.history.bail();
return this;
}
/**
* Undo to the given mark, discarding the changes so they cannot be redone.
*
* @example
* ```ts
* const beginDrag = editor.markHistoryStoppingPoint()
* // ... some changes
* editor.bailToMark(beginDrag)
* ```
*
* @public
*/
bailToMark(id) {
this.history.bailToMark(id);
return this;
}
_shouldIgnoreShapeLock = false;
/**
* Run a function in a transaction with optional options for context.
* You can use the options to change the way that history is treated
* or allow changes to locked shapes.
*
* @example
* ```ts
* // updating with
* editor.run(() => {
* editor.updateShape({ ...myShape, x: 100 })
* }, { history: "ignore" })
*
* // forcing changes / deletions for locked shapes
* editor.toggleLock([myShape])
* editor.run(() => {
* editor.updateShape({ ...myShape, x: 100 })
* editor.deleteShape(myShape)
* }, { ignoreShapeLock: true }, )
* ```
*
* @param fn - The callback function to run.
* @param opts - The options for the batch.
*
*
* @public
*/
run(fn, opts) {
const previousIgnoreShapeLock = this._shouldIgnoreShapeLock;
this._shouldIgnoreShapeLock = opts?.ignoreShapeLock ?? previousIgnoreShapeLock;
try {
this.history.batch(fn, opts);
} finally {
this._shouldIgnoreShapeLock = previousIgnoreShapeLock;
}
return this;
}
/* --------------------- Errors --------------------- */
/** @internal */
annotateError(error, {
origin,
willCrashApp,
tags,
extras
}) {
const defaultAnnotations = this.createErrorAnnotations(origin, willCrashApp);
annotateError(error, {
tags: { ...defaultAnnotations.tags, ...tags },
extras: { ...defaultAnnotations.extras, ...extras }
});
if (willCrashApp) {
this.store.markAsPossiblyCorrupted();
}
return this;
}
/** @internal */
createErrorAnnotations(origin, willCrashApp) {
try {
const editingShapeId = this.getEditingShapeId();
return {
tags: {
origin,
willCrashApp
},
extras: {
activeStateNode: this.root.getPath(),
selectedShapes: this.getSelectedShapes().map((s) => {
const { props, ...rest } = s;
const { text: _text, richText: _richText, ...restProps } = props;
return {
...rest,
props: restProps
};
}),
selectionCount: this.getSelectedShapes().length,
editingShape: editingShapeId ? this.getShape(editingShapeId) : void 0,
inputs: this.inputs.toJson(),
pageState: this.getCurrentPageState(),
instanceState: this.getInstanceState(),
collaboratorCount: this.getCollaboratorsOnCurrentPage().length
}
};
} catch {
return {
tags: {
origin,
willCrashApp
},
extras: {}
};
}
}
/** @internal */
_crashingError = 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 editor's `crash` event.
*
* @internal
*/
getCrashingError() {
return this._crashingError;
}
/** @internal */
crash(error) {
this._crashingError = error;
this.store.markAsPossiblyCorrupted();
this.emit("crash", { error });
return this;
}
getPath() {
return this.root.getPath().split("root.")[1];
}
/**
* Get whether a certain tool (or other state node) is currently active.
*
* @example
* ```ts
* editor.isIn('select')
* editor.isIn('select.brushing')
* ```
*
* @param path - The path of active states, separated by periods.
*
* @public
*/
isIn(path) {
const ids = path.split(".").reverse();
let state = this.root;
while (ids.length > 0) {
const id = ids.pop();
if (!id) return true;
const current = state.getCurrent();
if (current?.id === id) {
if (ids.length === 0) return true;
state = current;
continue;
} else return false;
}
return false;
}
/**
* Get whether the state node is in any of the given active paths.
*
* @example
* ```ts
* state.isInAny('select', 'erase')
* state.isInAny('select.brushing', 'erase.idle')
* ```
*
* @public
*/
isInAny(...paths) {
return paths.some((path) => this.isIn(path));
}
/**
* Set the selected tool.
*
* @example
* ```ts
* editor.setCurrentTool('hand')
* editor.setCurrentTool('hand', { date: Date.now() })
* ```
*
* @param id - The id of the tool to select.
* @param info - Arbitrary data to pass along into the transition.
*
* @public
*/
setCurrentTool(id, info = {}) {
this.root.transition(id, info);
return this;
}
getCurrentTool() {
return this.root.getCurrent();
}
getCurrentToolId() {
const currentTool = this.getCurrentTool();
if (!currentTool) return "";
return currentTool.getCurrentToolIdMask() ?? currentTool.id;
}
/**
* Get a descendant by its path.
*
* @example
* ```ts
* editor.getStateDescendant('select')
* editor.getStateDescendant('select.brushing')
* ```
*
* @param path - The descendant's path of state ids, separated by periods.
*
* @public
*/
getStateDescendant(path) {
const ids = path.split(".").reverse();
let state = this.root;
while (ids.length > 0) {
const id = ids.pop();
if (!id) return state;
const childState = state.children?.[id];
if (!childState) return void 0;
state = childState;
}
return state;
}
getDocumentSettings() {
return this.store.get(TLDOCUMENT_ID);
}
/**
* Update the global document settings that apply to all users.
*
* @public
**/
updateDocumentSettings(settings) {
this.run(
() => {
this.store.put([{ ...this.getDocumentSettings(), ...settings }]);
},
{ history: "ignore" }
);
return this;
}
getInstanceState() {
return this.store.get(TLINSTANCE_ID);
}
/**
* Update the instance's state.
*
* @param partial - A partial object to update the instance state with.
* @param historyOptions - History batch options.
*
* @public
*/
updateInstanceState(partial, historyOptions) {
this._updateInstanceState(partial, { history: "ignore", ...historyOptions });
if (partial.isChangingStyle !== void 0) {
clearTimeout(this._isChangingStyleTimeout);
if (partial.isChangingStyle === true) {
this._isChangingStyleTimeout = this.timers.setTimeout(() => {
this._updateInstanceState({ isChangingStyle: false }, { history: "ignore" });
}, 1e3);
}
}
return this;
}
/** @internal */
_updateInstanceState(partial, opts) {
this.run(() => {
this.store.put([
{
...this.getInstanceState(),
...partial
}
]);
}, opts);
}
/** @internal */
_isChangingStyleTimeout = -1;
// Menus
menus = tlmenus.forContext(this.contextId);
/* --------------------- Cursor --------------------- */
/**
* Set the cursor.
*
* No-op when the partial wouldn't change the current cursor — `setCursor`
* is called from pointer-move hot paths (see `updateHoveredOverlayId`,
* various tool states) and skipping redundant writes avoids needlessly
* dirtying instance state.
*
* @param cursor - The cursor to set.
* @public
*/
setCursor(cursor) {
const current = this.getInstanceState().cursor;
if ((cursor.type === void 0 || cursor.type === current.type) && (cursor.rotation === void 0 || cursor.rotation === current.rotation)) {
return this;
}
this.updateInstanceState({ cursor: { ...current, ...cursor } });
return this;
}
getPageStates() {
return this._getPageStatesQuery().get();
}
_getPageStatesQuery() {
return this.store.query.records("instance_page_state");
}
getCurrentPageState() {
return this.store.get(this._getCurrentPageStateId());
}
_getCurrentPageStateId() {
return InstancePageStateRecordType.createId(this.getCurrentPageId());
}
/**
* Update this instance's page state.
*
* @example
* ```ts
* editor.updateCurrentPageState({ id: 'page1', editingShapeId: 'shape:123' })
* ```
*
* @param partial - The partial of the page state object containing the changes.
*
* @public
*/
updateCurrentPageState(partial) {
this._updateCurrentPageState(partial);
return this;
}
_updateCurrentPageState(partial) {
this.store.update(partial.id ?? this.getCurrentPageState().id, (state) => ({
...state,
...partial
}));
}
getSelectedShapeIds() {
return this.getCurrentPageState().selectedShapeIds;
}
getSelectedShapes() {
return compact(this.getSelectedShapeIds().map((id) => this.store.get(id)));
}
/**
* Select one or more shapes.
*
* @example
* ```ts
* editor.setSelectedShapes(['id1'])
* editor.setSelectedShapes(['id1', 'id2'])
* ```
*
* @param shapes - The shape (or shape ids) to select.
*
* @public
*/
setSelectedShapes(shapes) {
return this.run(
() => {
const ids = shapes.map((shape) => typeof shape === "string" ? shape : shape.id);
const { selectedShapeIds: prevSelectedShapeIds } = this.getCurrentPageState();
const prevSet = new Set(prevSelectedShapeIds);
if (ids.length === prevSet.size && ids.every((id) => prevSet.has(id))) return null;
this.store.put([{ ...this.getCurrentPageState(), selectedShapeIds: ids }]);
},
{ history: "record-preserveRedoStack" }
);
}
/**
* Determine whether or not any of a shape's ancestors are selected.
*
* @param shape - The shape (or shape id) of the shape to check.
*
* @public
*/
isAncestorSelected(shape) {
const id = typeof shape === "string" ? shape : shape?.id ?? null;
const _shape = this.getShape(id);
if (!_shape) return false;
const selectedShapeIds = this.getSelectedShapeIds();
return !!this.findShapeAncestor(_shape, (parent) => selectedShapeIds.includes(parent.id));
}
/**
* Select one or more shapes.
*
* @example
* ```ts
* editor.select('id1')
* editor.select('id1', 'id2')
* ```
*
* @param shapes - The shape (or the shape ids) to select.
*
* @public
*/
select(...shapes) {
const ids = typeof shapes[0] === "string" ? shapes : shapes.map((shape) => shape.id);
this.setSelectedShapes(ids);
return this;
}
/**
* Remove a shape from the existing set of selected shapes.
*
* @example
* ```ts
* editor.deselect(shape.id)
* ```
*
* @public
*/
deselect(...shapes) {
const ids = typeof shapes[0] === "string" ? shapes : shapes.map((shape) => shape.id);
const selectedShapeIds = this.getSelectedShapeIds();
if (selectedShapeIds.length > 0 && ids.length > 0) {
this.setSelectedShapes(selectedShapeIds.filter((id) => !ids.includes(id)));
}
return this;
}
/**
* Select all shapes. If the user has selected shapes that share a parent,
* select all shapes within that parent. If the user has not selected any shapes,
* or if the shapes shapes are only on select all shapes on the current page.
*
* @example
* ```ts
* editor.selectAll()
* ```
*
* @public
*/
selectAll() {
let parentToSelectWithinId = null;
const selectedShapeIds = this.getSelectedShapeIds();
if (selectedShapeIds.length > 0) {
for (const id of selectedShapeIds) {
const shape = this.getShape(id);
if (!shape) continue;
if (parentToSelectWithinId === null) {
parentToSelectWithinId = shape.parentId;
} else if (parentToSelectWithinId !== shape.parentId) {
return this;
}
}
}
if (!parentToSelectWithinId) {
parentToSelectWithinId = this.getCurrentPageId();
}
const ids = this.getSortedChildIdsForParent(parentToSelectWithinId);
if (ids.length <= 0) return this;
this.setSelectedShapes(this._getUnlockedShapeIds(ids));
return this;
}
/**
* Select the next shape in the reading order or in cardinal order.
*
* @example
* ```ts
* editor.selectAdjacentShape('next')
* ```
*
* @public
*/
selectAdjacentShape(direction) {
const selectedShapeIds = this.getSelectedShapeIds();
const firstParentId = selectedShapeIds[0] ? this.getShape(selectedShapeIds[0])?.parentId : null;
const isSelectedWithinContainer = firstParentId && selectedShapeIds.every((shapeId) => this.getShape(shapeId)?.parentId === firstParentId) && !isPageId(firstParentId);
const filteredShapes = isSelectedWithinContainer ? this.getCurrentPageShapes().filter((shape2) => shape2.parentId === firstParentId) : this.getCurrentPageShapes().filter((shape2) => isPageId(shape2.parentId));
const readingOrderShapes = isSelectedWithinContainer ? this._getShapesInRead