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

View file

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

View file

@ -136,7 +136,12 @@ export {
SVG_PADDING, SVG_PADDING,
ZOOMS, ZOOMS,
} from './lib/constants' } 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 { export {
SnapManager, SnapManager,
type GapsSnapLine, type GapsSnapLine,

View file

@ -274,7 +274,7 @@ function TldrawEditorWithReadyStore({
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
if (editor && autoFocus) { if (editor && autoFocus) {
editor.focus() editor.getContainer().focus()
} }
}, [editor, autoFocus]) }, [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' import { stack } from './Stack'
function createCounterHistoryManager() { function createCounterHistoryManager() {
@ -286,3 +286,153 @@ describe(HistoryManager, () => {
expect(editor.getCount()).toBe(2) 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 { TLCommandHandler, TLHistoryEntry } from '../types/history-types'
import { Stack, stack } from './Stack' 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[]) => type CommandFn<Data> = (...args: any[]) =>
| { | ({
data: Data data: Data
squashing?: boolean } & CommandHistoryOptions)
ephemeral?: boolean
preservesRedoStack?: boolean
}
| null | null
| undefined | undefined
| void | void

View file

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

View file

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

View file

@ -13,7 +13,7 @@ import {
shortAngleDist, shortAngleDist,
} from '../../../../primitives/utils' } from '../../../../primitives/utils'
import type { Editor } from '../../../Editor' import type { Editor } from '../../../Editor'
import { ArcInfo, ArrowInfo } from './arrow-types' import { TLArcInfo, TLArrowInfo } from './arrow-types'
import { import {
BOUND_ARROW_OFFSET, BOUND_ARROW_OFFSET,
MIN_ARROW_LENGTH, MIN_ARROW_LENGTH,
@ -24,7 +24,11 @@ import {
} from './shared' } from './shared'
import { getStraightArrowInfo } from './straight-arrow' 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 { arrowheadEnd, arrowheadStart } = shape.props
const bend = shape.props.bend + extraBend const bend = shape.props.bend + extraBend
@ -291,7 +295,7 @@ export function getCurvedArrowInfo(editor: Editor, shape: TLArrowShape, extraBen
* @param info - The arrow info. * @param info - The arrow info.
* @public * @public
*/ */
export function getCurvedArrowHandlePath(info: ArrowInfo & { isStraight: false }) { export function getCurvedArrowHandlePath(info: TLArrowInfo & { isStraight: false }) {
const { const {
start, start,
end, end,
@ -306,7 +310,7 @@ export function getCurvedArrowHandlePath(info: ArrowInfo & { isStraight: false }
* @param info - The arrow info. * @param info - The arrow info.
* @public * @public
*/ */
export function getSolidCurvedArrowPath(info: ArrowInfo & { isStraight: false }) { export function getSolidCurvedArrowPath(info: TLArrowInfo & { isStraight: false }) {
const { const {
start, start,
end, end,
@ -373,7 +377,7 @@ export function getArcBoundingBox(center: VecLike, radius: number, start: VecLik
* @param b - The end of the arc * @param b - The end of the arc
* @param c - A point on 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 // 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) 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, intersectLineSegmentPolyline,
} from '../../../../primitives/intersect' } from '../../../../primitives/intersect'
import { Editor } from '../../../Editor' import { Editor } from '../../../Editor'
import { ArrowInfo } from './arrow-types' import { TLArrowInfo } from './arrow-types'
import { import {
BOUND_ARROW_OFFSET, BOUND_ARROW_OFFSET,
BoundShapeInfo, BoundShapeInfo,
@ -17,7 +17,7 @@ import {
getBoundShapeInfoForTerminal, getBoundShapeInfoForTerminal,
} from './shared' } 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 { start, end, arrowheadStart, arrowheadEnd } = shape.props
const terminalsInArrowSpace = getArrowTerminalsInArrowSpace(editor, shape) const terminalsInArrowSpace = getArrowTerminalsInArrowSpace(editor, shape)
@ -204,12 +204,12 @@ function updateArrowheadPointWithBoundShape(
} }
/** @public */ /** @public */
export function getStraightArrowHandlePath(info: ArrowInfo & { isStraight: true }) { export function getStraightArrowHandlePath(info: TLArrowInfo & { isStraight: true }) {
return getArrowPath(info.start.handle, info.end.handle) return getArrowPath(info.start.handle, info.end.handle)
} }
/** @public */ /** @public */
export function getSolidStraightArrowPath(info: ArrowInfo & { isStraight: true }) { export function getSolidStraightArrowPath(info: TLArrowInfo & { isStraight: true }) {
return getArrowPath(info.start.point, info.end.point) return getArrowPath(info.start.point, info.end.point)
} }

View file

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

View file

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

View file

@ -80,7 +80,8 @@ export class Pointing extends StateNode {
const id = createShapeId() const id = createShapeId()
this.markId = this.editor.mark(`creating:${id}`) this.markId = `creating:${id}`
this.editor.mark(this.markId)
this.editor.createShapes<TLArrowShape>([ this.editor.createShapes<TLArrowShape>([
{ {
@ -109,7 +110,7 @@ export class Pointing extends StateNode {
if (startTerminal?.type === 'binding') { if (startTerminal?.type === 'binding') {
this.editor.setHintingIds([startTerminal.boundShapeId]) this.editor.setHintingIds([startTerminal.boundShapeId])
} }
this.editor.updateShapes([change], true) this.editor.updateShapes([change], { squashing: true })
} }
// Cache the current shape after those changes // Cache the current shape after those changes
@ -139,7 +140,7 @@ export class Pointing extends StateNode {
if (endTerminal?.type === 'binding') { if (endTerminal?.type === 'binding') {
this.editor.setHintingIds([endTerminal.boundShapeId]) 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) { 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 break
} }
@ -424,7 +426,7 @@ export class Drawing extends StateNode {
) )
} }
this.editor.updateShapes([shapePartial], true) this.editor.updateShapes([shapePartial], { squashing: true })
} }
break break
@ -566,7 +568,7 @@ export class Drawing extends StateNode {
) )
} }
this.editor.updateShapes([shapePartial], true) this.editor.updateShapes([shapePartial], { squashing: true })
break 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. // Set a maximum length for the lines array; after 200 points, complete the line.
if (newPoints.length > 500) { if (newPoints.length > 500) {

View file

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

View file

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

View file

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

View file

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

View file

@ -7,7 +7,7 @@ export class Idle extends StateNode {
override onEnter = (info: { shapeId: TLShapeId }) => { override onEnter = (info: { shapeId: TLShapeId }) => {
this.shapeId = info.shapeId this.shapeId = info.shapeId
this.editor.updateInstanceState({ cursor: { type: 'cross', rotation: 0 } }, true) this.editor.setCursor({ type: 'cross', rotation: 0 })
} }
override onPointerDown: TLEventHandlers['onPointerDown'] = () => { 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) const shape = info.shapeId && this.editor.getShape<TLLineShape>(info.shapeId)
if (shape) { if (shape) {
this.markId = this.editor.mark(`creating:${shape.id}`) this.markId = `creating:${shape.id}`
this.editor.mark(this.markId)
this.shape = shape this.shape = shape
if (inputs.shiftKey) { if (inputs.shiftKey) {
@ -85,7 +86,8 @@ export class Pointing extends StateNode {
} else { } else {
const id = createShapeId() const id = createShapeId()
this.markId = this.editor.mark(`creating:${id}`) this.markId = `creating:${id}`
this.editor.mark(this.markId)
this.editor.createShapes<TLLineShape>([ this.editor.createShapes<TLLineShape>([
{ {

View file

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

View file

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

View file

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

View file

@ -10,6 +10,6 @@ export class EraserTool extends StateNode {
static override children = () => [Idle, Pointing, Erasing] static override children = () => [Idle, Pointing, Erasing]
override onEnter = () => { 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>() private excludedShapeIds = new Set<TLShapeId>()
override onEnter = (info: TLPointerEventInfo) => { 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 this.info = info
const { originPagePoint } = this.editor.inputs const { originPagePoint } = this.editor.inputs

View file

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

View file

@ -5,7 +5,10 @@ export class Pointing extends StateNode {
override onEnter = () => { override onEnter = () => {
this.editor.stopCameraAnimation() 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) => { override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => {

View file

@ -9,6 +9,6 @@ export class LaserTool extends StateNode {
static override children = () => [Idle, Lasering] static override children = () => [Idle, Lasering]
override onEnter = () => { 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) => { override onCancel?: TLCancelEvent | undefined = (info) => {
this.editor.setSelectedShapeIds(this.initialSelectedShapeIds, true) this.editor.setSelectedShapeIds(this.initialSelectedShapeIds, { squashing: true })
this.parent.transition('idle', info) this.parent.transition('idle', info)
} }
@ -168,7 +168,7 @@ export class Brushing extends StateNode {
} }
this.editor.updateInstanceState({ brush: { ...this.brush.toJson() } }) 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 = () => { override onInterrupt: TLInterruptEvent = () => {

View file

@ -5,7 +5,10 @@ export class Idle extends StateNode {
static override id = 'idle' static override id = 'idle'
override onEnter = () => { 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 const { onlySelectedShape } = this.editor
@ -22,7 +25,10 @@ export class Idle extends StateNode {
} }
override onExit: TLExitEventHandler = () => { 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) this.editor.off('change-history', this.cleanupCroppingState)
} }

View file

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

View file

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

View file

@ -23,7 +23,10 @@ export class Idle extends StateNode {
override onEnter = () => { override onEnter = () => {
this.parent.currentToolIdMask = undefined 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'] = () => { override onPointerMove: TLEventHandlers['onPointerMove'] = () => {

View file

@ -33,7 +33,10 @@ export class PointingCropHandle extends StateNode {
} }
override onExit = () => { 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 this.parent.currentToolIdMask = undefined
} }

View file

@ -14,12 +14,18 @@ export class PointingHandle extends StateNode {
this.editor.setHintingIds([initialTerminal.boundShapeId]) 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 = () => { override onExit = () => {
this.editor.setHintingIds([]) 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'] = () => { override onPointerUp: TLEventHandlers['onPointerUp'] = () => {

View file

@ -28,7 +28,10 @@ export class PointingRotateHandle extends StateNode {
override onExit = () => { override onExit = () => {
this.parent.currentToolIdMask = undefined 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 = () => { override onPointerMove = () => {

View file

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

View file

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

View file

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

View file

@ -53,9 +53,8 @@ export class Translating extends StateNode {
this.isCreating = isCreating this.isCreating = isCreating
this.editAfterComplete = editAfterComplete this.editAfterComplete = editAfterComplete
this.markId = isCreating this.markId = isCreating ? `creating:${this.editor.onlySelectedShape!.id}` : 'translating'
? this.editor.mark(`creating:${this.editor.onlySelectedShape!.id}`) this.editor.mark(this.markId)
: this.editor.mark('translating')
this.handleEnter(info) this.handleEnter(info)
this.editor.on('tick', this.updateParent) this.editor.on('tick', this.updateParent)
} }
@ -66,7 +65,10 @@ export class Translating extends StateNode {
this.selectionSnapshot = {} as any this.selectionSnapshot = {} as any
this.snapshot = {} as any this.snapshot = {} as any
this.editor.snaps.clear() 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() this.dragAndDropManager.clear()
} }
@ -111,7 +113,8 @@ export class Translating extends StateNode {
this.isCloning = true this.isCloning = true
this.reset() this.reset()
this.markId = this.editor.mark('translating') this.markId = 'translating'
this.editor.mark(this.markId)
this.editor.duplicateShapes(Array.from(this.editor.selectedShapeIds)) this.editor.duplicateShapes(Array.from(this.editor.selectedShapeIds))
@ -124,7 +127,8 @@ export class Translating extends StateNode {
this.isCloning = false this.isCloning = false
this.snapshot = this.selectionSnapshot this.snapshot = this.selectionSnapshot
this.reset() this.reset()
this.markId = this.editor.mark('translating') this.markId = 'translating'
this.editor.mark(this.markId)
this.updateShapes() this.updateShapes()
} }
@ -171,7 +175,7 @@ export class Translating extends StateNode {
this.isCloning = false this.isCloning = false
this.info = info this.info = info
this.editor.updateInstanceState({ cursor: { type: 'move', rotation: 0 } }, true) this.editor.setCursor({ type: 'move', rotation: 0 })
this.selectionSnapshot = getTranslatingSnapshot(this.editor) this.selectionSnapshot = getTranslatingSnapshot(this.editor)
// Don't clone on create; otherwise clone on altKey // 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.currentToolIdMask = undefined
this.editor.updateInstanceState( this.editor.updateInstanceState(
{ zoomBrush: null, cursor: { type: 'default', rotation: 0 } }, { zoomBrush: null, cursor: { type: 'default', rotation: 0 } },
true { ephemeral: true }
) )
this.currentToolIdMask = undefined this.currentToolIdMask = undefined
} }
@ -53,9 +53,15 @@ export class ZoomTool extends StateNode {
private updateCursor() { private updateCursor() {
if (this.editor.inputs.altKey) { 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 { } 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 <_ContextMenu.Item
key="new-page" key="new-page"
onSelect={() => { onSelect={() => {
editor.mark('move_shapes_to_page')
const newPageId = PageRecordType.createId() const newPageId = PageRecordType.createId()
const ids = editor.selectedShapeIds const ids = editor.selectedShapeIds
const oldPageId = editor.currentPageId
editor.batch(() => { editor.batch(() => {
editor.createPage('Page 1', newPageId) editor.mark('move_shapes_to_page')
editor.setCurrentPage(oldPageId) editor.createPage({ name: 'Page', id: newPageId })
editor.moveShapesToPage(ids, newPageId) editor.moveShapesToPage(ids, newPageId)
}) })
}} }}

View file

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

View file

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

View file

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

View file

@ -323,7 +323,7 @@ export async function pasteExcalidrawContent(editor: Editor, clipboard: any, poi
editor.mark('paste') editor.mark('paste')
editor.putContent(tldrawContent, { editor.putContentOntoCurrentPage(tldrawContent, {
point: p, point: p,
select: false, select: false,
preserveIds: true, 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) const p = point ?? (editor.inputs.shiftKey ? editor.inputs.currentPagePoint : undefined)
editor.mark('paste') editor.mark('paste')
editor.putContent(clipboard, { editor.putContentOntoCurrentPage(clipboard, {
point: p, point: p,
select: true, select: true,
}) })

View file

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

View file

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

View file

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

View file

@ -79,7 +79,7 @@ export function useExportAs() {
} }
case 'json': { case 'json': {
const data = editor.getContent(ids) const data = editor.getContentFromCurrentPage(ids)
const dataURL = URL.createObjectURL( const dataURL = URL.createObjectURL(
new Blob([JSON.stringify(data, null, 4)], { type: 'application/json' }) 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, [GeoShapeGeoStyle.id]: id,
}, },
}, },
true { ephemeral: true }
) )
editor.setCurrentTool('geo') editor.setCurrentTool('geo')
trackEvent('select-tool', { source, id: `geo-${id}` }) trackEvent('select-tool', { source, id: `geo-${id}` })

View file

@ -113,7 +113,7 @@ export function buildFromV1Document(editor: Editor, document: LegacyTldrawDocume
} else { } else {
const pageId = PageRecordType.createId() const pageId = PageRecordType.createId()
v1PageIdsToV2PageIds.set(v1Page.id, pageId) 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 const page1 = editor.currentPageId
editor.createPage('page 2', ids.page2) editor.createPage({ name: 'page 2', id: ids.page2 })
editor.setCurrentPage(page1) editor.setCurrentPage(page1)
}) })
@ -429,12 +429,12 @@ describe('isFocused', () => {
expect(focusMock).not.toHaveBeenCalled() expect(focusMock).not.toHaveBeenCalled()
expect(blurMock).not.toHaveBeenCalled() expect(blurMock).not.toHaveBeenCalled()
editor.focus() editor.getContainer().focus()
expect(focusMock).toHaveBeenCalled() expect(focusMock).toHaveBeenCalled()
expect(blurMock).not.toHaveBeenCalled() expect(blurMock).not.toHaveBeenCalled()
editor.blur() editor.getContainer().blur()
expect(blurMock).toHaveBeenCalled() expect(blurMock).toHaveBeenCalled()
}) })
@ -471,7 +471,7 @@ describe('getShapeUtil', () => {
{ id: ids.box1, type: 'blorg', x: 100, y: 100, props: { w: 100, h: 100 } }, { id: ids.box1, type: 'blorg', x: 100, y: 100, props: { w: 100, h: 100 } },
]) ])
const page1 = editor.currentPageId const page1 = editor.currentPageId
editor.createPage('page 2', ids.page2) editor.createPage({ name: 'page 2', id: ids.page2 })
editor.setCurrentPage(page1) editor.setCurrentPage(page1)
}) })

View file

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

View file

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

View file

@ -219,7 +219,10 @@ describe('<TldrawEditor />', () => {
expect(editor).toBeTruthy() expect(editor).toBeTruthy()
await act(async () => { 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() const id = createShapeId()
@ -340,7 +343,10 @@ describe('Custom shapes', () => {
expect(editor).toBeTruthy() expect(editor).toBeTruthy()
await act(async () => { 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() expect(editor.shapeUtils.card).toBeTruthy()

View file

@ -248,7 +248,7 @@ describe('arrowBindingsIndex', () => {
expect(editor.getArrowsBoundTo(ids.box2)).toHaveLength(3) expect(editor.getArrowsBoundTo(ids.box2)).toHaveLength(3)
expect(editor.getArrowsBoundTo(ids.box1)).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.box2)).toHaveLength(3)
expect(editor.getArrowsBoundTo(ids.box1)).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', () => { it('Creates a page', () => {
const oldPageId = editor.currentPageId const oldPageId = editor.currentPageId
const n = editor.pages.length 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) expect(editor.pages.length).toBe(n + 1)
const newPageId = editor.pages[n].id 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) expect(editor.currentPageId).toBe(newPageId)
editor.undo() editor.undo()
expect(editor.pages.length).toBe(n) expect(editor.pages.length).toBe(n)
expect(editor.currentPageId).toBe(oldPageId) expect(editor.currentPageId).toBe(oldPageId)
editor.redo() editor.redo()
expect(editor.pages.length).toBe(n + 1) expect(editor.pages.length).toBe(n + 1)
expect(editor.currentPageId).toBe(newPageId) expect(editor.currentPageId).toBe(newPageId)
@ -24,7 +32,7 @@ it('Creates a page', () => {
it("Doesn't create a page if max pages is reached", () => { it("Doesn't create a page if max pages is reached", () => {
for (let i = 0; i < MAX_PAGES + 1; i++) { 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) 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) 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) expect(editor.pages.some((p) => p.name === 'My Special Test Page')).toBe(true)
}) })

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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