history options / markId / createPage (#1796)

This PR:

- adds history options to several commands in order to allow them to
support squashing and ephemeral data (previously, these commands had
boolean values for squashing / ephemeral)

It also:
- changes `markId` to return the editor instance rather than the mark id
passed into the command
- removes `focus` and `blur` commands
- changes `createPage` parameters
- unifies `animateShape` / `animateShapes` options

### Change Type

- [x] `major` — Breaking change

### Test Plan

- [x] Unit Tests
This commit is contained in:
Steve Ruiz 2023-08-05 12:21:07 +01:00 committed by GitHub
parent ae56d975e0
commit 8991468446
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
72 changed files with 977 additions and 505 deletions

View file

@ -22,8 +22,6 @@ export default function APIExample() {
// Create a shape id
const id = createShapeId('hello')
editor.focus()
// Create a shape
editor.createShapes<TLGeoShape>([
{
@ -72,7 +70,7 @@ export default function APIExample() {
return (
<div className="tldraw__editor">
<Tldraw persistenceKey="api-example" onMount={handleMount} autoFocus={false}>
<Tldraw persistenceKey="api-example" onMount={handleMount}>
<InsideOfEditorContext />
</Tldraw>
</div>

View file

@ -40,6 +40,7 @@ import { TLAssetPartial } from '@tldraw/tlschema';
import { TLBaseShape } from '@tldraw/tlschema';
import { TLBookmarkAsset } from '@tldraw/tlschema';
import { TLCamera } from '@tldraw/tlschema';
import { TLCursor } from '@tldraw/tlschema';
import { TLCursorType } from '@tldraw/tlschema';
import { TLDefaultHorizontalAlignStyle } from '@tldraw/tlschema';
import { TLDocument } from '@tldraw/tlschema';
@ -530,14 +531,11 @@ export class Editor extends EventEmitter<TLEventMap> {
alignShapes(shapes: TLShape[], operation: 'bottom' | 'center-horizontal' | 'center-vertical' | 'left' | 'right' | 'top'): this;
// (undocumented)
alignShapes(ids: TLShapeId[], operation: 'bottom' | 'center-horizontal' | 'center-vertical' | 'left' | 'right' | 'top'): this;
animateShape(partial: null | TLShapePartial | undefined, options?: Partial<{
animateShape(partial: null | TLShapePartial | undefined, animationOptions?: TLAnimationOptions): this;
animateShapes(partials: (null | TLShapePartial | undefined)[], animationOptions?: Partial<{
duration: number;
ease: (t: number) => number;
easing: (t: number) => number;
}>): this;
animateShapes(partials: (null | TLShapePartial | undefined)[], options?: {
duration?: number;
ease?: (t: number) => number;
}): this;
animateToShape(shapeId: TLShapeId, opts?: TLAnimationOptions): this;
animateToUser(userId: string): this;
// @internal (undocumented)
@ -551,8 +549,6 @@ export class Editor extends EventEmitter<TLEventMap> {
bail(): this;
bailToMark(id: string): this;
batch(fn: () => void): this;
// (undocumented)
blur: () => void;
bringForward(shapes: TLShape[]): this;
// (undocumented)
bringForward(ids: TLShapeId[]): this;
@ -589,9 +585,9 @@ export class Editor extends EventEmitter<TLEventMap> {
inputs?: Record<string, unknown>;
};
};
createPage(title: string, id?: TLPageId, belowPageIndex?: string): this;
createShape<T extends TLUnknownShape>(partial: OptionalKeys<TLShapePartial<T>, 'id'>): this;
createShapes<T extends TLUnknownShape>(partials: OptionalKeys<TLShapePartial<T>, 'id'>[]): this;
createPage(page: Partial<TLPage>): this;
createShape<T extends TLUnknownShape>(shape: OptionalKeys<TLShapePartial<T>, 'id'>): this;
createShapes<T extends TLUnknownShape>(shapes: OptionalKeys<TLShapePartial<T>, 'id'>[]): this;
get croppingShapeId(): null | TLShapeId;
get currentPage(): TLPage;
get currentPageBounds(): Box2d | undefined;
@ -660,14 +656,13 @@ export class Editor extends EventEmitter<TLEventMap> {
flipShapes(shapes: TLShape[], operation: 'horizontal' | 'vertical'): this;
// (undocumented)
flipShapes(ids: TLShapeId[], operation: 'horizontal' | 'vertical'): this;
// (undocumented)
focus: () => void;
get focusedGroupId(): TLPageId | TLShapeId;
getAncestorPageId(shape?: TLShape): TLPageId | undefined;
// (undocumented)
getAncestorPageId(shapeId?: TLShapeId): TLPageId | undefined;
getArrowInfo(shape: TLArrowShape): TLArrowInfo | undefined;
// (undocumented)
getArrowInfo(shape: TLArrowShape): ArrowInfo | undefined;
getArrowInfo(id: TLShapeId): TLArrowInfo | undefined;
getArrowsBoundTo(shapeId: TLShapeId): {
arrowId: TLShapeId;
handleId: "end" | "start";
@ -677,9 +672,9 @@ export class Editor extends EventEmitter<TLEventMap> {
getAsset(id: TLAssetId): TLAsset | undefined;
getAssetForExternalContent(info: TLExternalAssetContent_2): Promise<TLAsset | undefined>;
getContainer: () => HTMLElement;
getContent(ids: TLShapeId[]): TLContent | undefined;
getContentFromCurrentPage(ids: TLShapeId[]): TLContent | undefined;
// (undocumented)
getContent(shapes: TLShape[]): TLContent | undefined;
getContentFromCurrentPage(shapes: TLShape[]): TLContent | undefined;
getCurrentPageShapeIds(pageId: TLPageId): Set<TLShapeId>;
// (undocumented)
getCurrentPageShapeIds(page: TLPage): Set<TLShapeId>;
@ -772,9 +767,9 @@ export class Editor extends EventEmitter<TLEventMap> {
darkMode?: boolean | undefined;
preserveAspectRatio: React.SVGAttributes<SVGSVGElement>['preserveAspectRatio'];
}>): Promise<SVGSVGElement | undefined>;
groupShapes(ids: TLShapeId[], groupId?: TLShapeId): this;
// (undocumented)
groupShapes(shapes: TLShape[], groupId?: TLShapeId): this;
// (undocumented)
groupShapes(ids: TLShapeId[], groupId?: TLShapeId): this;
hasAncestor(shape: TLShape | undefined, ancestorId: TLShapeId): boolean;
// (undocumented)
hasAncestor(shapeId: TLShapeId | undefined, ancestorId: TLShapeId): boolean;
@ -828,13 +823,13 @@ export class Editor extends EventEmitter<TLEventMap> {
isShapeOrAncestorLocked(shape?: TLShape): boolean;
// (undocumented)
isShapeOrAncestorLocked(id?: TLShapeId): boolean;
mark(markId?: string, onUndo?: boolean, onRedo?: boolean): string;
mark(markId?: string, onUndo?: boolean, onRedo?: boolean): this;
moveShapesToPage(shapes: TLShape[], pageId: TLPageId): this;
// (undocumented)
moveShapesToPage(ids: TLShapeId[], pageId: TLPageId): this;
nudgeShapes(shapes: TLShape[], offset: VecLike, ephemeral?: boolean): this;
nudgeShapes(shapes: TLShape[], offset: VecLike, historyOptions?: CommandHistoryOptions): this;
// (undocumented)
nudgeShapes(ids: TLShapeId[], offset: VecLike, ephemeral?: boolean): this;
nudgeShapes(ids: TLShapeId[], offset: VecLike, historyOptions?: CommandHistoryOptions): this;
get onlySelectedShape(): null | TLShape;
get openMenus(): string[];
packShapes(shapes: TLShape[], gap: number): this;
@ -850,7 +845,7 @@ export class Editor extends EventEmitter<TLEventMap> {
pan(offset: VecLike, animation?: TLAnimationOptions): this;
panZoomIntoView(ids: TLShapeId[], animation?: TLAnimationOptions): this;
popFocusLayer(): this;
putContent(content: TLContent, options?: {
putContentOntoCurrentPage(content: TLContent, options?: {
point?: VecLike;
select?: boolean;
preservePosition?: boolean;
@ -864,9 +859,9 @@ export class Editor extends EventEmitter<TLEventMap> {
registerExternalContentHandler<T extends TLExternalContent_2['type']>(type: T, handler: ((info: T extends TLExternalContent_2['type'] ? TLExternalContent_2 & {
type: T;
} : TLExternalContent_2) => void) | null): this;
renamePage(page: TLPage, name: string, squashing?: boolean): this;
renamePage(page: TLPage, name: string, historyOptions?: CommandHistoryOptions): this;
// (undocumented)
renamePage(id: TLPageId, name: string, squashing?: boolean): this;
renamePage(id: TLPageId, name: string, historyOptions?: CommandHistoryOptions): this;
get renderingBounds(): Box2d;
get renderingBoundsExpanded(): Box2d;
renderingBoundsMargin: number;
@ -885,15 +880,9 @@ export class Editor extends EventEmitter<TLEventMap> {
// (undocumented)
reparentShapes(ids: TLShapeId[], parentId: TLParentId, insertIndex?: string): this;
resetZoom(point?: Vec2d, animation?: TLAnimationOptions): this;
resizeShape(id: TLShapeId, scale: VecLike, options?: {
initialBounds?: Box2d;
scaleOrigin?: VecLike;
scaleAxisRotation?: number;
initialShape?: TLShape;
initialPageTransform?: MatLike;
dragHandle?: TLResizeHandle;
mode?: TLResizeMode;
}): this;
resizeShape(shape: TLShape, scale: VecLike, options?: TLResizeShapeOptions): this;
// (undocumented)
resizeShape(id: TLShapeId, scale: VecLike, options?: TLResizeShapeOptions): this;
readonly root: RootState;
rotateShapesBy(shapes: TLShape[], delta: number): this;
// (undocumented)
@ -921,18 +910,19 @@ export class Editor extends EventEmitter<TLEventMap> {
sendToBack(ids: TLShapeId[]): this;
setCamera(point: VecLike, animation?: TLAnimationOptions): this;
setCroppingShapeId(id: null | TLShapeId): this;
setCurrentPage(page: TLPage, opts?: TLViewportOptions): this;
setCurrentPage(page: TLPage, historyOptions?: CommandHistoryOptions): this;
// (undocumented)
setCurrentPage(pageId: TLPageId, opts?: TLViewportOptions): this;
setCurrentPage(pageId: TLPageId, historyOptions?: CommandHistoryOptions): this;
setCurrentTool(id: string, info?: {}): this;
setCursor: (cursor: Partial<TLCursor>) => this;
setEditingShapeId(id: null | TLShapeId): this;
setErasingShapeIds(ids: TLShapeId[]): this;
setFocusedGroupId(next: null | TLShapeId): this;
setHintingIds(ids: TLShapeId[]): this;
setHoveredShapeId(id: null | TLShapeId): this;
setOpacity(opacity: number, ephemeral?: boolean, squashing?: boolean): this;
setSelectedShapeIds(ids: TLShapeId[], squashing?: boolean): this;
setStyle<T>(style: StyleProp<T>, value: T, ephemeral?: boolean, squashing?: boolean): this;
setOpacity(opacity: number, historyOptions?: CommandHistoryOptions): this;
setSelectedShapeIds(ids: TLShapeId[], historyOptions?: CommandHistoryOptions): this;
setStyle<T>(style: StyleProp<T>, value: T, historyOptions?: CommandHistoryOptions): this;
shapeUtils: {
readonly [K in string]?: ShapeUtil<TLUnknownShape>;
};
@ -964,19 +954,19 @@ export class Editor extends EventEmitter<TLEventMap> {
toggleLock(shapes: TLShape[]): this;
// (undocumented)
toggleLock(ids: TLShapeId[]): this;
undo(): HistoryManager<this>;
undo(): this;
ungroupShapes(ids: TLShapeId[]): this;
// (undocumented)
ungroupShapes(ids: TLShape[]): this;
updateAssets(assets: TLAssetPartial[]): this;
updateCurrentPageState(partial: Partial<Omit<TLInstancePageState, 'editingShapeId' | 'focusedGroupId' | 'pageId' | 'selectedShapeIds'>>, ephemeral?: boolean): this;
updateCurrentPageState(partial: Partial<Omit<TLInstancePageState, 'editingShapeId' | 'focusedGroupId' | 'pageId' | 'selectedShapeIds'>>, historyOptions?: CommandHistoryOptions): this;
updateDocumentSettings(settings: Partial<TLDocument>): this;
updateInstanceState(partial: Partial<Omit<TLInstance, 'currentPageId'>>, ephemeral?: boolean, squashing?: boolean): this;
updatePage(partial: RequiredKeys<TLPage, 'id'>, squashing?: boolean): this;
updateInstanceState(partial: Partial<Omit<TLInstance, 'currentPageId'>>, historyOptions?: CommandHistoryOptions): this;
updatePage(partial: RequiredKeys<TLPage, 'id'>, historyOptions?: CommandHistoryOptions): this;
// @internal
updateRenderingBounds(): this;
updateShape<T extends TLUnknownShape>(partial: null | TLShapePartial<T> | undefined, squashing?: boolean): this;
updateShapes<T extends TLUnknownShape>(partials: (null | TLShapePartial<T> | undefined)[], squashing?: boolean): this;
updateShape<T extends TLUnknownShape>(partial: null | TLShapePartial<T> | undefined, historyOptions?: CommandHistoryOptions): this;
updateShapes<T extends TLUnknownShape>(partials: (null | TLShapePartial<T> | undefined)[], historyOptions?: CommandHistoryOptions): this;
updateViewportScreenBounds(center?: boolean): this;
readonly user: UserPreferencesManager;
get viewportPageBounds(): Box2d;
@ -1128,7 +1118,7 @@ export abstract class Geometry2d {
export function getArcLength(C: VecLike, r: number, A: VecLike, B: VecLike): number;
// @public (undocumented)
export function getArrowheadPathForType(info: ArrowInfo, side: 'end' | 'start', strokeWidth: number): string | undefined;
export function getArrowheadPathForType(info: TLArrowInfo, side: 'end' | 'start', strokeWidth: number): string | undefined;
// @public (undocumented)
export function getArrowTerminalsInArrowSpace(editor: Editor, shape: TLArrowShape): {
@ -1140,7 +1130,7 @@ export function getArrowTerminalsInArrowSpace(editor: Editor, shape: TLArrowShap
export function getCursor(cursor: TLCursorType, rotation?: number, color?: string): string;
// @public
export function getCurvedArrowHandlePath(info: ArrowInfo & {
export function getCurvedArrowHandlePath(info: TLArrowInfo & {
isStraight: false;
}): string;
@ -1198,12 +1188,12 @@ export function getRotationSnapshot({ editor }: {
}): null | TLRotationSnapshot;
// @public
export function getSolidCurvedArrowPath(info: ArrowInfo & {
export function getSolidCurvedArrowPath(info: TLArrowInfo & {
isStraight: false;
}): string;
// @public (undocumented)
export function getSolidStraightArrowPath(info: ArrowInfo & {
export function getSolidStraightArrowPath(info: TLArrowInfo & {
isStraight: true;
}): string;
@ -1211,7 +1201,7 @@ export function getSolidStraightArrowPath(info: ArrowInfo & {
export const getStarBounds: (sides: number, w: number, h: number) => Box2d;
// @public (undocumented)
export function getStraightArrowHandlePath(info: ArrowInfo & {
export function getStraightArrowHandlePath(info: TLArrowInfo & {
isStraight: true;
}): string;
@ -1988,7 +1978,7 @@ export const TAU: number;
// @public (undocumented)
export type TLAnimationOptions = Partial<{
duration: number;
easing: typeof EASINGS.easeInOutCubic;
easing: (t: number) => number;
}>;
// @public (undocumented)
@ -2144,7 +2134,6 @@ export type TLEditorComponents = {
// @public (undocumented)
export interface TLEditorOptions {
getContainer: () => HTMLElement;
// (undocumented)
initialState?: string;
shapeUtils: readonly TLShapeUtilConstructor<TLUnknownShape>[];
store: TLStore;
@ -2473,6 +2462,17 @@ export type TLResizeInfo<T extends TLShape> = {
// @public
export type TLResizeMode = 'resize_bounds' | 'scale_shape';
// @public (undocumented)
export type TLResizeShapeOptions = Partial<{
initialBounds: Box2d;
scaleOrigin: VecLike;
scaleAxisRotation: number;
initialShape: TLShape;
initialPageTransform: MatLike;
dragHandle: TLResizeHandle;
mode: TLResizeMode;
}>;
// @public
export type TLRotationSnapshot = {
selectionPageCenter: Vec2d;

View file

@ -136,7 +136,12 @@ export {
SVG_PADDING,
ZOOMS,
} from './lib/constants'
export { Editor, type TLAnimationOptions, type TLEditorOptions } from './lib/editor/Editor'
export {
Editor,
type TLAnimationOptions,
type TLEditorOptions,
type TLResizeShapeOptions,
} from './lib/editor/Editor'
export {
SnapManager,
type GapsSnapLine,

View file

@ -274,7 +274,7 @@ function TldrawEditorWithReadyStore({
React.useLayoutEffect(() => {
if (editor && autoFocus) {
editor.focus()
editor.getContainer().focus()
}
}, [editor, autoFocus])

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
import { HistoryManager } from './HistoryManager'
import { CommandHistoryOptions, HistoryManager } from './HistoryManager'
import { stack } from './Stack'
function createCounterHistoryManager() {
@ -286,3 +286,153 @@ describe(HistoryManager, () => {
expect(editor.getCount()).toBe(2)
})
})
describe('history options', () => {
let manager: HistoryManager<any>
let state: { a: number; b: number }
let setA: (n: number, historyOptions?: CommandHistoryOptions) => any
let setB: (n: number, historyOptions?: CommandHistoryOptions) => any
beforeEach(() => {
manager = new HistoryManager({ emit: () => void null }, () => {
return
})
state = {
a: 0,
b: 0,
}
setA = manager.createCommand(
'setA',
(n: number, historyOptions?: CommandHistoryOptions) => ({
data: { next: n, prev: state.a },
...historyOptions,
}),
{
do: ({ next }) => {
state = { ...state, a: next }
},
undo: ({ prev }) => {
state = { ...state, a: prev }
},
squash: ({ prev }, { next }) => ({ prev, next }),
}
)
setB = manager.createCommand(
'setB',
(n: number, historyOptions?: CommandHistoryOptions) => ({
data: { next: n, prev: state.b },
...historyOptions,
}),
{
do: ({ next }) => {
state = { ...state, b: next }
},
undo: ({ prev }) => {
state = { ...state, b: prev }
},
squash: ({ prev }, { next }) => ({ prev, next }),
}
)
})
it('sets, undoes, redoes', () => {
manager.mark()
setA(1)
manager.mark()
setB(1)
manager.mark()
setB(2)
expect(state).toMatchObject({ a: 1, b: 2 })
manager.undo()
expect(state).toMatchObject({ a: 1, b: 1 })
manager.redo()
expect(state).toMatchObject({ a: 1, b: 2 })
})
it('sets, undoes, redoes', () => {
manager.mark()
setA(1)
manager.mark()
setB(1)
manager.mark()
setB(2)
setB(3)
setB(4)
expect(state).toMatchObject({ a: 1, b: 4 })
manager.undo()
expect(state).toMatchObject({ a: 1, b: 1 })
manager.redo()
expect(state).toMatchObject({ a: 1, b: 4 })
})
it('sets ephemeral, undoes, redos', () => {
manager.mark()
setA(1)
manager.mark()
setB(1) // B 0->1
manager.mark()
setB(2, { ephemeral: true }) // B 0->2, but ephemeral
expect(state).toMatchObject({ a: 1, b: 2 })
manager.undo() // undoes B 2->0
expect(state).toMatchObject({ a: 1, b: 0 })
manager.redo() // redoes B 0->1, but not B 1-> 2
expect(state).toMatchObject({ a: 1, b: 1 }) // no change, b 1->2 was ephemeral
})
it('sets squashing, undoes, redos', () => {
manager.mark()
setA(1)
manager.mark()
setB(1)
setB(2, { squashing: true }) // squashes with the previous command
setB(3, { squashing: true }) // squashes with the previous command
expect(state).toMatchObject({ a: 1, b: 3 })
manager.undo()
expect(state).toMatchObject({ a: 1, b: 0 })
manager.redo()
expect(state).toMatchObject({ a: 1, b: 3 })
})
it('sets squashing and ephemeral, undoes, redos', () => {
manager.mark()
setA(1)
manager.mark()
setB(1)
setB(2, { squashing: true }) // squashes with the previous command
setB(3, { squashing: true, ephemeral: true }) // squashes with the previous command
expect(state).toMatchObject({ a: 1, b: 3 })
manager.undo()
expect(state).toMatchObject({ a: 1, b: 0 })
manager.redo()
expect(state).toMatchObject({ a: 1, b: 2 }) // B2->3 was ephemeral
})
})

View file

@ -4,13 +4,26 @@ import { uniqueId } from '../../utils/uniqueId'
import { TLCommandHandler, TLHistoryEntry } from '../types/history-types'
import { Stack, stack } from './Stack'
/** @public */
export type CommandHistoryOptions = Partial<{
/**
* When true, this command will be squashed with the previous command in the undo / redo stack.
*/
squashing: boolean
/**
* When true, this command will not add anything to the undo / redo stack. Its change will never be undone or redone.
*/
ephemeral: boolean
/**
* When true, adding this this command will not clear out the redo stack.
*/
preservesRedoStack: boolean
}>
type CommandFn<Data> = (...args: any[]) =>
| {
| ({
data: Data
squashing?: boolean
ephemeral?: boolean
preservesRedoStack?: boolean
}
} & CommandHistoryOptions)
| null
| undefined
| void

View file

@ -1,13 +1,15 @@
import { TLArrowShapeArrowheadStyle } from '@tldraw/tlschema'
import { VecLike } from '../../../../primitives/Vec2d'
export type ArrowPoint = {
/** @public */
export type TLArrowPoint = {
handle: VecLike
point: VecLike
arrowhead: TLArrowShapeArrowheadStyle
}
export interface ArcInfo {
/** @public */
export interface TLArcInfo {
center: VecLike
radius: number
size: number
@ -16,20 +18,21 @@ export interface ArcInfo {
sweepFlag: number
}
export type ArrowInfo =
/** @public */
export type TLArrowInfo =
| {
isStraight: false
start: ArrowPoint
end: ArrowPoint
start: TLArrowPoint
end: TLArrowPoint
middle: VecLike
handleArc: ArcInfo
bodyArc: ArcInfo
handleArc: TLArcInfo
bodyArc: TLArcInfo
isValid: boolean
}
| {
isStraight: true
start: ArrowPoint
end: ArrowPoint
start: TLArrowPoint
end: TLArrowPoint
middle: VecLike
isValid: boolean
length: number

View file

@ -1,7 +1,7 @@
import { Vec2d, VecLike } from '../../../../primitives/Vec2d'
import { intersectCircleCircle } from '../../../../primitives/intersect'
import { PI, TAU } from '../../../../primitives/utils'
import { ArrowInfo } from './arrow-types'
import { TLArrowInfo } from './arrow-types'
type TLArrowPointsInfo = {
point: VecLike
@ -9,7 +9,7 @@ type TLArrowPointsInfo = {
}
function getArrowPoints(
info: ArrowInfo,
info: TLArrowInfo,
side: 'start' | 'end',
strokeWidth: number
): TLArrowPointsInfo {
@ -110,7 +110,7 @@ export function getPipeHead() {
/** @public */
export function getArrowheadPathForType(
info: ArrowInfo,
info: TLArrowInfo,
side: 'start' | 'end',
strokeWidth: number
): string | undefined {

View file

@ -13,7 +13,7 @@ import {
shortAngleDist,
} from '../../../../primitives/utils'
import type { Editor } from '../../../Editor'
import { ArcInfo, ArrowInfo } from './arrow-types'
import { TLArcInfo, TLArrowInfo } from './arrow-types'
import {
BOUND_ARROW_OFFSET,
MIN_ARROW_LENGTH,
@ -24,7 +24,11 @@ import {
} from './shared'
import { getStraightArrowInfo } from './straight-arrow'
export function getCurvedArrowInfo(editor: Editor, shape: TLArrowShape, extraBend = 0): ArrowInfo {
export function getCurvedArrowInfo(
editor: Editor,
shape: TLArrowShape,
extraBend = 0
): TLArrowInfo {
const { arrowheadEnd, arrowheadStart } = shape.props
const bend = shape.props.bend + extraBend
@ -291,7 +295,7 @@ export function getCurvedArrowInfo(editor: Editor, shape: TLArrowShape, extraBen
* @param info - The arrow info.
* @public
*/
export function getCurvedArrowHandlePath(info: ArrowInfo & { isStraight: false }) {
export function getCurvedArrowHandlePath(info: TLArrowInfo & { isStraight: false }) {
const {
start,
end,
@ -306,7 +310,7 @@ export function getCurvedArrowHandlePath(info: ArrowInfo & { isStraight: false }
* @param info - The arrow info.
* @public
*/
export function getSolidCurvedArrowPath(info: ArrowInfo & { isStraight: false }) {
export function getSolidCurvedArrowPath(info: TLArrowInfo & { isStraight: false }) {
const {
start,
end,
@ -373,7 +377,7 @@ export function getArcBoundingBox(center: VecLike, radius: number, start: VecLik
* @param b - The end of the arc
* @param c - A point on the arc
*/
export function getArcInfo(a: VecLike, b: VecLike, c: VecLike): ArcInfo {
export function getArcInfo(a: VecLike, b: VecLike, c: VecLike): TLArcInfo {
// find a circle from the three points
const u = -2 * (a.x * (b.y - c.y) - a.y * (b.x - c.x) + b.x * c.y - c.x * b.y)

View file

@ -7,7 +7,7 @@ import {
intersectLineSegmentPolyline,
} from '../../../../primitives/intersect'
import { Editor } from '../../../Editor'
import { ArrowInfo } from './arrow-types'
import { TLArrowInfo } from './arrow-types'
import {
BOUND_ARROW_OFFSET,
BoundShapeInfo,
@ -17,7 +17,7 @@ import {
getBoundShapeInfoForTerminal,
} from './shared'
export function getStraightArrowInfo(editor: Editor, shape: TLArrowShape): ArrowInfo {
export function getStraightArrowInfo(editor: Editor, shape: TLArrowShape): TLArrowInfo {
const { start, end, arrowheadStart, arrowheadEnd } = shape.props
const terminalsInArrowSpace = getArrowTerminalsInArrowSpace(editor, shape)
@ -204,12 +204,12 @@ function updateArrowheadPointWithBoundShape(
}
/** @public */
export function getStraightArrowHandlePath(info: ArrowInfo & { isStraight: true }) {
export function getStraightArrowHandlePath(info: TLArrowInfo & { isStraight: true }) {
return getArrowPath(info.start.handle, info.end.handle)
}
/** @public */
export function getSolidStraightArrowPath(info: ArrowInfo & { isStraight: true }) {
export function getSolidStraightArrowPath(info: TLArrowInfo & { isStraight: true }) {
return getArrowPath(info.start.point, info.end.point)
}

View file

@ -9,7 +9,7 @@ export class Idle extends StateNode {
}
override onEnter = () => {
this.editor.updateInstanceState({ cursor: { type: 'cross', rotation: 0 } }, true)
this.editor.setCursor({ type: 'cross', rotation: 0 })
}
override onCancel = () => {

View file

@ -8,7 +8,7 @@ export class Idle extends StateNode {
}
override onEnter = () => {
this.editor.updateInstanceState({ cursor: { type: 'cross', rotation: 0 } }, true)
this.editor.setCursor({ type: 'cross', rotation: 0 })
}
override onCancel = () => {

View file

@ -80,7 +80,8 @@ export class Pointing extends StateNode {
const id = createShapeId()
this.markId = this.editor.mark(`creating:${id}`)
this.markId = `creating:${id}`
this.editor.mark(this.markId)
this.editor.createShapes<TLArrowShape>([
{
@ -109,7 +110,7 @@ export class Pointing extends StateNode {
if (startTerminal?.type === 'binding') {
this.editor.setHintingIds([startTerminal.boundShapeId])
}
this.editor.updateShapes([change], true)
this.editor.updateShapes([change], { squashing: true })
}
// Cache the current shape after those changes
@ -139,7 +140,7 @@ export class Pointing extends StateNode {
if (endTerminal?.type === 'binding') {
this.editor.setHintingIds([endTerminal.boundShapeId])
}
this.editor.updateShapes([change], true)
this.editor.updateShapes([change], { squashing: true })
}
}
@ -153,7 +154,7 @@ export class Pointing extends StateNode {
})
if (change) {
this.editor.updateShapes([change], true)
this.editor.updateShapes([change], { squashing: true })
}
}

View file

@ -364,7 +364,9 @@ export class Drawing extends StateNode {
)
}
this.editor.updateShapes<TLDrawShape | TLHighlightShape>([shapePartial], true)
this.editor.updateShapes<TLDrawShape | TLHighlightShape>([shapePartial], {
squashing: true,
})
}
break
}
@ -424,7 +426,7 @@ export class Drawing extends StateNode {
)
}
this.editor.updateShapes([shapePartial], true)
this.editor.updateShapes([shapePartial], { squashing: true })
}
break
@ -566,7 +568,7 @@ export class Drawing extends StateNode {
)
}
this.editor.updateShapes([shapePartial], true)
this.editor.updateShapes([shapePartial], { squashing: true })
break
}
@ -611,7 +613,7 @@ export class Drawing extends StateNode {
)
}
this.editor.updateShapes([shapePartial], true)
this.editor.updateShapes([shapePartial], { squashing: true })
// Set a maximum length for the lines array; after 200 points, complete the line.
if (newPoints.length > 500) {

View file

@ -8,7 +8,7 @@ export class Idle extends StateNode {
}
override onEnter = () => {
this.editor.updateInstanceState({ cursor: { type: 'cross', rotation: 0 } }, true)
this.editor.setCursor({ type: 'cross', rotation: 0 })
}
override onCancel = () => {

View file

@ -38,7 +38,7 @@ export const FrameLabelInput = forwardRef<
props: { name: value },
},
],
true
{ squashing: true }
)
},
[id, editor]
@ -61,7 +61,7 @@ export const FrameLabelInput = forwardRef<
props: { name: value },
},
],
true
{ squashing: true }
)
},
[id, editor]

View file

@ -8,7 +8,7 @@ export class Idle extends StateNode {
}
override onEnter = () => {
this.editor.updateInstanceState({ cursor: { type: 'cross', rotation: 0 } }, true)
this.editor.setCursor({ type: 'cross', rotation: 0 })
}
override onKeyUp: TLEventHandlers['onKeyUp'] = (info) => {

View file

@ -157,7 +157,7 @@ describe('Misc', () => {
y: 150,
})
editor.nudgeShapes(editor.selectedShapeIds, { x: 0, y: 10 }, true)
editor.nudgeShapes(editor.selectedShapeIds, { x: 0, y: 10 })
editor.expectShapeToMatch({
id: id,

View file

@ -7,7 +7,7 @@ export class Idle extends StateNode {
override onEnter = (info: { shapeId: TLShapeId }) => {
this.shapeId = info.shapeId
this.editor.updateInstanceState({ cursor: { type: 'cross', rotation: 0 } }, true)
this.editor.setCursor({ type: 'cross', rotation: 0 })
}
override onPointerDown: TLEventHandlers['onPointerDown'] = () => {

View file

@ -30,7 +30,8 @@ export class Pointing extends StateNode {
const shape = info.shapeId && this.editor.getShape<TLLineShape>(info.shapeId)
if (shape) {
this.markId = this.editor.mark(`creating:${shape.id}`)
this.markId = `creating:${shape.id}`
this.editor.mark(this.markId)
this.shape = shape
if (inputs.shiftKey) {
@ -85,7 +86,8 @@ export class Pointing extends StateNode {
} else {
const id = createShapeId()
this.markId = this.editor.mark(`creating:${id}`)
this.markId = `creating:${id}`
this.editor.mark(this.markId)
this.editor.createShapes<TLLineShape>([
{

View file

@ -8,7 +8,7 @@ export class Idle extends StateNode {
}
override onEnter = () => {
this.editor.updateInstanceState({ cursor: { type: 'cross', rotation: 0 } }, true)
this.editor.setCursor({ type: 'cross', rotation: 0 })
}
override onCancel = () => {

View file

@ -38,7 +38,7 @@ export class Idle extends StateNode {
}
override onEnter = () => {
this.editor.updateInstanceState({ cursor: { type: 'cross', rotation: 0 } }, true)
this.editor.setCursor({ type: 'cross', rotation: 0 })
}
override onKeyDown: TLEventHandlers['onKeyDown'] = (info) => {

View file

@ -19,7 +19,8 @@ export class Pointing extends StateNode {
const id = createShapeId()
this.markId = this.editor.mark(`creating:${id}`)
this.markId = `creating:${id}`
this.editor.mark(this.markId)
this.editor.createShapes<TLTextShape>([
{

View file

@ -10,6 +10,6 @@ export class EraserTool extends StateNode {
static override children = () => [Idle, Pointing, Erasing]
override onEnter = () => {
this.editor.updateInstanceState({ cursor: { type: 'cross', rotation: 0 } }, true)
this.editor.setCursor({ type: 'cross', rotation: 0 })
}
}

View file

@ -20,7 +20,8 @@ export class Erasing extends StateNode {
private excludedShapeIds = new Set<TLShapeId>()
override onEnter = (info: TLPointerEventInfo) => {
this.markId = this.editor.mark('erase scribble begin')
this.markId = 'erase scribble begin'
this.editor.mark(this.markId)
this.info = info
const { originPagePoint } = this.editor.inputs

View file

@ -4,7 +4,7 @@ export class Idle extends StateNode {
static override id = 'idle'
override onEnter = () => {
this.editor.updateInstanceState({ cursor: { type: 'grab', rotation: 0 } }, true)
this.editor.setCursor({ type: 'grab', rotation: 0 })
}
override onPointerDown: TLEventHandlers['onPointerDown'] = (info) => {

View file

@ -5,7 +5,10 @@ export class Pointing extends StateNode {
override onEnter = () => {
this.editor.stopCameraAnimation()
this.editor.updateInstanceState({ cursor: { type: 'grabbing', rotation: 0 } }, true)
this.editor.updateInstanceState(
{ cursor: { type: 'grabbing', rotation: 0 } },
{ ephemeral: true }
)
}
override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => {

View file

@ -9,6 +9,6 @@ export class LaserTool extends StateNode {
static override children = () => [Idle, Lasering]
override onEnter = () => {
this.editor.updateInstanceState({ cursor: { type: 'cross', rotation: 0 } }, true)
this.editor.setCursor({ type: 'cross', rotation: 0 })
}
}

View file

@ -72,7 +72,7 @@ export class Brushing extends StateNode {
}
override onCancel?: TLCancelEvent | undefined = (info) => {
this.editor.setSelectedShapeIds(this.initialSelectedShapeIds, true)
this.editor.setSelectedShapeIds(this.initialSelectedShapeIds, { squashing: true })
this.parent.transition('idle', info)
}
@ -168,7 +168,7 @@ export class Brushing extends StateNode {
}
this.editor.updateInstanceState({ brush: { ...this.brush.toJson() } })
this.editor.setSelectedShapeIds(Array.from(results), true)
this.editor.setSelectedShapeIds(Array.from(results), { squashing: true })
}
override onInterrupt: TLInterruptEvent = () => {

View file

@ -5,7 +5,10 @@ export class Idle extends StateNode {
static override id = 'idle'
override onEnter = () => {
this.editor.updateInstanceState({ cursor: { type: 'default', rotation: 0 } }, true)
this.editor.updateInstanceState(
{ cursor: { type: 'default', rotation: 0 } },
{ ephemeral: true }
)
const { onlySelectedShape } = this.editor
@ -22,7 +25,10 @@ export class Idle extends StateNode {
}
override onExit: TLExitEventHandler = () => {
this.editor.updateInstanceState({ cursor: { type: 'default', rotation: 0 } }, true)
this.editor.updateInstanceState(
{ cursor: { type: 'default', rotation: 0 } },
{ ephemeral: true }
)
this.editor.off('change-history', this.cleanupCroppingState)
}

View file

@ -27,12 +27,15 @@ export class TranslatingCrop extends StateNode {
this.snapshot = this.createSnapshot()
this.editor.mark(this.markId)
this.editor.updateInstanceState({ cursor: { type: 'move', rotation: 0 } }, true)
this.editor.setCursor({ type: 'move', rotation: 0 })
this.updateShapes()
}
override onExit = () => {
this.editor.updateInstanceState({ cursor: { type: 'default', rotation: 0 } }, true)
this.editor.updateInstanceState(
{ cursor: { type: 'default', rotation: 0 } },
{ ephemeral: true }
)
}
override onPointerMove = () => {
@ -99,7 +102,7 @@ export class TranslatingCrop extends StateNode {
const partial = getTranslateCroppedImageChange(this.editor, shape, delta)
if (partial) {
this.editor.updateShapes([partial], true)
this.editor.updateShapes([partial], { squashing: true })
}
}
}

View file

@ -37,7 +37,8 @@ export class Cropping extends StateNode {
}
) => {
this.info = info
this.markId = this.editor.mark('cropping')
this.markId = 'cropping'
this.editor.mark(this.markId)
this.snapshot = this.createSnapshot()
this.updateShapes()
}
@ -199,7 +200,7 @@ export class Cropping extends StateNode {
},
}
this.editor.updateShapes([partial], true)
this.editor.updateShapes([partial], { squashing: true })
this.updateCursor()
}

View file

@ -52,7 +52,8 @@ export class DraggingHandle extends StateNode {
this.info = info
this.parent.currentToolIdMask = info.onInteractionEnd
this.shapeId = shape.id
this.markId = isCreating ? `creating:${shape.id}` : this.editor.mark('dragging handle')
this.markId = isCreating ? `creating:${shape.id}` : 'dragging handle'
if (!isCreating) this.editor.mark(this.markId)
this.initialHandle = deepCopy(handle)
this.initialPageTransform = this.editor.getShapePageTransform(shape)!
this.initialPageRotation = this.initialPageTransform.rotation()
@ -60,7 +61,7 @@ export class DraggingHandle extends StateNode {
this.editor.updateInstanceState(
{ cursor: { type: isCreating ? 'cross' : 'grabbing', rotation: 0 } },
true
{ ephemeral: true }
)
// <!-- Only relevant to arrows
@ -168,7 +169,10 @@ export class DraggingHandle extends StateNode {
this.parent.currentToolIdMask = undefined
this.editor.setHintingIds([])
this.editor.snaps.clear()
this.editor.updateInstanceState({ cursor: { type: 'default', rotation: 0 } }, true)
this.editor.updateInstanceState(
{ cursor: { type: 'default', rotation: 0 } },
{ ephemeral: true }
)
}
private complete() {
@ -298,7 +302,7 @@ export class DraggingHandle extends StateNode {
}
if (changes) {
editor.updateShapes([next], true)
editor.updateShapes([next], { squashing: true })
}
}
}

View file

@ -23,7 +23,10 @@ export class Idle extends StateNode {
override onEnter = () => {
this.parent.currentToolIdMask = undefined
this.editor.updateInstanceState({ cursor: { type: 'default', rotation: 0 } }, true)
this.editor.updateInstanceState(
{ cursor: { type: 'default', rotation: 0 } },
{ ephemeral: true }
)
}
override onPointerMove: TLEventHandlers['onPointerMove'] = () => {

View file

@ -33,7 +33,10 @@ export class PointingCropHandle extends StateNode {
}
override onExit = () => {
this.editor.updateInstanceState({ cursor: { type: 'default', rotation: 0 } }, true)
this.editor.updateInstanceState(
{ cursor: { type: 'default', rotation: 0 } },
{ ephemeral: true }
)
this.parent.currentToolIdMask = undefined
}

View file

@ -14,12 +14,18 @@ export class PointingHandle extends StateNode {
this.editor.setHintingIds([initialTerminal.boundShapeId])
}
this.editor.updateInstanceState({ cursor: { type: 'grabbing', rotation: 0 } }, true)
this.editor.updateInstanceState(
{ cursor: { type: 'grabbing', rotation: 0 } },
{ ephemeral: true }
)
}
override onExit = () => {
this.editor.setHintingIds([])
this.editor.updateInstanceState({ cursor: { type: 'default', rotation: 0 } }, true)
this.editor.updateInstanceState(
{ cursor: { type: 'default', rotation: 0 } },
{ ephemeral: true }
)
}
override onPointerUp: TLEventHandlers['onPointerUp'] = () => {

View file

@ -28,7 +28,10 @@ export class PointingRotateHandle extends StateNode {
override onExit = () => {
this.parent.currentToolIdMask = undefined
this.editor.updateInstanceState({ cursor: { type: 'default', rotation: 0 } }, true)
this.editor.updateInstanceState(
{ cursor: { type: 'default', rotation: 0 } },
{ ephemeral: true }
)
}
override onPointerMove = () => {

View file

@ -56,13 +56,16 @@ export class Resizing extends StateNode {
this.creationCursorOffset = creationCursorOffset
if (info.isCreating) {
this.editor.updateInstanceState({ cursor: { type: 'cross', rotation: 0 } }, true)
this.editor.updateInstanceState(
{ cursor: { type: 'cross', rotation: 0 } },
{ ephemeral: true }
)
}
this.snapshot = this._createSnapshot()
this.markId = isCreating
? `creating:${this.editor.onlySelectedShape!.id}`
: this.editor.mark('starting resizing')
this.markId = isCreating ? `creating:${this.editor.onlySelectedShape!.id}` : 'starting resizing'
if (!isCreating) this.editor.mark(this.markId)
this.handleResizeStart()
this.updateShapes()
@ -349,12 +352,15 @@ export class Resizing extends StateNode {
nextCursor.rotation = rotation
this.editor.updateInstanceState({ cursor: nextCursor })
this.editor.setCursor(nextCursor)
}
override onExit = () => {
this.parent.currentToolIdMask = undefined
this.editor.updateInstanceState({ cursor: { type: 'default', rotation: 0 } }, true)
this.editor.updateInstanceState(
{ cursor: { type: 'default', rotation: 0 } },
{ ephemeral: true }
)
this.editor.snaps.clear()
}

View file

@ -29,7 +29,8 @@ export class Rotating extends StateNode {
this.info = info
this.parent.currentToolIdMask = info.onInteractionEnd
this.markId = this.editor.mark('rotate start')
this.markId = 'rotate start'
this.editor.mark(this.markId)
const snapshot = getRotationSnapshot({ editor: this.editor })
if (!snapshot) return this.parent.transition('idle', this.info)
@ -40,7 +41,7 @@ export class Rotating extends StateNode {
}
override onExit = () => {
this.editor.updateInstanceState({ cursor: { type: 'none', rotation: 0 } }, true)
this.editor.setCursor({ type: 'default', rotation: 0 })
this.parent.currentToolIdMask = undefined
this.snapshot = {} as TLRotationSnapshot

View file

@ -170,7 +170,7 @@ export class ScribbleBrushing extends StateNode {
: [...newlySelectedShapeIds]
),
],
true
{ squashing: true }
)
}
@ -179,7 +179,7 @@ export class ScribbleBrushing extends StateNode {
}
private cancel() {
this.editor.setSelectedShapeIds([...this.initialSelectedShapeIds], true)
this.editor.setSelectedShapeIds([...this.initialSelectedShapeIds], { squashing: true })
this.parent.transition('idle', {})
}
}

View file

@ -53,9 +53,8 @@ export class Translating extends StateNode {
this.isCreating = isCreating
this.editAfterComplete = editAfterComplete
this.markId = isCreating
? this.editor.mark(`creating:${this.editor.onlySelectedShape!.id}`)
: this.editor.mark('translating')
this.markId = isCreating ? `creating:${this.editor.onlySelectedShape!.id}` : 'translating'
this.editor.mark(this.markId)
this.handleEnter(info)
this.editor.on('tick', this.updateParent)
}
@ -66,7 +65,10 @@ export class Translating extends StateNode {
this.selectionSnapshot = {} as any
this.snapshot = {} as any
this.editor.snaps.clear()
this.editor.updateInstanceState({ cursor: { type: 'default', rotation: 0 } }, true)
this.editor.updateInstanceState(
{ cursor: { type: 'default', rotation: 0 } },
{ ephemeral: true }
)
this.dragAndDropManager.clear()
}
@ -111,7 +113,8 @@ export class Translating extends StateNode {
this.isCloning = true
this.reset()
this.markId = this.editor.mark('translating')
this.markId = 'translating'
this.editor.mark(this.markId)
this.editor.duplicateShapes(Array.from(this.editor.selectedShapeIds))
@ -124,7 +127,8 @@ export class Translating extends StateNode {
this.isCloning = false
this.snapshot = this.selectionSnapshot
this.reset()
this.markId = this.editor.mark('translating')
this.markId = 'translating'
this.editor.mark(this.markId)
this.updateShapes()
}
@ -171,7 +175,7 @@ export class Translating extends StateNode {
this.isCloning = false
this.info = info
this.editor.updateInstanceState({ cursor: { type: 'move', rotation: 0 } }, true)
this.editor.setCursor({ type: 'move', rotation: 0 })
this.selectionSnapshot = getTranslatingSnapshot(this.editor)
// Don't clone on create; otherwise clone on altKey
@ -406,6 +410,6 @@ export function moveShapesToPoint({
}
})
),
true
{ squashing: true }
)
}

View file

@ -21,7 +21,7 @@ export class ZoomTool extends StateNode {
this.currentToolIdMask = undefined
this.editor.updateInstanceState(
{ zoomBrush: null, cursor: { type: 'default', rotation: 0 } },
true
{ ephemeral: true }
)
this.currentToolIdMask = undefined
}
@ -53,9 +53,15 @@ export class ZoomTool extends StateNode {
private updateCursor() {
if (this.editor.inputs.altKey) {
this.editor.updateInstanceState({ cursor: { type: 'zoom-out', rotation: 0 } }, true)
this.editor.updateInstanceState(
{ cursor: { type: 'zoom-out', rotation: 0 } },
{ ephemeral: true }
)
} else {
this.editor.updateInstanceState({ cursor: { type: 'zoom-in', rotation: 0 } }, true)
this.editor.updateInstanceState(
{ cursor: { type: 'zoom-in', rotation: 0 } },
{ ephemeral: true }
)
}
}
}

View file

@ -77,13 +77,11 @@ export const MoveToPageMenu = track(function MoveToPageMenu() {
<_ContextMenu.Item
key="new-page"
onSelect={() => {
editor.mark('move_shapes_to_page')
const newPageId = PageRecordType.createId()
const ids = editor.selectedShapeIds
const oldPageId = editor.currentPageId
editor.batch(() => {
editor.createPage('Page 1', newPageId)
editor.setCurrentPage(oldPageId)
editor.mark('move_shapes_to_page')
editor.createPage({ name: 'Page', id: newPageId })
editor.moveShapesToPage(ids, newPageId)
})
}}

View file

@ -17,7 +17,7 @@ export const PageItemInput = function PageItemInput({
const handleChange = useCallback(
(value: string) => {
editor.renamePage(id, value ? value : 'New Page', true)
editor.renamePage(id, value ? value : 'New Page', { ephemeral: true })
},
[editor, id]
)
@ -25,7 +25,7 @@ export const PageItemInput = function PageItemInput({
const handleComplete = useCallback(
(value: string) => {
editor.mark('rename page')
editor.renamePage(id, value || 'New Page', false)
editor.renamePage(id, value || 'New Page', { ephemeral: false })
},
[editor, id]
)

View file

@ -240,10 +240,13 @@ export const PageMenu = function PageMenu() {
const handleCreatePageClick = useCallback(() => {
if (isReadonlyMode) return
editor.mark('creating page')
const newPageId = PageRecordType.createId()
editor.createPage(msg('page-menu.new-page-initial-name'), newPageId)
setIsEditing(true)
editor.batch(() => {
editor.mark('creating page')
const newPageId = PageRecordType.createId()
editor.createPage({ name: msg('page-menu.new-page-initial-name'), id: newPageId })
editor.setCurrentPage(newPageId)
setIsEditing(true)
})
}, [editor, msg, isReadonlyMode])
return (
@ -383,8 +386,10 @@ export const PageMenu = function PageMenu() {
editor.renamePage(page.id, name)
}
} else {
setIsEditing(true)
editor.setCurrentPage(page.id)
editor.batch(() => {
setIsEditing(true)
editor.setCurrentPage(page.id)
})
}
}}
/>

View file

@ -95,7 +95,7 @@ function useStyleChangeCallback() {
return React.useMemo(() => {
return function <T>(style: StyleProp<T>, value: T, squashing: boolean) {
editor.setStyle(style, value, squashing)
editor.setStyle(style, value, { squashing })
editor.updateInstanceState({ isChangingStyle: true })
}
}, [editor])
@ -118,7 +118,7 @@ function CommonStylePickerSet({
const handleOpacityValueChange = React.useCallback(
(value: number, ephemeral: boolean) => {
const item = tldrawSupportedOpacities[value]
editor.setOpacity(item, ephemeral)
editor.setOpacity(item, { ephemeral })
editor.updateInstanceState({ isChangingStyle: true })
},
[editor]

View file

@ -323,7 +323,7 @@ export async function pasteExcalidrawContent(editor: Editor, clipboard: any, poi
editor.mark('paste')
editor.putContent(tldrawContent, {
editor.putContentOntoCurrentPage(tldrawContent, {
point: p,
select: false,
preserveIds: true,

View file

@ -12,7 +12,7 @@ export function pasteTldrawContent(editor: Editor, clipboard: TLContent, point?:
const p = point ?? (editor.inputs.shiftKey ? editor.inputs.currentPagePoint : undefined)
editor.mark('paste')
editor.putContent(clipboard, {
editor.putContentOntoCurrentPage(clipboard, {
point: p,
select: true,
})

View file

@ -210,7 +210,7 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
readonlyOk: false,
onSelect(source) {
trackEvent('toggle-auto-size', { source })
editor.mark()
editor.mark('toggling auto size')
editor.updateShapes(
editor.selectedShapes
.filter(
@ -842,7 +842,7 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
{
exportBackground: !editor.instanceState.exportBackground,
},
true
{ ephemeral: true }
)
},
checkbox: true,
@ -902,7 +902,7 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
{
isDebugMode: !editor.instanceState.isDebugMode,
},
true
{ ephemeral: true }
)
},
checkbox: true,

View file

@ -514,7 +514,7 @@ async function handleClipboardThings(editor: Editor, things: ClipboardThing[], p
* @public
*/
const handleNativeOrMenuCopy = (editor: Editor) => {
const content = editor.getContent(editor.selectedShapeIds)
const content = editor.getContentFromCurrentPage(editor.selectedShapeIds)
if (!content) {
if (navigator && navigator.clipboard) {
navigator.clipboard.writeText('')

View file

@ -93,7 +93,7 @@ export function useCopyAs() {
}
case 'json': {
const data = editor.getContent(ids)
const data = editor.getContentFromCurrentPage(ids)
if (window.navigator.clipboard) {
const jsonStr = JSON.stringify(data)

View file

@ -79,7 +79,7 @@ export function useExportAs() {
}
case 'json': {
const data = editor.getContent(ids)
const data = editor.getContentFromCurrentPage(ids)
const dataURL = URL.createObjectURL(
new Blob([JSON.stringify(data, null, 4)], { type: 'application/json' })
)

View file

@ -111,7 +111,7 @@ export function ToolsProvider({ overrides, children }: TLUiToolsProviderProps) {
[GeoShapeGeoStyle.id]: id,
},
},
true
{ ephemeral: true }
)
editor.setCurrentTool('geo')
trackEvent('select-tool', { source, id: `geo-${id}` })

View file

@ -113,7 +113,7 @@ export function buildFromV1Document(editor: Editor, document: LegacyTldrawDocume
} else {
const pageId = PageRecordType.createId()
v1PageIdsToV2PageIds.set(v1Page.id, pageId)
editor.createPage(v1Page.name ?? 'Page', pageId)
editor.createPage({ name: v1Page.name ?? 'Page', id: pageId })
}
})

View file

@ -29,7 +29,7 @@ beforeEach(() => {
])
const page1 = editor.currentPageId
editor.createPage('page 2', ids.page2)
editor.createPage({ name: 'page 2', id: ids.page2 })
editor.setCurrentPage(page1)
})
@ -429,12 +429,12 @@ describe('isFocused', () => {
expect(focusMock).not.toHaveBeenCalled()
expect(blurMock).not.toHaveBeenCalled()
editor.focus()
editor.getContainer().focus()
expect(focusMock).toHaveBeenCalled()
expect(blurMock).not.toHaveBeenCalled()
editor.blur()
editor.getContainer().blur()
expect(blurMock).toHaveBeenCalled()
})
@ -471,7 +471,7 @@ describe('getShapeUtil', () => {
{ id: ids.box1, type: 'blorg', x: 100, y: 100, props: { w: 100, h: 100 } },
])
const page1 = editor.currentPageId
editor.createPage('page 2', ids.page2)
editor.createPage({ name: 'page 2', id: ids.page2 })
editor.setCurrentPage(page1)
})

View file

@ -57,7 +57,7 @@ describe('createSessionStateSnapshotSignal', () => {
expect(isGridMode).toBe(true)
expect(numPages).toBe(1)
editor.createPage('new page')
editor.createPage({ name: 'new page' })
expect(isGridMode).toBe(true)
expect(editor.pages.length).toBe(2)

View file

@ -136,7 +136,7 @@ export class TestEditor extends Editor {
copy = (ids = this.selectedShapeIds) => {
if (ids.length > 0) {
const content = this.getContent(ids)
const content = this.getContentFromCurrentPage(ids)
if (content) {
this.clipboard = content
}
@ -146,7 +146,7 @@ export class TestEditor extends Editor {
cut = (ids = this.selectedShapeIds) => {
if (ids.length > 0) {
const content = this.getContent(ids)
const content = this.getContentFromCurrentPage(ids)
if (content) {
this.clipboard = content
}
@ -160,7 +160,7 @@ export class TestEditor extends Editor {
const p = this.inputs.shiftKey ? this.inputs.currentPagePoint : point
this.mark('pasting')
this.putContent(this.clipboard, {
this.putContentOntoCurrentPage(this.clipboard, {
point: p,
select: true,
})

View file

@ -219,7 +219,10 @@ describe('<TldrawEditor />', () => {
expect(editor).toBeTruthy()
await act(async () => {
editor.updateInstanceState({ screenBounds: { x: 0, y: 0, w: 1080, h: 720 } }, true, true)
editor.updateInstanceState(
{ screenBounds: { x: 0, y: 0, w: 1080, h: 720 } },
{ ephemeral: true, squashing: true }
)
})
const id = createShapeId()
@ -340,7 +343,10 @@ describe('Custom shapes', () => {
expect(editor).toBeTruthy()
await act(async () => {
editor.updateInstanceState({ screenBounds: { x: 0, y: 0, w: 1080, h: 720 } }, true, true)
editor.updateInstanceState(
{ screenBounds: { x: 0, y: 0, w: 1080, h: 720 } },
{ ephemeral: true, squashing: true }
)
})
expect(editor.shapeUtils.card).toBeTruthy()

View file

@ -248,7 +248,7 @@ describe('arrowBindingsIndex', () => {
expect(editor.getArrowsBoundTo(ids.box2)).toHaveLength(3)
expect(editor.getArrowsBoundTo(ids.box1)).toHaveLength(3)
editor.nudgeShapes([ids.box2], { x: 0, y: -1 }, true)
editor.nudgeShapes([ids.box2], { x: 0, y: -1 })
expect(editor.getArrowsBoundTo(ids.box2)).toHaveLength(3)
expect(editor.getArrowsBoundTo(ids.box1)).toHaveLength(3)

View file

@ -0,0 +1,129 @@
import { TLArrowShape, createShapeId } from '@tldraw/editor'
import { TestEditor } from './TestEditor'
let editor: TestEditor
const ids = {
box1: createShapeId('box1'),
box2: createShapeId('box2'),
box3: createShapeId('box3'),
box4: createShapeId('box4'),
box5: createShapeId('box5'),
frame1: createShapeId('frame1'),
group1: createShapeId('group1'),
group2: createShapeId('group2'),
group3: createShapeId('group3'),
arrow1: createShapeId('arrow1'),
arrow2: createShapeId('arrow2'),
arrow3: createShapeId('arrow3'),
}
beforeEach(() => {
editor = new TestEditor()
})
function arrow() {
return editor.currentPageShapes.find((s) => s.type === 'arrow') as TLArrowShape
}
describe('restoring bound arrows', () => {
beforeEach(() => {
editor.createShapes([
{ id: ids.box1, type: 'geo', x: 0, y: 0 },
{ id: ids.box2, type: 'geo', x: 200, y: 0 },
])
// create arrow from box1 to box2
editor
.setCurrentTool('arrow')
.pointerMove(50, 50)
.pointerDown()
.pointerMove(250, 50)
.pointerUp()
})
it('removes bound arrows on delete, restores them on undo but only when change was done by user', () => {
editor.mark('deleting')
editor.deleteShapes([ids.box2])
expect(arrow().props.end.type).toBe('point')
editor.undo()
expect(arrow().props.end.type).toBe('binding')
editor.redo()
expect(arrow().props.end.type).toBe('point')
})
it('removes / restores multiple bindings', () => {
editor.mark('deleting')
expect(arrow().props.start.type).toBe('binding')
expect(arrow().props.end.type).toBe('binding')
editor.deleteShapes([ids.box1, ids.box2])
expect(arrow().props.start.type).toBe('point')
expect(arrow().props.end.type).toBe('point')
editor.undo()
expect(arrow().props.start.type).toBe('binding')
expect(arrow().props.end.type).toBe('binding')
editor.redo()
expect(arrow().props.start.type).toBe('point')
expect(arrow().props.end.type).toBe('point')
})
})
describe('restoring bound arrows multiplayer', () => {
it('restores bound arrows after the shape was deleted by a different client', () => {
editor.mark('before creating box shape')
editor.createShapes([{ id: ids.box2, type: 'geo', x: 100, y: 0 }])
editor.setCurrentTool('arrow').pointerMove(0, 50).pointerDown().pointerMove(150, 50).pointerUp()
// console.log(JSON.stringify(editor.history._undos.value.toArray(), null, 2))
expect(arrow().props.start.type).toBe('point')
expect(arrow().props.end.type).toBe('binding')
// Merge a change from a remote source that deletes box 2
editor.store.mergeRemoteChanges(() => {
editor.store.remove([ids.box2])
})
// box is gone
expect(editor.getShape(ids.box2)).toBeUndefined()
// arrow is still there, but without its binding
expect(arrow()).not.toBeUndefined()
expect(arrow().props.start.type).toBe('point')
expect(arrow().props.end.type).toBe('point')
editor.undo() // undo creating the arrow
// arrow is gone too now
expect(editor.currentPageShapeIds.size).toBe(0)
editor.redo() // redo creating the arrow
expect(editor.getShape(ids.box2)).toBeUndefined()
expect(arrow()).not.toBeUndefined()
expect(arrow().props.start.type).toBe('point')
expect(arrow().props.end.type).toBe('point')
editor.undo() // undo creating arrow
expect(editor.currentPageShapeIds.size).toBe(0)
editor.undo() // undo creating box
expect(editor.currentPageShapeIds.size).toBe(0)
editor.redo() // redo creating box
// box is back! arrow is gone
expect(editor.getShape(ids.box2)).not.toBeUndefined()
expect(arrow()).toBeUndefined()
editor.redo() // redo creating arrow
// box is back! arrow should be bound
expect(arrow().props.start.type).toBe('point')
expect(arrow().props.end.type).toBe('binding')
})
})

View file

@ -10,13 +10,21 @@ beforeEach(() => {
it('Creates a page', () => {
const oldPageId = editor.currentPageId
const n = editor.pages.length
editor.createPage('Page 1')
editor.mark('creating new page')
editor.createPage({ name: 'Page 1' })
expect(editor.pages.length).toBe(n + 1)
const newPageId = editor.pages[n].id
// does not move to the new page right away
expect(editor.currentPageId).toBe(oldPageId)
// needs to be done manually
editor.setCurrentPage(newPageId)
expect(editor.currentPageId).toBe(newPageId)
editor.undo()
expect(editor.pages.length).toBe(n)
expect(editor.currentPageId).toBe(oldPageId)
editor.redo()
expect(editor.pages.length).toBe(n + 1)
expect(editor.currentPageId).toBe(newPageId)
@ -24,7 +32,7 @@ it('Creates a page', () => {
it("Doesn't create a page if max pages is reached", () => {
for (let i = 0; i < MAX_PAGES + 1; i++) {
editor.createPage(`Test Page ${i}`)
editor.createPage({ name: `Test Page ${i}` })
}
expect(editor.pages.length).toBe(MAX_PAGES)
})
@ -52,6 +60,6 @@ it('[regression] does not die if every page has the same index', () => {
expect(editor.pages.every((p) => p.index === page.index)).toBe(true)
editor.createPage('My Special Test Page')
editor.createPage({ name: 'My Special Test Page' })
expect(editor.pages.some((p) => p.name === 'My Special Test Page')).toBe(true)
})

View file

@ -10,7 +10,7 @@ beforeEach(() => {
describe('deletePage', () => {
it('deletes the page', () => {
const page2Id = PageRecordType.createId('page2')
editor.createPage('New Page 2', page2Id)
editor.createPage({ name: 'New Page 2', id: page2Id })
const pages = editor.pages
expect(pages.length).toBe(2)
@ -20,13 +20,13 @@ describe('deletePage', () => {
})
it('is undoable and redoable', () => {
const page2Id = PageRecordType.createId('page2')
editor.mark()
editor.createPage('New Page 2', page2Id)
editor.mark('before creating page')
editor.createPage({ name: 'New Page 2', id: page2Id })
const pages = editor.pages
expect(pages.length).toBe(2)
editor.mark()
editor.mark('before deleting page')
editor.deletePage(pages[0].id)
expect(editor.pages.length).toBe(1)
@ -39,8 +39,8 @@ describe('deletePage', () => {
})
it('does not allow deleting all pages', () => {
const page2Id = PageRecordType.createId('page2')
editor.mark()
editor.createPage('New Page 2', page2Id)
editor.mark('before creating page')
editor.createPage({ name: 'New Page 2', id: page2Id })
const pages = editor.pages
editor.deletePage(pages[1].id)
@ -53,8 +53,8 @@ describe('deletePage', () => {
})
it('switches the page if you are deleting the current page', () => {
const page2Id = PageRecordType.createId('page2')
editor.mark()
editor.createPage('New Page 2', page2Id)
editor.mark('before creating page')
editor.createPage({ name: 'New Page 2', id: page2Id })
const currentPageId = editor.currentPageId
editor.deletePage(currentPageId)
@ -65,8 +65,8 @@ describe('deletePage', () => {
it('switches the page if another user or tab deletes the current page', () => {
const currentPageId = editor.currentPageId
const page2Id = PageRecordType.createId('page2')
editor.mark()
editor.createPage('New Page 2', page2Id)
editor.mark('before creating')
editor.createPage({ name: 'New Page 2', id: page2Id })
editor.store.mergeRemoteChanges(() => {
editor.store.remove([currentPageId])

View file

@ -49,7 +49,7 @@ beforeEach(() => {
describe('Editor.deleteShapes', () => {
it('Deletes a shape', () => {
editor.select(ids.box3, ids.box4)
editor.mark()
editor.mark('before deleting')
editor.deleteShapes(editor.selectedShapeIds) // delete the selected shapes
expect(editor.getShape(ids.box3)).toBeUndefined()
expect(editor.getShape(ids.box4)).toBeUndefined()
@ -74,7 +74,7 @@ describe('Editor.deleteShapes', () => {
it('Deletes descendants', () => {
editor.reparentShapes([ids.box4], ids.box3)
editor.select(ids.box3)
editor.mark()
editor.mark('before deleting')
editor.deleteShapes(editor.selectedShapeIds) // should be a noop, nothing to delete
expect(editor.getShape(ids.box3)).toBeUndefined()
expect(editor.getShape(ids.box4)).toBeUndefined()
@ -90,7 +90,7 @@ describe('Editor.deleteShapes', () => {
describe('When deleting arrows', () => {
it('Restores any bindings on undo', () => {
editor.select(ids.arrow1)
editor.mark()
editor.mark('before deleting')
// @ts-expect-error
expect(editor._arrowBindingsIndex.value[ids.box1]).not.toBeUndefined()
// @ts-expect-error

View file

@ -14,14 +14,18 @@ const ids = {
beforeEach(() => {
editor = new TestEditor()
editor.createPage(ids.page1, ids.page1)
const page0Id = editor.currentPageId
editor.createPage({ name: ids.page1, id: ids.page1 })
expect(editor.currentPageId).toBe(page0Id)
editor.setCurrentPage(ids.page1)
expect(editor.currentPageId).toBe(ids.page1)
editor.createShapes([
{ id: ids.ellipse1, type: 'geo', x: 0, y: 0, props: { geo: 'ellipse' } },
{ id: ids.box1, type: 'geo', x: 0, y: 0 },
{ id: ids.box2, parentId: ids.box1, type: 'geo', x: 150, y: 150 },
])
editor.createPage(ids.page2, ids.page2)
editor.setCurrentPage(ids.page1)
editor.createPage({ name: ids.page2, id: ids.page2 })
expect(editor.currentPageId).toBe(ids.page1)
expect(editor.getShape(ids.box1)!.parentId).toEqual(ids.page1)
expect(editor.getShape(ids.box2)!.parentId).toEqual(ids.box1)
@ -103,7 +107,8 @@ describe('Editor.moveShapesToPage', () => {
editor = new TestEditor()
const page2Id = PageRecordType.createId('newPage2')
editor.createPage('New Page 2', page2Id)
editor.createPage({ name: 'New Page 2', id: page2Id })
editor.setCurrentPage(page2Id)
expect(editor.currentPageId).toBe(page2Id)
editor.createShapes([{ id: ids.box1, type: 'geo', x: 0, y: 0, props: { geo: 'ellipse' } }])
editor.expectShapeToMatch({
@ -113,7 +118,9 @@ describe('Editor.moveShapesToPage', () => {
})
const page3Id = PageRecordType.createId('newPage3')
editor.createPage('New Page 3', page3Id)
editor.createPage({ name: 'New Page 3', id: page3Id })
editor.setCurrentPage(page3Id)
expect(editor.currentPageId).toBe(page3Id)
editor.createShapes([{ id: ids.box2, type: 'geo', x: 0, y: 0, props: { geo: 'ellipse' } }])
editor.expectShapeToMatch({

View file

@ -39,22 +39,22 @@ function nudgeAndGet(ids: TLShapeId[], key: string, shiftKey: boolean) {
switch (key) {
case 'ArrowLeft': {
editor.mark('nudge')
editor.nudgeShapes(editor.selectedShapeIds, { x: -step, y: 0 }, shiftKey)
editor.nudgeShapes(editor.selectedShapeIds, { x: -step, y: 0 })
break
}
case 'ArrowRight': {
editor.mark('nudge')
editor.nudgeShapes(editor.selectedShapeIds, { x: step, y: 0 }, shiftKey)
editor.nudgeShapes(editor.selectedShapeIds, { x: step, y: 0 })
break
}
case 'ArrowUp': {
editor.mark('nudge')
editor.nudgeShapes(editor.selectedShapeIds, { x: 0, y: -step }, shiftKey)
editor.nudgeShapes(editor.selectedShapeIds, { x: 0, y: -step })
break
}
case 'ArrowDown': {
editor.mark('nudge')
editor.nudgeShapes(editor.selectedShapeIds, { x: 0, y: step }, shiftKey)
editor.nudgeShapes(editor.selectedShapeIds, { x: 0, y: step })
break
}
}

View file

@ -16,17 +16,17 @@ describe('Migrations', () => {
const withoutSchema = structuredClone(clipboardContent)
// @ts-expect-error
delete withoutSchema.schema
expect(() => editor.putContent(withoutSchema)).toThrowError()
expect(() => editor.putContentOntoCurrentPage(withoutSchema)).toThrowError()
})
it('Does not throw error if content has a schema', () => {
expect(() => editor.putContent(clipboardContent)).not.toThrowError()
expect(() => editor.putContentOntoCurrentPage(clipboardContent)).not.toThrowError()
})
it('Throws error if any shape is invalid due to wrong type', () => {
const withInvalidShapeType = structuredClone(clipboardContent)
withInvalidShapeType.shapes[0].type = 'invalid'
expect(() => editor.putContent(withInvalidShapeType)).toThrowError()
expect(() => editor.putContentOntoCurrentPage(withInvalidShapeType)).toThrowError()
})
// we temporarily disabled validations
@ -34,6 +34,6 @@ describe('Migrations', () => {
const withInvalidShapeModel = structuredClone(clipboardContent)
// @ts-expect-error
withInvalidShapeModel.shapes[0].x = 'invalid'
expect(() => editor.putContent(withInvalidShapeModel)).toThrowError()
expect(() => editor.putContentOntoCurrentPage(withInvalidShapeModel)).toThrowError()
})
})

View file

@ -869,7 +869,7 @@ describe('When undoing and redoing...', () => {
ids['G']
)
editor.mark()
editor.mark('before sending to back')
editor.sendBackward([ids['F'], ids['G']])
expectShapesInOrder(

View file

@ -12,8 +12,12 @@ describe('setCurrentPage', () => {
const page1Id = editor.pages[0].id
const page2Id = PageRecordType.createId('page2')
editor.createPage('New Page 2', page2Id)
editor.createPage({ name: 'New Page 2', id: page2Id })
expect(editor.currentPageId).toBe(page1Id)
editor.setCurrentPage(page2Id)
expect(editor.currentPageId).toEqual(page2Id)
expect(editor.currentPage).toEqual(editor.pages[1])
editor.setCurrentPage(page1Id)
@ -21,7 +25,9 @@ describe('setCurrentPage', () => {
expect(editor.currentPage).toEqual(editor.pages[0])
const page3Id = PageRecordType.createId('page3')
editor.createPage('New Page 3', page3Id)
editor.createPage({ name: 'New Page 3', id: page3Id })
expect(editor.currentPageId).toBe(page1Id)
editor.setCurrentPage(page3Id)
expect(editor.currentPageId).toEqual(page3Id)
expect(editor.currentPage).toEqual(editor.pages[2])
@ -41,7 +47,7 @@ describe('setCurrentPage', () => {
it('squashes', () => {
const page2Id = PageRecordType.createId('page2')
editor.createPage('New Page 2', page2Id)
editor.createPage({ name: 'New Page 2', index: page2Id })
editor.history.clear()
editor.setCurrentPage(editor.pages[1].id)
@ -53,7 +59,7 @@ describe('setCurrentPage', () => {
it('preserves the undo stack', () => {
const boxId = createShapeId('geo')
const page2Id = PageRecordType.createId('page2')
editor.createPage('New Page 2', page2Id)
editor.createPage({ name: 'New Page 2', id: page2Id })
editor.history.clear()
editor.createShapes([{ type: 'geo', id: boxId, props: { w: 100, h: 100 } }])
@ -68,8 +74,8 @@ describe('setCurrentPage', () => {
})
it('logs an error when trying to navigate to a page that does not exist', () => {
const page2Id = PageRecordType.createId('page2')
editor.createPage('New Page 2', page2Id)
const initialPageId = editor.currentPageId
expect(editor.currentPageId).toBe(initialPageId)
console.error = jest.fn()
expect(() => {
@ -77,6 +83,6 @@ describe('setCurrentPage', () => {
}).not.toThrow()
expect(console.error).toHaveBeenCalled()
expect(editor.currentPageId).toEqual(page2Id)
expect(editor.currentPageId).toBe(initialPageId)
})
})

View file

@ -52,7 +52,7 @@ describe('shapeIdsInCurrentPage', () => {
{ type: 'geo', id: ids.box3 },
])
const id = PageRecordType.createId('page2')
editor.createPage('New Page 2', id)
editor.createPage({ name: 'New Page 2', id })
editor.setCurrentPage(id)
editor.createShapes([
{ type: 'geo', id: ids.box4 },

View file

@ -1731,3 +1731,55 @@ describe('When dragging a shape onto a parent', () => {
expect(editor.getShape(ids.box1)?.parentId).toBe(editor.currentPageId)
})
})
describe('When dragging shapes', () => {
it('should drag and undo and redo', () => {
editor.deleteShapes(editor.currentPageShapes)
editor.setCurrentTool('arrow').pointerMove(0, 0).pointerDown().pointerMove(100, 100).pointerUp()
editor.expectShapeToMatch({
id: editor.currentPageShapes[0]!.id,
x: 0,
y: 0,
})
editor.setCurrentTool('geo').pointerMove(-10, 100).pointerDown().pointerUp()
editor.expectShapeToMatch({
id: editor.currentPageShapes[1]!.id,
x: -110,
y: 0,
})
editor
.selectAll()
.pointerMove(50, 50)
.pointerDown()
.pointerMove(100, 50)
.pointerUp()
.expectShapeToMatch({
id: editor.currentPageShapes[0]!.id,
x: 50, // 50 to the right
y: 0,
})
.expectShapeToMatch({
id: editor.currentPageShapes[1]!.id,
x: -60, // 50 to the right
y: 0,
})
editor
.undo()
.expectShapeToMatch({
id: editor.currentPageShapes[0]!.id,
x: 0, // 50 to the right
y: 0,
})
.expectShapeToMatch({
id: editor.currentPageShapes[1]!.id,
x: -110, // 50 to the right
y: 0,
})
})
})

View file

@ -0,0 +1,18 @@
import { TestEditor } from './TestEditor'
let editor: TestEditor
beforeEach(() => {
editor = new TestEditor()
editor
})
describe('When following a user', () => {
it.todo('starts following a user')
it.todo('stops following a user')
it.todo('stops following a user when the camera changes due to user action')
it.todo('moves the camera to follow the user without unfollowing them')
it.todo('stops any animations while following')
it.todo('stops following a user when the page changes due to user action')
it.todo('follows a user to another page without unfollowing them')
})