Editor.run, locked shapes improvements (#4042)

This PR:
- creates `Editor.run` (previously `Editor.batch`)
- deprecates `Editor.batch`
- introduces a `ignoreShapeLock` option top the `Editor.run` method that
allows the editor to update and delete locked shapes
- fixes a bug with `updateShapes` that allowed updating locked shapes
- fixes a bug with `ungroupShapes` that allowed ungrouping locked shapes
- makes `Editor.history` private
- adds `Editor.squashToMark`
- adds `Editor.clearHistory`
- removes `History.ignore`
- removes `History.onBatchComplete`
- makes `_updateCurrentPageState` private

```ts
editor.run(() => {
  editor.updateShape({ ...myLockedShape })
  editor.deleteShape(myLockedShape)
}, { ignoreShapeLock: true })
```

It also:

## How it works

Normally `updateShape`/`updateShapes` and `deleteShape`/`deleteShapes`
do not effect locked shapes.

```ts
const myLockedShape = editor.getShape(myShapeId)!

// no change from update
editor.updateShape({ ...myLockedShape, x: 100 })
expect(editor.getShape(myShapeId)).toMatchObject(myLockedShape)

// no change from delete
editor.deleteShapes([myLockedShape])
expect(editor.getShape(myShapeId)).toMatchObject(myLockedShape)
```

The new `run` method adds the option to ignore shape lock.

```ts
const myLockedShape = editor.getShape(myShapeId)!

// update works
editor.run(() => { editor.updateShape({ ...myLockedShape, x: 100 }) }, { ignoreShapeLock: true })
expect(editor.getShape(myShapeId)).toMatchObject({ ...myLockedShape, x: 100 })

// delete works
editor.run(() => { editor.deleteShapes([myLockedShape]), { ignoreShapeLock: true })
expect(editor.getShape(myShapeId)).toBeUndefined()
```

## History changes

This is a related but not entirely related change in this PR.

Previously, we had a few ways to run code that ignored the history.

- `editor.history.ignore(() => { ... })`
- `editor.batch(() => { ... }, { history: "ignore" })`
- `editor.history.batch(() => { ... }, { history: "ignore" })`
- `editor.updateCurrentPageState(() => { ... }, { history: "ignore" })`

We now have one way to run code that ignores history:

- `editor.run(() => { ... }, { history: "ignore" })`

## Design notes

We want a user to be able to update or delete locked shapes
programmatically.

### Callback vs. method options?

We could have added a `{ force: boolean }` property to the
`updateShapes` / `deleteShapes` methods, however there are places where
those methods are called from other methods (such as
`distributeShapes`). If we wanted to make these work, we would have also
had to provide a `force` option / bag to those methods.

Using a wrapper callback allows for "regular" tldraw editor code to work
while allowing for updates and deletes.

### Interaction logic?

We don't want this change to effect any of our interaction logic.

A lot of our interaction logic depends on identifying which shapes are
locked and which shapes aren't. For example, clicking on a locked shape
will go to the `pointing_canvas` state rather than the `pointing_shape`.
This PR has no effect on that part of the library.

It only effects the updateShapes and deleteShapes methods. As an example
of this, when `_force` is set to true by default, the only tests that
should fail are in `lockedShapes.test.ts`. The "user land" experience of
locked shapes is identical to what it is now.

### Change type

- [x] `bugfix`
- [ ] `improvement`
- [x] `feature`
- [x] `api`
- [ ] `other`

### Test plan

1. Create a shape
2. Lock it
3. From the console, update it
4. From the console, delete it

- [x] Unit tests

### Release notes

- SDK: Adds `Editor.force()` to permit updating / deleting locked shapes
- Fixed a bug that would allow locked shapes to be updated
programmatically
- Fixed a bug that would allow locked group shapes to be ungrouped
programmatically

---------

Co-authored-by: alex <alex@dytry.ch>
This commit is contained in:
Steve Ruiz 2024-07-15 15:10:09 +01:00 committed by GitHub
parent f4ceb581dd
commit 01bc73e750
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 572 additions and 286 deletions

View file

@ -56,9 +56,12 @@ export function useUrlState(onChangeUrl: (params: UrlStateParams) => void) {
url.searchParams.get(PARAMS.page) ?? 'page:' + url.searchParams.get(PARAMS.p) url.searchParams.get(PARAMS.page) ?? 'page:' + url.searchParams.get(PARAMS.p)
if (newPageId) { if (newPageId) {
if (editor.store.has(newPageId as TLPageId)) { if (editor.store.has(newPageId as TLPageId)) {
editor.history.ignore(() => { editor.run(
() => {
editor.setCurrentPage(newPageId as TLPageId) editor.setCurrentPage(newPageId as TLPageId)
}) },
{ history: 'ignore' }
)
} }
} }
} }

View file

@ -95,7 +95,7 @@ export function useFileSystem({ isMultiplayer }: { isMultiplayer: boolean }): TL
editor.store.clear() editor.store.clear()
editor.store.ensureStoreIsUsable() editor.store.ensureStoreIsUsable()
editor.history.clear() editor.clearHistory()
// Put the old bounds back in place // Put the old bounds back in place
editor.updateViewportScreenBounds(bounds) editor.updateViewportScreenBounds(bounds)
editor.updateInstanceState({ isFocused }) editor.updateInstanceState({ isFocused })

View file

@ -144,7 +144,7 @@ const CameraOptionsControlPanel = track(() => {
useEffect(() => { useEffect(() => {
if (!editor) return if (!editor) return
editor.batch(() => { editor.run(() => {
editor.setCameraOptions(cameraOptions) editor.setCameraOptions(cameraOptions)
editor.setCamera(editor.getCamera(), { editor.setCamera(editor.getCamera(), {
immediate: true, immediate: true,

View file

@ -115,7 +115,7 @@ export function ImageAnnotationEditor({
) )
// Reset the history // Reset the history
editor.history.clear() editor.clearHistory()
setImageShapeId(shapeId) setImageShapeId(shapeId)
return () => { return () => {

View file

@ -816,7 +816,8 @@ export class Editor extends EventEmitter<TLEventMap> {
}): this; }): this;
bail(): this; bail(): this;
bailToMark(id: string): this; bailToMark(id: string): this;
batch(fn: () => void, opts?: TLHistoryBatchOptions): this; // @deprecated (undocumented)
batch(fn: () => void, opts?: TLEditorRunOptions): this;
bindingUtils: { bindingUtils: {
readonly [K in string]?: BindingUtil<TLUnknownBinding>; readonly [K in string]?: BindingUtil<TLUnknownBinding>;
}; };
@ -842,6 +843,8 @@ export class Editor extends EventEmitter<TLEventMap> {
// @internal (undocumented) // @internal (undocumented)
capturedPointerId: null | number; capturedPointerId: null | number;
centerOnPoint(point: VecLike, opts?: TLCameraMoveOptions): this; centerOnPoint(point: VecLike, opts?: TLCameraMoveOptions): this;
// (undocumented)
clearHistory(): this;
clearOpenMenus(): this; clearOpenMenus(): this;
// @internal // @internal
protected _clickManager: ClickManager; protected _clickManager: ClickManager;
@ -1076,7 +1079,7 @@ export class Editor extends EventEmitter<TLEventMap> {
hasAncestor(shape: TLShape | TLShapeId | undefined, ancestorId: TLShapeId): boolean; hasAncestor(shape: TLShape | TLShapeId | undefined, ancestorId: TLShapeId): boolean;
// (undocumented) // (undocumented)
hasExternalAssetHandler(type: TLExternalAssetContent['type']): boolean; hasExternalAssetHandler(type: TLExternalAssetContent['type']): boolean;
readonly history: HistoryManager<TLRecord>; protected readonly history: HistoryManager<TLRecord>;
inputs: { inputs: {
buttons: Set<number>; buttons: Set<number>;
keys: Set<string>; keys: Set<string>;
@ -1150,6 +1153,7 @@ export class Editor extends EventEmitter<TLEventMap> {
}): Promise<null | string>; }): Promise<null | string>;
readonly root: StateNode; readonly root: StateNode;
rotateShapesBy(shapes: TLShape[] | TLShapeId[], delta: number): this; rotateShapesBy(shapes: TLShape[] | TLShapeId[], delta: number): this;
run(fn: () => void, opts?: TLEditorRunOptions): this;
screenToPage(point: VecLike): Vec; screenToPage(point: VecLike): Vec;
readonly scribbles: ScribbleManager; readonly scribbles: ScribbleManager;
select(...shapes: TLShape[] | TLShapeId[]): this; select(...shapes: TLShape[] | TLShapeId[]): this;
@ -1184,6 +1188,7 @@ export class Editor extends EventEmitter<TLEventMap> {
speedThreshold?: number | undefined; speedThreshold?: number | undefined;
}): this; }): this;
readonly snaps: SnapManager; readonly snaps: SnapManager;
squashToMark(markId: string): this;
stackShapes(shapes: TLShape[] | TLShapeId[], operation: 'horizontal' | 'vertical', gap: number): this; stackShapes(shapes: TLShape[] | TLShapeId[], operation: 'horizontal' | 'vertical', gap: number): this;
startFollowingUser(userId: string): this; startFollowingUser(userId: string): this;
stopCameraAnimation(): this; stopCameraAnimation(): this;
@ -1208,9 +1213,7 @@ export class Editor extends EventEmitter<TLEventMap> {
updateAssets(assets: TLAssetPartial[]): this; updateAssets(assets: TLAssetPartial[]): this;
updateBinding<B extends TLBinding = TLBinding>(partial: TLBindingUpdate<B>): this; updateBinding<B extends TLBinding = TLBinding>(partial: TLBindingUpdate<B>): this;
updateBindings(partials: (null | TLBindingUpdate | undefined)[]): this; updateBindings(partials: (null | TLBindingUpdate | undefined)[]): this;
updateCurrentPageState(partial: Partial<Omit<TLInstancePageState, 'editingShapeId' | 'focusedGroupId' | 'pageId' | 'selectedShapeIds'>>, historyOptions?: TLHistoryBatchOptions): this; updateCurrentPageState(partial: Partial<Omit<TLInstancePageState, 'editingShapeId' | 'focusedGroupId' | 'pageId' | 'selectedShapeIds'>>): this;
// (undocumented)
_updateCurrentPageState: (partial: Partial<Omit<TLInstancePageState, 'selectedShapeIds'>>, historyOptions?: TLHistoryBatchOptions) => void;
updateDocumentSettings(settings: Partial<TLDocument>): this; updateDocumentSettings(settings: Partial<TLDocument>): this;
updateInstanceState(partial: Partial<Omit<TLInstance, 'currentPageId'>>, historyOptions?: TLHistoryBatchOptions): this; updateInstanceState(partial: Partial<Omit<TLInstance, 'currentPageId'>>, historyOptions?: TLHistoryBatchOptions): this;
updatePage(partial: RequiredKeys<Partial<TLPage>, 'id'>): this; updatePage(partial: RequiredKeys<Partial<TLPage>, 'id'>): this;
@ -1562,15 +1565,11 @@ export class HistoryManager<R extends UnknownRecord> {
getNumRedos(): number; getNumRedos(): number;
// (undocumented) // (undocumented)
getNumUndos(): number; getNumUndos(): number;
// (undocumented)
ignore(fn: () => void): this;
// @internal (undocumented) // @internal (undocumented)
_isInBatch: boolean; _isInBatch: boolean;
// (undocumented) // (undocumented)
mark: (id?: string) => string; mark: (id?: string) => string;
// (undocumented) // (undocumented)
onBatchComplete: () => void;
// (undocumented)
redo: () => this; redo: () => this;
// (undocumented) // (undocumented)
squashToMark: (id: string) => this; squashToMark: (id: string) => this;
@ -2688,6 +2687,12 @@ export interface TLEditorOptions {
user?: TLUser; user?: TLUser;
} }
// @public
export interface TLEditorRunOptions extends TLHistoryBatchOptions {
// (undocumented)
ignoreShapeLock?: boolean;
}
// @public (undocumented) // @public (undocumented)
export interface TLEditorSnapshot { export interface TLEditorSnapshot {
// (undocumented) // (undocumented)

View file

@ -134,7 +134,12 @@ export { createTLUser, type TLUser } from './lib/config/createTLUser'
export { type TLAnyBindingUtilConstructor } from './lib/config/defaultBindings' export { type TLAnyBindingUtilConstructor } from './lib/config/defaultBindings'
export { coreShapes, type TLAnyShapeUtilConstructor } from './lib/config/defaultShapes' export { coreShapes, type TLAnyShapeUtilConstructor } from './lib/config/defaultShapes'
export { DEFAULT_ANIMATION_OPTIONS, DEFAULT_CAMERA_OPTIONS, SIDES } from './lib/constants' export { DEFAULT_ANIMATION_OPTIONS, DEFAULT_CAMERA_OPTIONS, SIDES } from './lib/constants'
export { Editor, type TLEditorOptions, type TLResizeShapeOptions } from './lib/editor/Editor' export {
Editor,
type TLEditorOptions,
type TLEditorRunOptions,
type TLResizeShapeOptions,
} from './lib/editor/Editor'
export { export {
BindingUtil, BindingUtil,
type BindingOnChangeOptions, type BindingOnChangeOptions,

View file

@ -497,10 +497,15 @@ export function useOnMount(onMount?: TLOnMountHandler) {
const onMountEvent = useEvent((editor: Editor) => { const onMountEvent = useEvent((editor: Editor) => {
let teardown: (() => void) | void = undefined let teardown: (() => void) | void = undefined
editor.history.ignore(() => { // If the user wants to do something when the editor mounts, we make sure it doesn't effect the history.
// todo: is this reeeeally what we want to do, or should we leave it up to the caller?
editor.run(
() => {
teardown = onMount?.(editor) teardown = onMount?.(editor)
editor.emit('mount') editor.emit('mount')
}) },
{ history: 'ignore' }
)
window.tldrawReady = true window.tldrawReady = true
return teardown return teardown
}) })

View file

@ -218,6 +218,14 @@ export interface TLEditorOptions {
licenseKey?: string licenseKey?: string
} }
/**
* Options for {@link Editor.(run:1)}.
* @public
*/
export interface TLEditorRunOptions extends TLHistoryBatchOptions {
ignoreShapeLock?: boolean
}
/** @public */ /** @public */
export class Editor extends EventEmitter<TLEventMap> { export class Editor extends EventEmitter<TLEventMap> {
constructor({ constructor({
@ -671,7 +679,8 @@ export class Editor extends EventEmitter<TLEventMap> {
) )
this.disposables.add(this.history.dispose) this.disposables.add(this.history.dispose)
this.history.ignore(() => { this.run(
() => {
this.store.ensureStoreIsUsable() this.store.ensureStoreIsUsable()
// clear ephemeral state // clear ephemeral state
@ -680,7 +689,9 @@ export class Editor extends EventEmitter<TLEventMap> {
hoveredShapeId: null, hoveredShapeId: null,
erasingShapeIds: [], erasingShapeIds: [],
}) })
}) },
{ history: 'ignore' }
)
if (initialState && this.root.children[initialState] === undefined) { if (initialState && this.root.children[initialState] === undefined) {
throw Error(`No state found for initialState "${initialState}".`) throw Error(`No state found for initialState "${initialState}".`)
@ -913,7 +924,7 @@ export class Editor extends EventEmitter<TLEventMap> {
* *
* @readonly * @readonly
*/ */
readonly history: HistoryManager<TLRecord> protected readonly history: HistoryManager<TLRecord>
/** /**
* Undo to the last mark. * Undo to the last mark.
@ -958,6 +969,11 @@ export class Editor extends EventEmitter<TLEventMap> {
return this return this
} }
clearHistory() {
this.history.clear()
return this
}
/** /**
* Whether the app can redo. * Whether the app can redo.
* *
@ -986,6 +1002,23 @@ export class Editor extends EventEmitter<TLEventMap> {
return this return this
} }
/**
* Squash the history to the given mark id.
*
* @example
* ```ts
* editor.mark('bump shapes')
* // ... some changes
* editor.squashToMark('bump shapes')
* ```
*
* @param markId - The mark id to squash to.
*/
squashToMark(markId: string): this {
this.history.squashToMark(markId)
return this
}
/** /**
* Clear all marks in the undo stack back to the next mark. * Clear all marks in the undo stack back to the next mark.
* *
@ -1016,16 +1049,53 @@ export class Editor extends EventEmitter<TLEventMap> {
return this return this
} }
private _shouldIgnoreShapeLock = false
/** /**
* Run a function in a batch. * Run a function in a transaction with optional options for context.
* You can use the options to change the way that history is treated
* or allow changes to locked shapes.
*
* @example
* ```ts
* // updating with
* editor.run({ history: "ignore" }, () => {
* editor.updateShape({ ...myShape, x: 100 })
* })
*
* // forcing changes / deletions for locked shapes
* editor.toggleLock([myShape])
* editor.run({ ignoreShapeLock: true }, () => {
* editor.updateShape({ ...myShape, x: 100 })
* editor.deleteShape(myShape)
* })
* ```
* @param opts - The options for the batch.
* @param fn - The callback function to run.
*
* *
* @public * @public
*/ */
batch(fn: () => void, opts?: TLHistoryBatchOptions): this { run(fn: () => void, opts?: TLEditorRunOptions): this {
const previousIgnoreShapeLock = this._shouldIgnoreShapeLock
this._shouldIgnoreShapeLock = opts?.ignoreShapeLock ?? previousIgnoreShapeLock
try {
this.history.batch(fn, opts) this.history.batch(fn, opts)
} finally {
this._shouldIgnoreShapeLock = previousIgnoreShapeLock
}
return this return this
} }
/**
* @deprecated Use `Editor.run` instead.
*/
batch(fn: () => void, opts?: TLEditorRunOptions): this {
return this.run(fn, opts)
}
/* --------------------- Errors --------------------- */ /* --------------------- Errors --------------------- */
/** @internal */ /** @internal */
@ -1258,9 +1328,12 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public * @public
**/ **/
updateDocumentSettings(settings: Partial<TLDocument>): this { updateDocumentSettings(settings: Partial<TLDocument>): this {
this.history.ignore(() => { this.run(
() => {
this.store.put([{ ...this.getDocumentSettings(), ...settings }]) this.store.put([{ ...this.getDocumentSettings(), ...settings }])
}) },
{ history: 'ignore' }
)
return this return this
} }
@ -1306,7 +1379,7 @@ export class Editor extends EventEmitter<TLEventMap> {
partial: Partial<Omit<TLInstance, 'currentPageId'>>, partial: Partial<Omit<TLInstance, 'currentPageId'>>,
opts?: TLHistoryBatchOptions opts?: TLHistoryBatchOptions
) => { ) => {
this.batch(() => { this.run(() => {
this.store.put([ this.store.put([
{ {
...this.getInstanceState(), ...this.getInstanceState(),
@ -1456,39 +1529,27 @@ export class Editor extends EventEmitter<TLEventMap> {
* @example * @example
* ```ts * ```ts
* editor.updateCurrentPageState({ id: 'page1', editingShapeId: 'shape:123' }) * editor.updateCurrentPageState({ id: 'page1', editingShapeId: 'shape:123' })
* editor.updateCurrentPageState({ id: 'page1', editingShapeId: 'shape:123' }, { ephemeral: true })
* ``` * ```
* *
* @param partial - The partial of the page state object containing the changes. * @param partial - The partial of the page state object containing the changes.
* @param historyOptions - The history options for the change.
* *
* @public * @public
*/ */
updateCurrentPageState( updateCurrentPageState(
partial: Partial< partial: Partial<
Omit<TLInstancePageState, 'selectedShapeIds' | 'editingShapeId' | 'pageId' | 'focusedGroupId'> Omit<TLInstancePageState, 'selectedShapeIds' | 'editingShapeId' | 'pageId' | 'focusedGroupId'>
>, >
historyOptions?: TLHistoryBatchOptions
): this { ): this {
this._updateCurrentPageState(partial, historyOptions) this._updateCurrentPageState(partial)
return this return this
} }
_updateCurrentPageState = ( private _updateCurrentPageState = (
partial: Partial<Omit<TLInstancePageState, 'selectedShapeIds'>>, partial: Partial<Omit<TLInstancePageState, 'selectedShapeIds'>>
historyOptions?: TLHistoryBatchOptions
) => { ) => {
this.batch(
() => {
this.store.update(partial.id ?? this.getCurrentPageState().id, (state) => ({ this.store.update(partial.id ?? this.getCurrentPageState().id, (state) => ({
...state, ...state,
...partial, ...partial,
})) }))
},
{
history: 'ignore',
...historyOptions,
}
)
} }
/** /**
@ -1525,7 +1586,7 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public * @public
*/ */
setSelectedShapes(shapes: TLShapeId[] | TLShape[]): this { setSelectedShapes(shapes: TLShapeId[] | TLShape[]): this {
return this.batch( return this.run(
() => { () => {
const ids = shapes.map((shape) => (typeof shape === 'string' ? shape : shape.id)) const ids = shapes.map((shape) => (typeof shape === 'string' ? shape : shape.id))
const { selectedShapeIds: prevSelectedShapeIds } = this.getCurrentPageState() const { selectedShapeIds: prevSelectedShapeIds } = this.getCurrentPageState()
@ -1804,7 +1865,7 @@ export class Editor extends EventEmitter<TLEventMap> {
if (id === this.getFocusedGroupId()) return this if (id === this.getFocusedGroupId()) return this
return this.batch( return this.run(
() => { () => {
this.store.update(this.getCurrentPageState().id, (s) => ({ ...s, focusedGroupId: id })) this.store.update(this.getCurrentPageState().id, (s) => ({ ...s, focusedGroupId: id }))
}, },
@ -1875,13 +1936,23 @@ export class Editor extends EventEmitter<TLEventMap> {
if (id) { if (id) {
const shape = this.getShape(id) const shape = this.getShape(id)
if (shape && this.getShapeUtil(shape).canEdit(shape)) { if (shape && this.getShapeUtil(shape).canEdit(shape)) {
this.run(
() => {
this._updateCurrentPageState({ editingShapeId: id }) this._updateCurrentPageState({ editingShapeId: id })
},
{ history: 'ignore' }
)
return this return this
} }
} }
// Either we just set the editing id to null, or the shape was missing or not editable // Either we just set the editing id to null, or the shape was missing or not editable
this.run(
() => {
this._updateCurrentPageState({ editingShapeId: null }) this._updateCurrentPageState({ editingShapeId: null })
},
{ history: 'ignore' }
)
} }
return this return this
} }
@ -1923,7 +1994,12 @@ export class Editor extends EventEmitter<TLEventMap> {
setHoveredShape(shape: TLShapeId | TLShape | null): this { setHoveredShape(shape: TLShapeId | TLShape | null): this {
const id = typeof shape === 'string' ? shape : shape?.id ?? null const id = typeof shape === 'string' ? shape : shape?.id ?? null
if (id === this.getHoveredShapeId()) return this if (id === this.getHoveredShapeId()) return this
this.updateCurrentPageState({ hoveredShapeId: id }, { history: 'ignore' }) this.run(
() => {
this.updateCurrentPageState({ hoveredShapeId: id })
},
{ history: 'ignore' }
)
return this return this
} }
@ -1966,7 +2042,12 @@ export class Editor extends EventEmitter<TLEventMap> {
? (shapes as TLShapeId[]) ? (shapes as TLShapeId[])
: (shapes as TLShape[]).map((shape) => shape.id) : (shapes as TLShape[]).map((shape) => shape.id)
// always ephemeral // always ephemeral
this.updateCurrentPageState({ hintingShapeIds: dedupe(ids) }, { history: 'ignore' }) this.run(
() => {
this._updateCurrentPageState({ hintingShapeIds: dedupe(ids) })
},
{ history: 'ignore' }
)
return this return this
} }
@ -2011,7 +2092,8 @@ export class Editor extends EventEmitter<TLEventMap> {
: (shapes as TLShape[]).map((shape) => shape.id) : (shapes as TLShape[]).map((shape) => shape.id)
ids.sort() // sort the incoming ids ids.sort() // sort the incoming ids
const erasingShapeIds = this.getErasingShapeIds() const erasingShapeIds = this.getErasingShapeIds()
this.history.ignore(() => { this.run(
() => {
if (ids.length === erasingShapeIds.length) { if (ids.length === erasingShapeIds.length) {
// if the new ids are the same length as the current ids, they might be the same. // if the new ids are the same length as the current ids, they might be the same.
// presuming the current ids are also sorted, check each item to see if it's the same; // presuming the current ids are also sorted, check each item to see if it's the same;
@ -2026,7 +2108,9 @@ export class Editor extends EventEmitter<TLEventMap> {
// if the ids are a different length, then we know they're different. // if the ids are a different length, then we know they're different.
this._updateCurrentPageState({ erasingShapeIds: ids }) this._updateCurrentPageState({ erasingShapeIds: ids })
} }
}) },
{ history: 'ignore' }
)
return this return this
} }
@ -2059,6 +2143,8 @@ export class Editor extends EventEmitter<TLEventMap> {
setCroppingShape(shape: TLShapeId | TLShape | null): this { setCroppingShape(shape: TLShapeId | TLShape | null): this {
const id = typeof shape === 'string' ? shape : shape?.id ?? null const id = typeof shape === 'string' ? shape : shape?.id ?? null
if (id !== this.getCroppingShapeId()) { if (id !== this.getCroppingShapeId()) {
this.run(
() => {
if (!id) { if (!id) {
this.updateCurrentPageState({ croppingShapeId: null }) this.updateCurrentPageState({ croppingShapeId: null })
} else { } else {
@ -2068,6 +2154,9 @@ export class Editor extends EventEmitter<TLEventMap> {
this.updateCurrentPageState({ croppingShapeId: id }) this.updateCurrentPageState({ croppingShapeId: id })
} }
} }
},
{ history: 'ignore' }
)
} }
return this return this
} }
@ -2459,11 +2548,14 @@ export class Editor extends EventEmitter<TLEventMap> {
return this return this
} }
this.batch(() => { transact(() => {
const camera = { ...currentCamera, x, y, z } const camera = { ...currentCamera, x, y, z }
this.history.ignore(() => { this.run(
() => {
this.store.put([camera]) // include id and meta here this.store.put([camera]) // include id and meta here
}) },
{ history: 'ignore' }
)
// Dispatch a new pointer move because the pointer's page will have changed // Dispatch a new pointer move because the pointer's page will have changed
// (its screen position will compute to a new page position given the new camera position) // (its screen position will compute to a new page position given the new camera position)
@ -2986,7 +3078,7 @@ export class Editor extends EventEmitter<TLEventMap> {
if (!presence) return this if (!presence) return this
this.batch(() => { this.run(() => {
// If we're following someone, stop following them // If we're following someone, stop following them
if (this.getInstanceState().followingUserId !== null) { if (this.getInstanceState().followingUserId !== null) {
this.stopFollowingUser() this.stopFollowingUser()
@ -3284,13 +3376,16 @@ export class Editor extends EventEmitter<TLEventMap> {
this.getPage(leaderPresence.currentPageId) this.getPage(leaderPresence.currentPageId)
) { ) {
// if the page changed, switch page // if the page changed, switch page
this.history.ignore(() => { this.run(
() => {
// sneaky store.put here, we can't go through setCurrentPage because it calls stopFollowingUser // sneaky store.put here, we can't go through setCurrentPage because it calls stopFollowingUser
this.store.put([ this.store.put([
{ ...this.getInstanceState(), currentPageId: leaderPresence.currentPageId }, { ...this.getInstanceState(), currentPageId: leaderPresence.currentPageId },
]) ])
this._isLockedOnFollowingUser.set(true) this._isLockedOnFollowingUser.set(true)
}) },
{ history: 'ignore' }
)
} }
}) })
@ -3384,14 +3479,17 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public * @public
*/ */
stopFollowingUser(): this { stopFollowingUser(): this {
this.history.ignore(() => { this.run(
() => {
// commit the current camera to the store // commit the current camera to the store
this.store.put([this.getCamera()]) this.store.put([this.getCamera()])
// this must happen after the camera is committed // this must happen after the camera is committed
this._isLockedOnFollowingUser.set(false) this._isLockedOnFollowingUser.set(false)
this.updateInstanceState({ followingUserId: null }) this.updateInstanceState({ followingUserId: null })
this.emit('stop-following') this.emit('stop-following')
}) },
{ history: 'ignore' }
)
return this return this
} }
@ -3681,8 +3779,10 @@ export class Editor extends EventEmitter<TLEventMap> {
// finish off any in-progress interactions // finish off any in-progress interactions
this.complete() this.complete()
return this.batch( return this.run(
() => this.store.put([{ ...this.getInstanceState(), currentPageId: pageId }]), () => {
this.store.put([{ ...this.getInstanceState(), currentPageId: pageId }])
},
{ history: 'record-preserveRedoStack' } { history: 'record-preserveRedoStack' }
) )
} }
@ -3705,7 +3805,7 @@ export class Editor extends EventEmitter<TLEventMap> {
const prev = this.getPage(partial.id) const prev = this.getPage(partial.id)
if (!prev) return this if (!prev) return this
return this.batch(() => this.store.update(partial.id, (page) => ({ ...page, ...partial }))) return this.run(() => this.store.update(partial.id, (page) => ({ ...page, ...partial })))
} }
/** /**
@ -3722,7 +3822,7 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public * @public
*/ */
createPage(page: Partial<TLPage>): this { createPage(page: Partial<TLPage>): this {
this.history.batch(() => { this.run(() => {
if (this.getInstanceState().isReadonly) return if (this.getInstanceState().isReadonly) return
if (this.getPages().length >= this.options.maxPages) return if (this.getPages().length >= this.options.maxPages) return
const pages = this.getPages() const pages = this.getPages()
@ -3764,7 +3864,7 @@ export class Editor extends EventEmitter<TLEventMap> {
*/ */
deletePage(page: TLPageId | TLPage): this { deletePage(page: TLPageId | TLPage): this {
const id = typeof page === 'string' ? page : page.id const id = typeof page === 'string' ? page : page.id
this.batch(() => { this.run(() => {
if (this.getInstanceState().isReadonly) return if (this.getInstanceState().isReadonly) return
const pages = this.getPages() const pages = this.getPages()
if (pages.length === 1) return if (pages.length === 1) return
@ -3799,7 +3899,7 @@ export class Editor extends EventEmitter<TLEventMap> {
const prevCamera = { ...this.getCamera() } const prevCamera = { ...this.getCamera() }
const content = this.getContentFromCurrentPage(this.getSortedChildIdsForParent(freshPage.id)) const content = this.getContentFromCurrentPage(this.getSortedChildIdsForParent(freshPage.id))
this.batch(() => { this.run(() => {
const pages = this.getPages() const pages = this.getPages()
const index = getIndexBetween(freshPage.index, pages[pages.indexOf(freshPage) + 1]?.index) const index = getIndexBetween(freshPage.index, pages[pages.indexOf(freshPage) + 1]?.index)
@ -3870,7 +3970,7 @@ export class Editor extends EventEmitter<TLEventMap> {
createAssets(assets: TLAsset[]): this { createAssets(assets: TLAsset[]): this {
if (this.getInstanceState().isReadonly) return this if (this.getInstanceState().isReadonly) return this
if (assets.length <= 0) return this if (assets.length <= 0) return this
this.history.ignore(() => this.store.put(assets)) this.run(() => this.store.put(assets), { history: 'ignore' })
return this return this
} }
@ -3889,14 +3989,17 @@ export class Editor extends EventEmitter<TLEventMap> {
updateAssets(assets: TLAssetPartial[]): this { updateAssets(assets: TLAssetPartial[]): this {
if (this.getInstanceState().isReadonly) return this if (this.getInstanceState().isReadonly) return this
if (assets.length <= 0) return this if (assets.length <= 0) return this
this.history.ignore(() => { this.run(
() => {
this.store.put( this.store.put(
assets.map((partial) => ({ assets.map((partial) => ({
...this.store.get(partial.id)!, ...this.store.get(partial.id)!,
...partial, ...partial,
})) }))
) )
}) },
{ history: 'ignore' }
)
return this return this
} }
@ -3921,7 +4024,7 @@ export class Editor extends EventEmitter<TLEventMap> {
: (assets as TLAsset[]).map((a) => a.id) : (assets as TLAsset[]).map((a) => a.id)
if (ids.length <= 0) return this if (ids.length <= 0) return this
this.history.ignore(() => this.store.remove(ids)) this.run(() => this.store.remove(ids), { history: 'ignore' })
return this return this
} }
@ -5019,15 +5122,10 @@ export class Editor extends EventEmitter<TLEventMap> {
const shapesToReparent = compact(ids.map((id) => this.getShape(id))) const shapesToReparent = compact(ids.map((id) => this.getShape(id)))
// The user is allowed to re-parent locked shapes. Unintuitive? Yeah! But there are plenty of // Ignore locked shapes so that we can reparent locked shapes, for example
// times when a locked shape's parent is deleted... and we need to put that shape somewhere! // when a locked shape's parent is deleted.
const lockedShapes = shapesToReparent.filter((shape) => shape.isLocked) this.run(
() => {
if (lockedShapes.length) {
// If we have locked shapes, unlock them before we update them
this.updateShapes(lockedShapes.map(({ id, type }) => ({ id, type, isLocked: false })))
}
for (let i = 0; i < shapesToReparent.length; i++) { for (let i = 0; i < shapesToReparent.length; i++) {
const shape = shapesToReparent[i] const shape = shapesToReparent[i]
@ -5048,11 +5146,13 @@ export class Editor extends EventEmitter<TLEventMap> {
y: newPoint.y, y: newPoint.y,
rotation: newRotation, rotation: newRotation,
index: indices[i], index: indices[i],
isLocked: shape.isLocked, // this will re-lock locked shapes
}) })
} }
this.updateShapes(changes) this.updateShapes(changes)
},
{ ignoreShapeLock: true }
)
return this return this
} }
@ -5520,7 +5620,7 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public * @public
*/ */
duplicateShapes(shapes: TLShapeId[] | TLShape[], offset?: VecLike): this { duplicateShapes(shapes: TLShapeId[] | TLShape[], offset?: VecLike): this {
this.history.batch(() => { this.run(() => {
const ids = const ids =
typeof shapes[0] === 'string' typeof shapes[0] === 'string'
? (shapes as TLShapeId[]) ? (shapes as TLShapeId[])
@ -5666,7 +5766,7 @@ export class Editor extends EventEmitter<TLEventMap> {
const fromPageZ = this.getCamera().z const fromPageZ = this.getCamera().z
this.history.batch(() => { this.run(() => {
// Delete the shapes on the current page // Delete the shapes on the current page
this.deleteShapes(ids) this.deleteShapes(ids)
@ -5723,7 +5823,7 @@ export class Editor extends EventEmitter<TLEventMap> {
} }
} }
} }
this.batch(() => { this.run(() => {
if (allUnlocked) { if (allUnlocked) {
this.updateShapes( this.updateShapes(
shapesToToggle.map((shape) => ({ id: shape.id, type: shape.type, isLocked: true })) shapesToToggle.map((shape) => ({ id: shape.id, type: shape.type, isLocked: true }))
@ -5877,7 +5977,7 @@ export class Editor extends EventEmitter<TLEventMap> {
compact(shapesToFlip.map((id) => this.getShapePageBounds(id))) compact(shapesToFlip.map((id) => this.getShapePageBounds(id)))
).center ).center
this.batch(() => { this.run(() => {
for (const shape of shapesToFlip) { for (const shape of shapesToFlip) {
const bounds = this.getShapeGeometry(shape).bounds const bounds = this.getShapeGeometry(shape).bounds
const initialPageTransform = this.getShapePageTransform(shape.id) const initialPageTransform = this.getShapePageTransform(shape.id)
@ -6383,7 +6483,7 @@ export class Editor extends EventEmitter<TLEventMap> {
switch (operation) { switch (operation) {
case 'vertical': { case 'vertical': {
this.batch(() => { this.run(() => {
for (const shape of shapesToStretch) { for (const shape of shapesToStretch) {
const pageRotation = this.getShapePageTransform(shape)!.rotation() const pageRotation = this.getShapePageTransform(shape)!.rotation()
if (pageRotation % PI2) continue if (pageRotation % PI2) continue
@ -6407,7 +6507,7 @@ export class Editor extends EventEmitter<TLEventMap> {
break break
} }
case 'horizontal': { case 'horizontal': {
this.batch(() => { this.run(() => {
for (const shape of shapesToStretch) { for (const shape of shapesToStretch) {
const bounds = shapeBounds[shape.id] const bounds = shapeBounds[shape.id]
const pageBounds = shapePageBounds[shape.id] const pageBounds = shapePageBounds[shape.id]
@ -6773,7 +6873,7 @@ export class Editor extends EventEmitter<TLEventMap> {
const focusedGroupId = this.getFocusedGroupId() const focusedGroupId = this.getFocusedGroupId()
return this.batch(() => { this.run(() => {
// 1. Parents // 1. Parents
// Make sure that each partial will become the child of either the // Make sure that each partial will become the child of either the
@ -6933,6 +7033,8 @@ export class Editor extends EventEmitter<TLEventMap> {
this.store.put(shapeRecordsToCreate) this.store.put(shapeRecordsToCreate)
}) })
return this
} }
private animatingShapes = new Map<TLShapeId, string>() private animatingShapes = new Map<TLShapeId, string>()
@ -7092,7 +7194,11 @@ export class Editor extends EventEmitter<TLEventMap> {
if (ids.length <= 1) return this if (ids.length <= 1) return this
const shapesToGroup = compact(this._getUnlockedShapeIds(ids).map((id) => this.getShape(id))) const shapesToGroup = compact(
(this._shouldIgnoreShapeLock ? ids : this._getUnlockedShapeIds(ids)).map((id) =>
this.getShape(id)
)
)
const sortedShapeIds = shapesToGroup.sort(sortByIndex).map((s) => s.id) const sortedShapeIds = shapesToGroup.sort(sortByIndex).map((s) => s.id)
const pageBounds = Box.Common(compact(shapesToGroup.map((id) => this.getShapePageBounds(id)))) const pageBounds = Box.Common(compact(shapesToGroup.map((id) => this.getShapePageBounds(id))))
@ -7115,7 +7221,7 @@ export class Editor extends EventEmitter<TLEventMap> {
const highestIndex = shapesWithRootParent[shapesWithRootParent.length - 1]?.index const highestIndex = shapesWithRootParent[shapesWithRootParent.length - 1]?.index
this.batch(() => { this.run(() => {
this.createShapes<TLGroupShape>([ this.createShapes<TLGroupShape>([
{ {
id: groupId, id: groupId,
@ -7155,18 +7261,24 @@ export class Editor extends EventEmitter<TLEventMap> {
ungroupShapes(ids: TLShapeId[], options?: Partial<{ select: boolean }>): this ungroupShapes(ids: TLShapeId[], options?: Partial<{ select: boolean }>): this
ungroupShapes(shapes: TLShape[], options?: Partial<{ select: boolean }>): this ungroupShapes(shapes: TLShape[], options?: Partial<{ select: boolean }>): this
ungroupShapes(shapes: TLShapeId[] | TLShape[], options = {} as Partial<{ select: boolean }>) { ungroupShapes(shapes: TLShapeId[] | TLShape[], options = {} as Partial<{ select: boolean }>) {
if (this.getInstanceState().isReadonly) return this
const { select = true } = options const { select = true } = options
const ids = const ids =
typeof shapes[0] === 'string' typeof shapes[0] === 'string'
? (shapes as TLShapeId[]) ? (shapes as TLShapeId[])
: (shapes as TLShape[]).map((s) => s.id) : (shapes as TLShape[]).map((s) => s.id)
if (this.getInstanceState().isReadonly) return this
if (ids.length === 0) return this
// Only ungroup when the select tool is active const shapesToUngroup = compact(
(this._shouldIgnoreShapeLock ? ids : this._getUnlockedShapeIds(ids)).map((id) =>
this.getShape(id)
)
)
if (shapesToUngroup.length === 0) return this
// todo: the editor shouldn't know about the select tool, move to group / ungroup actions
if (this.getCurrentToolId() !== 'select') return this if (this.getCurrentToolId() !== 'select') return this
// If not already in idle, cancel the current interaction (get back to idle)
if (!this.isIn('select.idle')) { if (!this.isIn('select.idle')) {
this.cancel() this.cancel()
} }
@ -7179,7 +7291,7 @@ export class Editor extends EventEmitter<TLEventMap> {
// Get all groups in the selection // Get all groups in the selection
const groups: TLGroupShape[] = [] const groups: TLGroupShape[] = []
compact(ids.map((id) => this.getShape(id))).forEach((shape) => { shapesToUngroup.forEach((shape) => {
if (this.isShapeOfType<TLGroupShape>(shape, 'group')) { if (this.isShapeOfType<TLGroupShape>(shape, 'group')) {
groups.push(shape) groups.push(shape)
} else { } else {
@ -7189,7 +7301,7 @@ export class Editor extends EventEmitter<TLEventMap> {
if (groups.length === 0) return this if (groups.length === 0) return this
this.batch(() => { this.run(() => {
let group: TLGroupShape let group: TLGroupShape
for (let i = 0, n = groups.length; i < n; i++) { for (let i = 0, n = groups.length; i < n; i++) {
@ -7253,8 +7365,21 @@ export class Editor extends EventEmitter<TLEventMap> {
const shape = this.getShape(partial.id) const shape = this.getShape(partial.id)
if (!shape) continue if (!shape) continue
// If the shape is locked and we're not setting isLocked to true, continue // If we're "forcing" the update, then we'll update the shape
if (this.isShapeOrAncestorLocked(shape) && !Object.hasOwn(partial, 'isLocked')) continue // regardless of whether it / its ancestor is locked
if (!this._shouldIgnoreShapeLock) {
if (shape.isLocked) {
// If the shape itself is locked (even if one of its ancestors is
// also locked) then only allow an update that unlocks the shape.
if (!(Object.hasOwn(partial, 'isLocked') && !partial.isLocked)) {
continue
}
} else if (this.isShapeOrAncestorLocked(shape)) {
// If the shape itself is unlocked, and any of the shape's
// ancestors are locked then we'll skip the update
continue
}
}
// Remove any animating shapes from the list of partials // Remove any animating shapes from the list of partials
this.animatingShapes.delete(partial.id) this.animatingShapes.delete(partial.id)
@ -7270,7 +7395,7 @@ export class Editor extends EventEmitter<TLEventMap> {
private _updateShapes = (_partials: (TLShapePartial | null | undefined)[]) => { private _updateShapes = (_partials: (TLShapePartial | null | undefined)[]) => {
if (this.getInstanceState().isReadonly) return if (this.getInstanceState().isReadonly) return
this.batch(() => { this.run(() => {
const updates = [] const updates = []
let shape: TLShape | undefined let shape: TLShape | undefined
@ -7323,27 +7448,32 @@ export class Editor extends EventEmitter<TLEventMap> {
deleteShapes(ids: TLShapeId[]): this deleteShapes(ids: TLShapeId[]): this
deleteShapes(shapes: TLShape[]): this deleteShapes(shapes: TLShape[]): this
deleteShapes(_ids: TLShapeId[] | TLShape[]): this { deleteShapes(_ids: TLShapeId[] | TLShape[]): this {
if (this.getInstanceState().isReadonly) return this
if (!Array.isArray(_ids)) { if (!Array.isArray(_ids)) {
throw Error('Editor.deleteShapes: must provide an array of shapes or shapeIds') throw Error('Editor.deleteShapes: must provide an array of shapes or shapeIds')
} }
const ids = this._getUnlockedShapeIds( const shapeIds =
typeof _ids[0] === 'string' ? (_ids as TLShapeId[]) : (_ids as TLShape[]).map((s) => s.id) typeof _ids[0] === 'string' ? (_ids as TLShapeId[]) : (_ids as TLShape[]).map((s) => s.id)
)
if (this.getInstanceState().isReadonly) return this // Normally we don't want to delete locked shapes, but if the force option is set, we'll delete them anyway
if (ids.length === 0) return this const shapeIdsToDelete = this._shouldIgnoreShapeLock
? shapeIds
: this._getUnlockedShapeIds(shapeIds)
const allIds = new Set(ids) if (shapeIdsToDelete.length === 0) return this
for (const id of ids) { // We also need to delete these shapes' descendants
const allShapeIdsToDelete = new Set<TLShapeId>(shapeIdsToDelete)
for (const id of shapeIdsToDelete) {
this.visitDescendants(id, (childId) => { this.visitDescendants(id, (childId) => {
allIds.add(childId) allShapeIdsToDelete.add(childId)
}) })
} }
const deletedIds = [...allIds] return this.run(() => this.store.remove([...allShapeIdsToDelete]))
return this.batch(() => this.store.remove(deletedIds))
} }
/** /**
@ -8137,7 +8267,7 @@ export class Editor extends EventEmitter<TLEventMap> {
}) })
) )
this.batch(() => { this.run(() => {
// Create any assets that need to be created // Create any assets that need to be created
if (assetsToCreate.length > 0) { if (assetsToCreate.length > 0) {
this.createAssets(assetsToCreate) this.createAssets(assetsToCreate)
@ -8369,7 +8499,8 @@ export class Editor extends EventEmitter<TLEventMap> {
} }
// todo: We only have to do this if there are multiple users in the document // todo: We only have to do this if there are multiple users in the document
this.history.ignore(() => { this.run(
() => {
this.store.put([ this.store.put([
{ {
id: TLPOINTER_ID, id: TLPOINTER_ID,
@ -8386,7 +8517,9 @@ export class Editor extends EventEmitter<TLEventMap> {
meta: {}, meta: {},
}, },
]) ])
}) },
{ history: 'ignore' }
)
} }
/** /**
@ -8643,7 +8776,7 @@ export class Editor extends EventEmitter<TLEventMap> {
private _pendingEventsForNextTick: TLEventInfo[] = [] private _pendingEventsForNextTick: TLEventInfo[] = []
private _flushEventsForTick(elapsed: number) { private _flushEventsForTick(elapsed: number) {
this.batch(() => { this.run(() => {
if (this._pendingEventsForNextTick.length > 0) { if (this._pendingEventsForNextTick.length > 0) {
const events = [...this._pendingEventsForNextTick] const events = [...this._pendingEventsForNextTick]
this._pendingEventsForNextTick.length = 0 this._pendingEventsForNextTick.length = 0
@ -9194,7 +9327,8 @@ function withIsolatedShapes<T>(
): T { ): T {
let result!: Result<T, unknown> let result!: Result<T, unknown>
editor.history.ignore(() => { editor.run(
() => {
const changes = editor.store.extractingChanges(() => { const changes = editor.store.extractingChanges(() => {
const bindingsWithBoth = new Set<TLBindingId>() const bindingsWithBoth = new Set<TLBindingId>()
const bindingsToRemove = new Set<TLBindingId>() const bindingsToRemove = new Set<TLBindingId>()
@ -9226,7 +9360,9 @@ function withIsolatedShapes<T>(
}) })
editor.store.applyDiff(reverseRecordsDiff(changes)) editor.store.applyDiff(reverseRecordsDiff(changes))
}) },
{ history: 'ignore' }
)
if (result.ok) { if (result.ok) {
return result.value return result.value

View file

@ -56,7 +56,7 @@ function createCounterHistoryManager() {
} }
const setName = (name = 'David') => { const setName = (name = 'David') => {
manager.ignore(() => _setName(name)) manager.batch(() => _setName(name), { history: 'ignore' })
} }
const setAge = (age = 35) => { const setAge = (age = 35) => {

View file

@ -72,17 +72,17 @@ export class HistoryManager<R extends UnknownRecord> {
})) }))
} }
onBatchComplete: () => void = () => void null
getNumUndos() { getNumUndos() {
return this.stacks.get().undos.length + (this.pendingDiff.isEmpty() ? 0 : 1) return this.stacks.get().undos.length + (this.pendingDiff.isEmpty() ? 0 : 1)
} }
getNumRedos() { getNumRedos() {
return this.stacks.get().redos.length return this.stacks.get().redos.length
} }
/** @internal */ /** @internal */
_isInBatch = false _isInBatch = false
batch = (fn: () => void, opts?: TLHistoryBatchOptions) => { batch = (fn: () => void, opts?: TLHistoryBatchOptions) => {
const previousState = this.state const previousState = this.state
@ -93,16 +93,13 @@ export class HistoryManager<R extends UnknownRecord> {
try { try {
if (this._isInBatch) { if (this._isInBatch) {
fn() transact(fn)
return this return this
} }
this._isInBatch = true this._isInBatch = true
try { try {
transact(() => { transact(fn)
fn()
this.onBatchComplete()
})
} catch (error) { } catch (error) {
this.annotateError(error) this.annotateError(error)
throw error throw error
@ -116,10 +113,6 @@ export class HistoryManager<R extends UnknownRecord> {
} }
} }
ignore(fn: () => void) {
return this.batch(fn, { history: 'ignore' })
}
// History // History
private _undo = ({ private _undo = ({
pushToRedoStack, pushToRedoStack,

View file

@ -87,7 +87,7 @@ export class ScribbleManager {
*/ */
tick = (elapsed: number) => { tick = (elapsed: number) => {
if (this.scribbleItems.size === 0) return if (this.scribbleItems.size === 0) return
this.editor.batch(() => { this.editor.run(() => {
this.scribbleItems.forEach((item) => { this.scribbleItems.forEach((item) => {
// let the item get at least eight points before // let the item get at least eight points before
// switching from starting to active // switching from starting to active

View file

@ -443,7 +443,7 @@ export function registerDefaultExternalContentHandlers(
} }
} }
editor.batch(() => { editor.run(() => {
if (shouldAlsoCreateAsset) { if (shouldAlsoCreateAsset) {
editor.createAssets([asset]) editor.createAssets([asset])
} }
@ -526,7 +526,7 @@ export async function createShapesForAssets(
} }
} }
editor.batch(() => { editor.run(() => {
// Create any assets // Create any assets
const assetsToCreate = assets.filter((asset) => !editor.getAsset(asset.id)) const assetsToCreate = assets.filter((asset) => !editor.getAsset(asset.id))
if (assetsToCreate.length) { if (assetsToCreate.length) {
@ -589,7 +589,7 @@ export function createEmptyBookmarkShape(
}, },
} }
editor.batch(() => { editor.run(() => {
editor.createShapes([partial]).select(partial.id) editor.createShapes([partial]).select(partial.id)
centerSelectionAroundPoint(editor, position) centerSelectionAroundPoint(editor, position)
}) })

View file

@ -234,7 +234,7 @@ const createBookmarkAssetOnUrlChange = debounce(async (editor: Editor, shape: TL
return return
} }
editor.batch(() => { editor.run(() => {
// Create the new asset // Create the new asset
editor.createAssets([asset]) editor.createAssets([asset])

View file

@ -40,7 +40,7 @@ export class DragAndDropManager {
private setDragTimer(movingShapes: TLShape[], duration: number, cb: () => void) { private setDragTimer(movingShapes: TLShape[], duration: number, cb: () => void) {
this.droppingNodeTimer = this.editor.timers.setTimeout(() => { this.droppingNodeTimer = this.editor.timers.setTimeout(() => {
this.editor.batch(() => { this.editor.run(() => {
this.handleDrag(this.editor.inputs.currentPagePoint, movingShapes, cb) this.handleDrag(this.editor.inputs.currentPagePoint, movingShapes, cb)
}) })
this.droppingNodeTimer = null this.droppingNodeTimer = null

View file

@ -19,14 +19,15 @@ export class Crop extends StateNode {
markId = '' markId = ''
override onEnter = () => { override onEnter = () => {
this.didCancel = false this.didCancel = false
this.markId = this.editor.history.mark() this.markId = 'crop'
this.editor.mark(this.markId)
} }
didCancel = false didCancel = false
override onExit = () => { override onExit = () => {
if (this.didCancel) { if (this.didCancel) {
this.editor.bailToMark(this.markId) this.editor.bailToMark(this.markId)
} else { } else {
this.editor.history.squashToMark(this.markId) this.editor.squashToMark(this.markId)
} }
} }
override onCancel = () => { override onCancel = () => {

View file

@ -142,7 +142,7 @@ export class PointingShape extends StateNode {
textLabel.bounds.containsPoint(pointInShapeSpace, 0) && textLabel.bounds.containsPoint(pointInShapeSpace, 0) &&
textLabel.hitTestPoint(pointInShapeSpace) textLabel.hitTestPoint(pointInShapeSpace)
) { ) {
this.editor.batch(() => { this.editor.run(() => {
this.editor.mark('editing on pointer up') this.editor.mark('editing on pointer up')
this.editor.select(selectingShape.id) this.editor.select(selectingShape.id)

View file

@ -298,7 +298,7 @@ function createNShapes(editor: Editor, n: number) {
} }
} }
editor.batch(() => { editor.run(() => {
editor.createShapes(shapesToCreate).setSelectedShapes(shapesToCreate.map((s) => s.id)) editor.createShapes(shapesToCreate).setSelectedShapes(shapesToCreate.map((s) => s.id))
}) })
} }

View file

@ -250,7 +250,7 @@ export const DefaultPageMenu = memo(function DefaultPageMenu() {
const handleCreatePageClick = useCallback(() => { const handleCreatePageClick = useCallback(() => {
if (isReadonlyMode) return if (isReadonlyMode) return
editor.batch(() => { editor.run(() => {
editor.mark('creating page') editor.mark('creating page')
const newPageId = PageRecordType.createId() const newPageId = PageRecordType.createId()
editor.createPage({ name: msg('page-menu.new-page-initial-name'), id: newPageId }) editor.createPage({ name: msg('page-menu.new-page-initial-name'), id: newPageId })
@ -399,7 +399,7 @@ export const DefaultPageMenu = memo(function DefaultPageMenu() {
editor.renamePage(page.id, name) editor.renamePage(page.id, name)
} }
} else { } else {
editor.batch(() => { editor.run(() => {
setIsEditing(true) setIsEditing(true)
editor.setCurrentPage(page.id) editor.setCurrentPage(page.id)
}) })

View file

@ -80,7 +80,7 @@ function useStyleChangeCallback() {
return React.useMemo( return React.useMemo(
() => () =>
function handleStyleChange<T>(style: StyleProp<T>, value: T) { function handleStyleChange<T>(style: StyleProp<T>, value: T) {
editor.batch(() => { editor.run(() => {
if (editor.isIn('select')) { if (editor.isIn('select')) {
editor.setStyleForSelectedShapes(style, value) editor.setStyleForSelectedShapes(style, value)
} }
@ -360,7 +360,7 @@ export function OpacitySlider() {
const handleOpacityValueChange = React.useCallback( const handleOpacityValueChange = React.useCallback(
(value: number) => { (value: number) => {
const item = tldrawSupportedOpacities[value] const item = tldrawSupportedOpacities[value]
editor.batch(() => { editor.run(() => {
if (editor.isIn('select')) { if (editor.isIn('select')) {
editor.setOpacityForSelectedShapes(item) editor.setOpacityForSelectedShapes(item)
} }

View file

@ -394,7 +394,7 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
if (!canApplySelectionAction()) return if (!canApplySelectionAction()) return
if (mustGoBackToSelectToolFirst()) return if (mustGoBackToSelectToolFirst()) return
editor.batch(() => { editor.run(() => {
trackEvent('convert-to-bookmark', { source }) trackEvent('convert-to-bookmark', { source })
const shapes = editor.getSelectedShapes() const shapes = editor.getSelectedShapes()
@ -439,7 +439,7 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('convert-to-embed', { source }) trackEvent('convert-to-embed', { source })
editor.batch(() => { editor.run(() => {
const ids = editor.getSelectedShapeIds() const ids = editor.getSelectedShapeIds()
const shapes = compact(ids.map((id) => editor.getShape(id))) const shapes = compact(ids.map((id) => editor.getShape(id)))
@ -970,7 +970,7 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
kbd: '$a', kbd: '$a',
readonlyOk: true, readonlyOk: true,
onSelect(source) { onSelect(source) {
editor.batch(() => { editor.run(() => {
if (mustGoBackToSelectToolFirst()) return if (mustGoBackToSelectToolFirst()) return
trackEvent('select-all-shapes', { source }) trackEvent('select-all-shapes', { source })
@ -1263,7 +1263,7 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
// this needs to be deferred because it causes the menu // this needs to be deferred because it causes the menu
// UI to unmount which puts us in a dodgy state // UI to unmount which puts us in a dodgy state
editor.timers.requestAnimationFrame(() => { editor.timers.requestAnimationFrame(() => {
editor.batch(() => { editor.run(() => {
trackEvent('toggle-focus-mode', { source }) trackEvent('toggle-focus-mode', { source })
clearDialogs() clearDialogs()
clearToasts() clearToasts()
@ -1362,7 +1362,7 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
onSelect(source) { onSelect(source) {
const newPageId = PageRecordType.createId() const newPageId = PageRecordType.createId()
const ids = editor.getSelectedShapeIds() const ids = editor.getSelectedShapeIds()
editor.batch(() => { editor.run(() => {
editor.mark('move_shapes_to_page') editor.mark('move_shapes_to_page')
editor.createPage({ name: msg('page-menu.new-page-initial-name'), id: newPageId }) editor.createPage({ name: msg('page-menu.new-page-initial-name'), id: newPageId })
editor.moveShapesToPage(ids, newPageId) editor.moveShapesToPage(ids, newPageId)
@ -1376,7 +1376,7 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
kbd: '?t', kbd: '?t',
onSelect(source) { onSelect(source) {
const style = DefaultColorStyle const style = DefaultColorStyle
editor.batch(() => { editor.run(() => {
editor.mark('change-color') editor.mark('change-color')
if (editor.isIn('select')) { if (editor.isIn('select')) {
editor.setStyleForSelectedShapes(style, 'white') editor.setStyleForSelectedShapes(style, 'white')
@ -1392,7 +1392,7 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
kbd: '?f', kbd: '?f',
onSelect(source) { onSelect(source) {
const style = DefaultFillStyle const style = DefaultFillStyle
editor.batch(() => { editor.run(() => {
editor.mark('change-fill') editor.mark('change-fill')
if (editor.isIn('select')) { if (editor.isIn('select')) {
editor.setStyleForSelectedShapes(style, 'fill') editor.setStyleForSelectedShapes(style, 'fill')

View file

@ -12,7 +12,7 @@ export function useMenuIsOpen(id: string, cb?: (isOpen: boolean) => void) {
(isOpen: boolean) => { (isOpen: boolean) => {
rIsOpen.current = isOpen rIsOpen.current = isOpen
editor.batch(() => { editor.run(() => {
if (isOpen) { if (isOpen) {
editor.complete() editor.complete()
editor.addOpenMenu(id) editor.addOpenMenu(id)

View file

@ -101,7 +101,7 @@ export function ToolsProvider({ overrides, children }: TLUiToolsProviderProps) {
kbd: id === 'rectangle' ? 'r' : id === 'ellipse' ? 'o' : undefined, kbd: id === 'rectangle' ? 'r' : id === 'ellipse' ? 'o' : undefined,
icon: ('geo-' + id) as TLUiIconType, icon: ('geo-' + id) as TLUiIconType,
onSelect(source: TLUiEventSource) { onSelect(source: TLUiEventSource) {
editor.batch(() => { editor.run(() => {
editor.setStyleForNextShapes(GeoShapeGeoStyle, id) editor.setStyleForNextShapes(GeoShapeGeoStyle, id)
editor.setCurrentTool('geo') editor.setCurrentTool('geo')
trackEvent('select-tool', { source, id: `geo-${id}` }) trackEvent('select-tool', { source, id: `geo-${id}` })

View file

@ -17,7 +17,7 @@ export function removeFrame(editor: Editor, ids: TLShapeId[]) {
if (!frames.length) return if (!frames.length) return
const allChildren: TLShapeId[] = [] const allChildren: TLShapeId[] = []
editor.batch(() => { editor.run(() => {
frames.map((frame) => { frames.map((frame) => {
const children = editor.getSortedChildIdsForParent(frame.id) const children = editor.getSortedChildIdsForParent(frame.id)
if (children.length) { if (children.length) {
@ -66,7 +66,7 @@ export function fitFrameToContent(editor: Editor, id: TLShapeId, opts = {} as {
if (dx === 0 && dy === 0 && frame.props.w === w && frame.props.h === h) return if (dx === 0 && dy === 0 && frame.props.w === w && frame.props.h === h) return
const diff = new Vec(dx, dy).rot(frame.rotation) const diff = new Vec(dx, dy).rot(frame.rotation)
editor.batch(() => { editor.run(() => {
const changes: TLShapePartial[] = childIds.map((child) => { const changes: TLShapePartial[] = childIds.map((child) => {
const shape = editor.getShape(child)! const shape = editor.getShape(child)!
return { return {

View file

@ -34,7 +34,7 @@ const TLDRAW_V1_VERSION = 15.5
/** @internal */ /** @internal */
export function buildFromV1Document(editor: Editor, _document: unknown) { export function buildFromV1Document(editor: Editor, _document: unknown) {
let document = _document as TLV1Document let document = _document as TLV1Document
editor.batch(() => { editor.run(() => {
document = migrate(document, TLDRAW_V1_VERSION) document = migrate(document, TLDRAW_V1_VERSION)
// Cancel any interactions / states // Cancel any interactions / states
editor.cancel().cancel().cancel().cancel() editor.cancel().cancel().cancel().cancel()
@ -594,7 +594,7 @@ export function buildFromV1Document(editor: Editor, _document: unknown) {
// Set the current page to the first page again // Set the current page to the first page again
editor.setCurrentPage(firstPageId) editor.setCurrentPage(firstPageId)
editor.history.clear() editor.clearHistory()
editor.selectNone() editor.selectNone()
const bounds = editor.getCurrentPageBounds() const bounds = editor.getCurrentPageBounds()

View file

@ -306,7 +306,7 @@ export async function parseAndLoadDocument(
editor.store.put(nonShapes, 'initialize') editor.store.put(nonShapes, 'initialize')
editor.store.ensureStoreIsUsable() editor.store.ensureStoreIsUsable()
editor.store.put(shapes, 'initialize') editor.store.put(shapes, 'initialize')
editor.history.clear() editor.clearHistory()
// Put the old bounds back in place // Put the old bounds back in place
editor.updateViewportScreenBounds(initialBounds) editor.updateViewportScreenBounds(initialBounds)

View file

@ -131,6 +131,10 @@ export class TestEditor extends Editor {
}) })
} }
getHistory() {
return this.history
}
private _lastCreatedShapes: TLShape[] = [] private _lastCreatedShapes: TLShape[] = []
/** /**

View file

@ -172,3 +172,137 @@ describe('Unlocking', () => {
expect(getLockedStatus()).toStrictEqual([false, false]) expect(getLockedStatus()).toStrictEqual([false, false])
}) })
}) })
describe('When forced', () => {
it('Can be deleted', () => {
editor.run(
() => {
const numberOfShapesBefore = editor.getCurrentPageShapes().length
editor.deleteShapes([ids.lockedShapeA])
expect(editor.getCurrentPageShapes().length).toBe(numberOfShapesBefore - 1)
},
{ ignoreShapeLock: true }
)
})
it('Can be changed', () => {
editor.run(
() => {
editor.updateShapes([{ id: ids.lockedShapeA, type: 'geo', x: 100 }])
expect(editor.getShape(ids.lockedShapeA)!.x).toBe(100)
},
{ ignoreShapeLock: true }
)
})
it('Can be grouped / ungrouped', () => {
editor.run(
() => {
const shapeCount = editor.getCurrentPageShapes().length
editor.groupShapes([ids.lockedShapeA, ids.unlockedShapeA, ids.unlockedShapeB])
expect(editor.getCurrentPageShapes().length).toBe(shapeCount + 1)
expect(editor.getShape(ids.lockedShapeA)!.parentId).not.toBe(editor.getCurrentPageId())
},
{ ignoreShapeLock: true }
)
})
it('Cannot be moved', () => {
editor.run(
() => {
const shape = editor.getShape(ids.lockedShapeA)
editor.pointerDown(150, 150, { target: 'shape', shape })
editor.expectToBeIn('select.pointing_canvas')
editor.pointerMove(10, 10)
editor.expectToBeIn('select.brushing')
editor.pointerUp()
editor.expectToBeIn('select.idle')
},
{ ignoreShapeLock: true }
)
})
it('Can be selected with select all', () => {
editor.run(
() => {
editor.selectAll()
expect(editor.getSelectedShapeIds()).toEqual([ids.unlockedShapeA, ids.unlockedShapeB])
},
{ ignoreShapeLock: true }
)
})
it('Cannot be selected by clicking', () => {
editor.run(
() => {
const shape = editor.getShape(ids.lockedShapeA)!
editor
.pointerDown(10, 10, { target: 'shape', shape })
.expectToBeIn('select.pointing_canvas')
.pointerUp()
.expectToBeIn('select.idle')
expect(editor.getSelectedShapeIds()).not.toContain(shape.id)
},
{ ignoreShapeLock: true }
)
})
it('Cannot be edited', () => {
editor.run(
() => {
const shape = editor.getShape(ids.lockedShapeA)!
const shapeCount = editor.getCurrentPageShapes().length
// We create a new shape and we edit that one
editor.doubleClick(10, 10, { target: 'shape', shape }).expectToBeIn('select.editing_shape')
expect(editor.getCurrentPageShapes().length).toBe(shapeCount + 1)
expect(editor.getSelectedShapeIds()).not.toContain(shape.id)
},
{ ignoreShapeLock: true }
)
})
})
it('does not update a locked shape, even if spreading in a full shape', () => {
const myShapeId = createShapeId()
editor.createShape({ id: myShapeId, type: 'geo', isLocked: true })
const myLockedShape = editor.getShape(myShapeId)!
// include the `isLocked` property, but don't change it
editor.updateShape({ ...myLockedShape, x: 100 })
expect(editor.getShape(myShapeId)).toMatchObject(myLockedShape)
})
it('works when forced', () => {
const myShapeId = createShapeId()
editor.createShape({ id: myShapeId, type: 'geo', isLocked: true })
const myLockedShape = editor.getShape(myShapeId)!
// no change from update
editor.updateShape({ ...myLockedShape, x: 100 })
expect(editor.getShape(myShapeId)).toMatchObject(myLockedShape)
// no change from delete
editor.deleteShapes([myLockedShape])
expect(editor.getShape(myShapeId)).toMatchObject(myLockedShape)
// update works
editor.run(
() => {
editor.updateShape({ ...myLockedShape, x: 100 })
},
{ ignoreShapeLock: true }
)
expect(editor.getShape(myShapeId)).toMatchObject({ ...myLockedShape, x: 100 })
// delete works
editor.run(
() => {
editor.deleteShapes([myLockedShape])
},
{ ignoreShapeLock: true }
)
expect(editor.getShape(myShapeId)).toBeUndefined()
})

View file

@ -59,28 +59,28 @@ describe('Editor.moveShapesToPage', () => {
}) })
it('Adds undo items', () => { it('Adds undo items', () => {
editor.history.clear() editor.getHistory().clear()
expect(editor.history.getNumUndos()).toBe(0) expect(editor.getHistory().getNumUndos()).toBe(0)
editor.moveShapesToPage([ids.box1], ids.page2) editor.moveShapesToPage([ids.box1], ids.page2)
expect(editor.history.getNumUndos()).toBe(1) expect(editor.getHistory().getNumUndos()).toBe(1)
}) })
it('Does nothing on an empty ids array', () => { it('Does nothing on an empty ids array', () => {
editor.history.clear() editor.getHistory().clear()
editor.moveShapesToPage([], ids.page2) editor.moveShapesToPage([], ids.page2)
expect(editor.history.getNumUndos()).toBe(0) expect(editor.getHistory().getNumUndos()).toBe(0)
}) })
it('Does nothing if the new page is not found or is deleted', () => { it('Does nothing if the new page is not found or is deleted', () => {
editor.history.clear() editor.getHistory().clear()
editor.moveShapesToPage([ids.box1], PageRecordType.createId('missing')) editor.moveShapesToPage([ids.box1], PageRecordType.createId('missing'))
expect(editor.history.getNumUndos()).toBe(0) expect(editor.getHistory().getNumUndos()).toBe(0)
}) })
it('Does not move shapes to the current page', () => { it('Does not move shapes to the current page', () => {
editor.history.clear() editor.getHistory().clear()
editor.moveShapesToPage([ids.box1], ids.page1) editor.moveShapesToPage([ids.box1], ids.page1)
expect(editor.history.getNumUndos()).toBe(0) expect(editor.getHistory().getNumUndos()).toBe(0)
}) })
it('Restores on undo / redo', () => { it('Restores on undo / redo', () => {

View file

@ -24,13 +24,13 @@ describe('resizing a shape', () => {
editor.createShapes([{ id: ids.boxA, type: 'geo', props: { w: 100, h: 100 } }]) editor.createShapes([{ id: ids.boxA, type: 'geo', props: { w: 100, h: 100 } }])
editor.mark('start') editor.mark('start')
const startHistoryLength = editor.history.getNumUndos() const startHistoryLength = editor.getHistory().getNumUndos()
editor.resizeShape(ids.boxA, { x: 2, y: 2 }) editor.resizeShape(ids.boxA, { x: 2, y: 2 })
expect(editor.history.getNumUndos()).toBe(startHistoryLength + 1) expect(editor.getHistory().getNumUndos()).toBe(startHistoryLength + 1)
editor.resizeShape(ids.boxA, { x: 2, y: 2 }) editor.resizeShape(ids.boxA, { x: 2, y: 2 })
expect(editor.history.getNumUndos()).toBe(startHistoryLength + 1) expect(editor.getHistory().getNumUndos()).toBe(startHistoryLength + 1)
editor.resizeShape(ids.boxA, { x: 2, y: 2 }) editor.resizeShape(ids.boxA, { x: 2, y: 2 })
expect(editor.history.getNumUndos()).toBe(startHistoryLength + 1) expect(editor.getHistory().getNumUndos()).toBe(startHistoryLength + 1)
expect(editor.getShapePageBounds(ids.boxA)).toCloselyMatchObject({ expect(editor.getShapePageBounds(ids.boxA)).toCloselyMatchObject({
w: 800, w: 800,

View file

@ -46,14 +46,14 @@ describe('setCurrentPage', () => {
}) })
it('squashes', () => { it('squashes', () => {
const page1Id = editor.getCurrentPageId()
const page2Id = PageRecordType.createId('page2') const page2Id = PageRecordType.createId('page2')
editor.createPage({ name: 'New Page 2', id: page2Id }) editor.createPage({ name: 'New Page 2', id: page2Id })
editor.setCurrentPage(page1Id)
editor.history.clear() editor.setCurrentPage(page2Id)
editor.setCurrentPage(editor.getPages()[1].id) editor.setCurrentPage(page2Id)
editor.setCurrentPage(editor.getPages()[0].id) editor.undo()
editor.setCurrentPage(editor.getPages()[0].id) expect(editor.getCurrentPageId()).toBe(page1Id)
expect(editor.history.getNumUndos()).toBe(1)
}) })
it('preserves the undo stack', () => { it('preserves the undo stack', () => {
@ -61,14 +61,14 @@ describe('setCurrentPage', () => {
const page2Id = PageRecordType.createId('page2') const page2Id = PageRecordType.createId('page2')
editor.createPage({ name: 'New Page 2', id: page2Id }) editor.createPage({ name: 'New Page 2', id: page2Id })
editor.history.clear() editor.clearHistory()
editor.createShapes([{ type: 'geo', id: boxId, props: { w: 100, h: 100 } }]) editor.createShapes([{ type: 'geo', id: boxId, props: { w: 100, h: 100 } }])
editor.undo() editor.undo()
editor.setCurrentPage(editor.getPages()[1].id) editor.setCurrentPage(editor.getPages()[1].id)
editor.setCurrentPage(editor.getPages()[0].id) editor.setCurrentPage(editor.getPages()[0].id)
editor.setCurrentPage(editor.getPages()[0].id) editor.setCurrentPage(editor.getPages()[0].id)
expect(editor.getShape(boxId)).toBeUndefined() expect(editor.getShape(boxId)).toBeUndefined()
expect(editor.history.getNumUndos()).toBe(1) // expect(editor.history.getNumUndos()).toBe(1)
editor.redo() editor.redo()
expect(editor.getShape(boxId)).not.toBeUndefined() expect(editor.getShape(boxId)).not.toBeUndefined()
}) })