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)
if (newPageId) {
if (editor.store.has(newPageId as TLPageId)) {
editor.history.ignore(() => {
editor.setCurrentPage(newPageId as TLPageId)
})
editor.run(
() => {
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.ensureStoreIsUsable()
editor.history.clear()
editor.clearHistory()
// Put the old bounds back in place
editor.updateViewportScreenBounds(bounds)
editor.updateInstanceState({ isFocused })

View file

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

View file

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

View file

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

View file

@ -134,7 +134,12 @@ export { createTLUser, type TLUser } from './lib/config/createTLUser'
export { type TLAnyBindingUtilConstructor } from './lib/config/defaultBindings'
export { coreShapes, type TLAnyShapeUtilConstructor } from './lib/config/defaultShapes'
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 {
BindingUtil,
type BindingOnChangeOptions,

View file

@ -497,10 +497,15 @@ export function useOnMount(onMount?: TLOnMountHandler) {
const onMountEvent = useEvent((editor: Editor) => {
let teardown: (() => void) | void = undefined
editor.history.ignore(() => {
teardown = onMount?.(editor)
editor.emit('mount')
})
// 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)
editor.emit('mount')
},
{ history: 'ignore' }
)
window.tldrawReady = true
return teardown
})

View file

@ -218,6 +218,14 @@ export interface TLEditorOptions {
licenseKey?: string
}
/**
* Options for {@link Editor.(run:1)}.
* @public
*/
export interface TLEditorRunOptions extends TLHistoryBatchOptions {
ignoreShapeLock?: boolean
}
/** @public */
export class Editor extends EventEmitter<TLEventMap> {
constructor({
@ -671,16 +679,19 @@ export class Editor extends EventEmitter<TLEventMap> {
)
this.disposables.add(this.history.dispose)
this.history.ignore(() => {
this.store.ensureStoreIsUsable()
this.run(
() => {
this.store.ensureStoreIsUsable()
// clear ephemeral state
this._updateCurrentPageState({
editingShapeId: null,
hoveredShapeId: null,
erasingShapeIds: [],
})
})
// clear ephemeral state
this._updateCurrentPageState({
editingShapeId: null,
hoveredShapeId: null,
erasingShapeIds: [],
})
},
{ history: 'ignore' }
)
if (initialState && this.root.children[initialState] === undefined) {
throw Error(`No state found for initialState "${initialState}".`)
@ -913,7 +924,7 @@ export class Editor extends EventEmitter<TLEventMap> {
*
* @readonly
*/
readonly history: HistoryManager<TLRecord>
protected readonly history: HistoryManager<TLRecord>
/**
* Undo to the last mark.
@ -958,6 +969,11 @@ export class Editor extends EventEmitter<TLEventMap> {
return this
}
clearHistory() {
this.history.clear()
return this
}
/**
* Whether the app can redo.
*
@ -986,6 +1002,23 @@ export class Editor extends EventEmitter<TLEventMap> {
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.
*
@ -1016,16 +1049,53 @@ export class Editor extends EventEmitter<TLEventMap> {
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
*/
batch(fn: () => void, opts?: TLHistoryBatchOptions): this {
this.history.batch(fn, opts)
run(fn: () => void, opts?: TLEditorRunOptions): this {
const previousIgnoreShapeLock = this._shouldIgnoreShapeLock
this._shouldIgnoreShapeLock = opts?.ignoreShapeLock ?? previousIgnoreShapeLock
try {
this.history.batch(fn, opts)
} finally {
this._shouldIgnoreShapeLock = previousIgnoreShapeLock
}
return this
}
/**
* @deprecated Use `Editor.run` instead.
*/
batch(fn: () => void, opts?: TLEditorRunOptions): this {
return this.run(fn, opts)
}
/* --------------------- Errors --------------------- */
/** @internal */
@ -1258,9 +1328,12 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public
**/
updateDocumentSettings(settings: Partial<TLDocument>): this {
this.history.ignore(() => {
this.store.put([{ ...this.getDocumentSettings(), ...settings }])
})
this.run(
() => {
this.store.put([{ ...this.getDocumentSettings(), ...settings }])
},
{ history: 'ignore' }
)
return this
}
@ -1306,7 +1379,7 @@ export class Editor extends EventEmitter<TLEventMap> {
partial: Partial<Omit<TLInstance, 'currentPageId'>>,
opts?: TLHistoryBatchOptions
) => {
this.batch(() => {
this.run(() => {
this.store.put([
{
...this.getInstanceState(),
@ -1456,39 +1529,27 @@ export class Editor extends EventEmitter<TLEventMap> {
* @example
* ```ts
* 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 historyOptions - The history options for the change.
*
* @public
*/
updateCurrentPageState(
partial: Partial<
Omit<TLInstancePageState, 'selectedShapeIds' | 'editingShapeId' | 'pageId' | 'focusedGroupId'>
>,
historyOptions?: TLHistoryBatchOptions
>
): this {
this._updateCurrentPageState(partial, historyOptions)
this._updateCurrentPageState(partial)
return this
}
_updateCurrentPageState = (
partial: Partial<Omit<TLInstancePageState, 'selectedShapeIds'>>,
historyOptions?: TLHistoryBatchOptions
private _updateCurrentPageState = (
partial: Partial<Omit<TLInstancePageState, 'selectedShapeIds'>>
) => {
this.batch(
() => {
this.store.update(partial.id ?? this.getCurrentPageState().id, (state) => ({
...state,
...partial,
}))
},
{
history: 'ignore',
...historyOptions,
}
)
this.store.update(partial.id ?? this.getCurrentPageState().id, (state) => ({
...state,
...partial,
}))
}
/**
@ -1525,7 +1586,7 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public
*/
setSelectedShapes(shapes: TLShapeId[] | TLShape[]): this {
return this.batch(
return this.run(
() => {
const ids = shapes.map((shape) => (typeof shape === 'string' ? shape : shape.id))
const { selectedShapeIds: prevSelectedShapeIds } = this.getCurrentPageState()
@ -1804,7 +1865,7 @@ export class Editor extends EventEmitter<TLEventMap> {
if (id === this.getFocusedGroupId()) return this
return this.batch(
return this.run(
() => {
this.store.update(this.getCurrentPageState().id, (s) => ({ ...s, focusedGroupId: id }))
},
@ -1875,13 +1936,23 @@ export class Editor extends EventEmitter<TLEventMap> {
if (id) {
const shape = this.getShape(id)
if (shape && this.getShapeUtil(shape).canEdit(shape)) {
this._updateCurrentPageState({ editingShapeId: id })
this.run(
() => {
this._updateCurrentPageState({ editingShapeId: id })
},
{ history: 'ignore' }
)
return this
}
}
// Either we just set the editing id to null, or the shape was missing or not editable
this._updateCurrentPageState({ editingShapeId: null })
this.run(
() => {
this._updateCurrentPageState({ editingShapeId: null })
},
{ history: 'ignore' }
)
}
return this
}
@ -1923,7 +1994,12 @@ export class Editor extends EventEmitter<TLEventMap> {
setHoveredShape(shape: TLShapeId | TLShape | null): this {
const id = typeof shape === 'string' ? shape : shape?.id ?? null
if (id === this.getHoveredShapeId()) return this
this.updateCurrentPageState({ hoveredShapeId: id }, { history: 'ignore' })
this.run(
() => {
this.updateCurrentPageState({ hoveredShapeId: id })
},
{ history: 'ignore' }
)
return this
}
@ -1966,7 +2042,12 @@ export class Editor extends EventEmitter<TLEventMap> {
? (shapes as TLShapeId[])
: (shapes as TLShape[]).map((shape) => shape.id)
// always ephemeral
this.updateCurrentPageState({ hintingShapeIds: dedupe(ids) }, { history: 'ignore' })
this.run(
() => {
this._updateCurrentPageState({ hintingShapeIds: dedupe(ids) })
},
{ history: 'ignore' }
)
return this
}
@ -2011,22 +2092,25 @@ export class Editor extends EventEmitter<TLEventMap> {
: (shapes as TLShape[]).map((shape) => shape.id)
ids.sort() // sort the incoming ids
const erasingShapeIds = this.getErasingShapeIds()
this.history.ignore(() => {
if (ids.length === erasingShapeIds.length) {
// 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;
// if we find any unequal, then we know the new ids are different.
for (let i = 0; i < ids.length; i++) {
if (ids[i] !== erasingShapeIds[i]) {
this._updateCurrentPageState({ erasingShapeIds: ids })
break
this.run(
() => {
if (ids.length === erasingShapeIds.length) {
// 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;
// if we find any unequal, then we know the new ids are different.
for (let i = 0; i < ids.length; i++) {
if (ids[i] !== erasingShapeIds[i]) {
this._updateCurrentPageState({ erasingShapeIds: ids })
break
}
}
} else {
// if the ids are a different length, then we know they're different.
this._updateCurrentPageState({ erasingShapeIds: ids })
}
} else {
// if the ids are a different length, then we know they're different.
this._updateCurrentPageState({ erasingShapeIds: ids })
}
})
},
{ history: 'ignore' }
)
return this
}
@ -2059,15 +2143,20 @@ export class Editor extends EventEmitter<TLEventMap> {
setCroppingShape(shape: TLShapeId | TLShape | null): this {
const id = typeof shape === 'string' ? shape : shape?.id ?? null
if (id !== this.getCroppingShapeId()) {
if (!id) {
this.updateCurrentPageState({ croppingShapeId: null })
} else {
const shape = this.getShape(id)!
const util = this.getShapeUtil(shape)
if (shape && util.canCrop(shape)) {
this.updateCurrentPageState({ croppingShapeId: id })
}
}
this.run(
() => {
if (!id) {
this.updateCurrentPageState({ croppingShapeId: null })
} else {
const shape = this.getShape(id)!
const util = this.getShapeUtil(shape)
if (shape && util.canCrop(shape)) {
this.updateCurrentPageState({ croppingShapeId: id })
}
}
},
{ history: 'ignore' }
)
}
return this
}
@ -2459,11 +2548,14 @@ export class Editor extends EventEmitter<TLEventMap> {
return this
}
this.batch(() => {
transact(() => {
const camera = { ...currentCamera, x, y, z }
this.history.ignore(() => {
this.store.put([camera]) // include id and meta here
})
this.run(
() => {
this.store.put([camera]) // include id and meta here
},
{ history: 'ignore' }
)
// 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)
@ -2986,7 +3078,7 @@ export class Editor extends EventEmitter<TLEventMap> {
if (!presence) return this
this.batch(() => {
this.run(() => {
// If we're following someone, stop following them
if (this.getInstanceState().followingUserId !== null) {
this.stopFollowingUser()
@ -3284,13 +3376,16 @@ export class Editor extends EventEmitter<TLEventMap> {
this.getPage(leaderPresence.currentPageId)
) {
// if the page changed, switch page
this.history.ignore(() => {
// sneaky store.put here, we can't go through setCurrentPage because it calls stopFollowingUser
this.store.put([
{ ...this.getInstanceState(), currentPageId: leaderPresence.currentPageId },
])
this._isLockedOnFollowingUser.set(true)
})
this.run(
() => {
// sneaky store.put here, we can't go through setCurrentPage because it calls stopFollowingUser
this.store.put([
{ ...this.getInstanceState(), currentPageId: leaderPresence.currentPageId },
])
this._isLockedOnFollowingUser.set(true)
},
{ history: 'ignore' }
)
}
})
@ -3384,14 +3479,17 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public
*/
stopFollowingUser(): this {
this.history.ignore(() => {
// commit the current camera to the store
this.store.put([this.getCamera()])
// this must happen after the camera is committed
this._isLockedOnFollowingUser.set(false)
this.updateInstanceState({ followingUserId: null })
this.emit('stop-following')
})
this.run(
() => {
// commit the current camera to the store
this.store.put([this.getCamera()])
// this must happen after the camera is committed
this._isLockedOnFollowingUser.set(false)
this.updateInstanceState({ followingUserId: null })
this.emit('stop-following')
},
{ history: 'ignore' }
)
return this
}
@ -3681,8 +3779,10 @@ export class Editor extends EventEmitter<TLEventMap> {
// finish off any in-progress interactions
this.complete()
return this.batch(
() => this.store.put([{ ...this.getInstanceState(), currentPageId: pageId }]),
return this.run(
() => {
this.store.put([{ ...this.getInstanceState(), currentPageId: pageId }])
},
{ history: 'record-preserveRedoStack' }
)
}
@ -3705,7 +3805,7 @@ export class Editor extends EventEmitter<TLEventMap> {
const prev = this.getPage(partial.id)
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
*/
createPage(page: Partial<TLPage>): this {
this.history.batch(() => {
this.run(() => {
if (this.getInstanceState().isReadonly) return
if (this.getPages().length >= this.options.maxPages) return
const pages = this.getPages()
@ -3764,7 +3864,7 @@ export class Editor extends EventEmitter<TLEventMap> {
*/
deletePage(page: TLPageId | TLPage): this {
const id = typeof page === 'string' ? page : page.id
this.batch(() => {
this.run(() => {
if (this.getInstanceState().isReadonly) return
const pages = this.getPages()
if (pages.length === 1) return
@ -3799,7 +3899,7 @@ export class Editor extends EventEmitter<TLEventMap> {
const prevCamera = { ...this.getCamera() }
const content = this.getContentFromCurrentPage(this.getSortedChildIdsForParent(freshPage.id))
this.batch(() => {
this.run(() => {
const pages = this.getPages()
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 {
if (this.getInstanceState().isReadonly) 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
}
@ -3889,14 +3989,17 @@ export class Editor extends EventEmitter<TLEventMap> {
updateAssets(assets: TLAssetPartial[]): this {
if (this.getInstanceState().isReadonly) return this
if (assets.length <= 0) return this
this.history.ignore(() => {
this.store.put(
assets.map((partial) => ({
...this.store.get(partial.id)!,
...partial,
}))
)
})
this.run(
() => {
this.store.put(
assets.map((partial) => ({
...this.store.get(partial.id)!,
...partial,
}))
)
},
{ history: 'ignore' }
)
return this
}
@ -3921,7 +4024,7 @@ export class Editor extends EventEmitter<TLEventMap> {
: (assets as TLAsset[]).map((a) => a.id)
if (ids.length <= 0) return this
this.history.ignore(() => this.store.remove(ids))
this.run(() => this.store.remove(ids), { history: 'ignore' })
return this
}
@ -5019,40 +5122,37 @@ export class Editor extends EventEmitter<TLEventMap> {
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
// times when a locked shape's parent is deleted... and we need to put that shape somewhere!
const lockedShapes = shapesToReparent.filter((shape) => shape.isLocked)
// Ignore locked shapes so that we can reparent locked shapes, for example
// when a locked shape's parent is deleted.
this.run(
() => {
for (let i = 0; i < shapesToReparent.length; i++) {
const shape = shapesToReparent[i]
if (lockedShapes.length) {
// If we have locked shapes, unlock them before we update them
this.updateShapes(lockedShapes.map(({ id, type }) => ({ id, type, isLocked: false })))
}
const pageTransform = this.getShapePageTransform(shape)!
if (!pageTransform) continue
for (let i = 0; i < shapesToReparent.length; i++) {
const shape = shapesToReparent[i]
const pagePoint = pageTransform.point()
if (!pagePoint) continue
const pageTransform = this.getShapePageTransform(shape)!
if (!pageTransform) continue
const newPoint = invertedParentTransform.applyToPoint(pagePoint)
const newRotation = pageTransform.rotation() - parentPageRotation
const pagePoint = pageTransform.point()
if (!pagePoint) continue
changes.push({
id: shape.id,
type: shape.type,
parentId: parentId,
x: newPoint.x,
y: newPoint.y,
rotation: newRotation,
index: indices[i],
})
}
const newPoint = invertedParentTransform.applyToPoint(pagePoint)
const newRotation = pageTransform.rotation() - parentPageRotation
changes.push({
id: shape.id,
type: shape.type,
parentId: parentId,
x: newPoint.x,
y: newPoint.y,
rotation: newRotation,
index: indices[i],
isLocked: shape.isLocked, // this will re-lock locked shapes
})
}
this.updateShapes(changes)
this.updateShapes(changes)
},
{ ignoreShapeLock: true }
)
return this
}
@ -5520,7 +5620,7 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public
*/
duplicateShapes(shapes: TLShapeId[] | TLShape[], offset?: VecLike): this {
this.history.batch(() => {
this.run(() => {
const ids =
typeof shapes[0] === 'string'
? (shapes as TLShapeId[])
@ -5666,7 +5766,7 @@ export class Editor extends EventEmitter<TLEventMap> {
const fromPageZ = this.getCamera().z
this.history.batch(() => {
this.run(() => {
// Delete the shapes on the current page
this.deleteShapes(ids)
@ -5723,7 +5823,7 @@ export class Editor extends EventEmitter<TLEventMap> {
}
}
}
this.batch(() => {
this.run(() => {
if (allUnlocked) {
this.updateShapes(
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)))
).center
this.batch(() => {
this.run(() => {
for (const shape of shapesToFlip) {
const bounds = this.getShapeGeometry(shape).bounds
const initialPageTransform = this.getShapePageTransform(shape.id)
@ -6383,7 +6483,7 @@ export class Editor extends EventEmitter<TLEventMap> {
switch (operation) {
case 'vertical': {
this.batch(() => {
this.run(() => {
for (const shape of shapesToStretch) {
const pageRotation = this.getShapePageTransform(shape)!.rotation()
if (pageRotation % PI2) continue
@ -6407,7 +6507,7 @@ export class Editor extends EventEmitter<TLEventMap> {
break
}
case 'horizontal': {
this.batch(() => {
this.run(() => {
for (const shape of shapesToStretch) {
const bounds = shapeBounds[shape.id]
const pageBounds = shapePageBounds[shape.id]
@ -6773,7 +6873,7 @@ export class Editor extends EventEmitter<TLEventMap> {
const focusedGroupId = this.getFocusedGroupId()
return this.batch(() => {
this.run(() => {
// 1. Parents
// 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)
})
return this
}
private animatingShapes = new Map<TLShapeId, string>()
@ -7092,7 +7194,11 @@ export class Editor extends EventEmitter<TLEventMap> {
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 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
this.batch(() => {
this.run(() => {
this.createShapes<TLGroupShape>([
{
id: groupId,
@ -7155,18 +7261,24 @@ export class Editor extends EventEmitter<TLEventMap> {
ungroupShapes(ids: TLShapeId[], options?: Partial<{ select: boolean }>): this
ungroupShapes(shapes: TLShape[], options?: Partial<{ select: boolean }>): this
ungroupShapes(shapes: TLShapeId[] | TLShape[], options = {} as Partial<{ select: boolean }>) {
if (this.getInstanceState().isReadonly) return this
const { select = true } = options
const ids =
typeof shapes[0] === 'string'
? (shapes as TLShapeId[])
: (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 not already in idle, cancel the current interaction (get back to idle)
if (!this.isIn('select.idle')) {
this.cancel()
}
@ -7179,7 +7291,7 @@ export class Editor extends EventEmitter<TLEventMap> {
// Get all groups in the selection
const groups: TLGroupShape[] = []
compact(ids.map((id) => this.getShape(id))).forEach((shape) => {
shapesToUngroup.forEach((shape) => {
if (this.isShapeOfType<TLGroupShape>(shape, 'group')) {
groups.push(shape)
} else {
@ -7189,7 +7301,7 @@ export class Editor extends EventEmitter<TLEventMap> {
if (groups.length === 0) return this
this.batch(() => {
this.run(() => {
let group: TLGroupShape
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)
if (!shape) continue
// If the shape is locked and we're not setting isLocked to true, continue
if (this.isShapeOrAncestorLocked(shape) && !Object.hasOwn(partial, 'isLocked')) continue
// If we're "forcing" the update, then we'll update the shape
// 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
this.animatingShapes.delete(partial.id)
@ -7270,7 +7395,7 @@ export class Editor extends EventEmitter<TLEventMap> {
private _updateShapes = (_partials: (TLShapePartial | null | undefined)[]) => {
if (this.getInstanceState().isReadonly) return
this.batch(() => {
this.run(() => {
const updates = []
let shape: TLShape | undefined
@ -7323,27 +7448,32 @@ export class Editor extends EventEmitter<TLEventMap> {
deleteShapes(ids: TLShapeId[]): this
deleteShapes(shapes: TLShape[]): this
deleteShapes(_ids: TLShapeId[] | TLShape[]): this {
if (this.getInstanceState().isReadonly) return this
if (!Array.isArray(_ids)) {
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)
)
if (this.getInstanceState().isReadonly) return this
if (ids.length === 0) return this
// Normally we don't want to delete locked shapes, but if the force option is set, we'll delete them anyway
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) => {
allIds.add(childId)
allShapeIdsToDelete.add(childId)
})
}
const deletedIds = [...allIds]
return this.batch(() => this.store.remove(deletedIds))
return this.run(() => this.store.remove([...allShapeIdsToDelete]))
}
/**
@ -8137,7 +8267,7 @@ export class Editor extends EventEmitter<TLEventMap> {
})
)
this.batch(() => {
this.run(() => {
// Create any assets that need to be created
if (assetsToCreate.length > 0) {
this.createAssets(assetsToCreate)
@ -8369,24 +8499,27 @@ export class Editor extends EventEmitter<TLEventMap> {
}
// todo: We only have to do this if there are multiple users in the document
this.history.ignore(() => {
this.store.put([
{
id: TLPOINTER_ID,
typeName: 'pointer',
x: currentPagePoint.x,
y: currentPagePoint.y,
lastActivityTimestamp:
// If our pointer moved only because we're following some other user, then don't
// update our last activity timestamp; otherwise, update it to the current timestamp.
info.type === 'pointer' && info.pointerId === INTERNAL_POINTER_IDS.CAMERA_MOVE
? this.store.unsafeGetWithoutCapture(TLPOINTER_ID)?.lastActivityTimestamp ??
this._tickManager.now
: this._tickManager.now,
meta: {},
},
])
})
this.run(
() => {
this.store.put([
{
id: TLPOINTER_ID,
typeName: 'pointer',
x: currentPagePoint.x,
y: currentPagePoint.y,
lastActivityTimestamp:
// If our pointer moved only because we're following some other user, then don't
// update our last activity timestamp; otherwise, update it to the current timestamp.
info.type === 'pointer' && info.pointerId === INTERNAL_POINTER_IDS.CAMERA_MOVE
? this.store.unsafeGetWithoutCapture(TLPOINTER_ID)?.lastActivityTimestamp ??
this._tickManager.now
: this._tickManager.now,
meta: {},
},
])
},
{ history: 'ignore' }
)
}
/**
@ -8643,7 +8776,7 @@ export class Editor extends EventEmitter<TLEventMap> {
private _pendingEventsForNextTick: TLEventInfo[] = []
private _flushEventsForTick(elapsed: number) {
this.batch(() => {
this.run(() => {
if (this._pendingEventsForNextTick.length > 0) {
const events = [...this._pendingEventsForNextTick]
this._pendingEventsForNextTick.length = 0
@ -9194,39 +9327,42 @@ function withIsolatedShapes<T>(
): T {
let result!: Result<T, unknown>
editor.history.ignore(() => {
const changes = editor.store.extractingChanges(() => {
const bindingsWithBoth = new Set<TLBindingId>()
const bindingsToRemove = new Set<TLBindingId>()
editor.run(
() => {
const changes = editor.store.extractingChanges(() => {
const bindingsWithBoth = new Set<TLBindingId>()
const bindingsToRemove = new Set<TLBindingId>()
for (const shapeId of shapeIds) {
const shape = editor.getShape(shapeId)
if (!shape) continue
for (const shapeId of shapeIds) {
const shape = editor.getShape(shapeId)
if (!shape) continue
for (const binding of editor.getBindingsInvolvingShape(shapeId)) {
const hasFrom = shapeIds.has(binding.fromId)
const hasTo = shapeIds.has(binding.toId)
if (hasFrom && hasTo) {
bindingsWithBoth.add(binding.id)
continue
}
if (!hasFrom || !hasTo) {
bindingsToRemove.add(binding.id)
for (const binding of editor.getBindingsInvolvingShape(shapeId)) {
const hasFrom = shapeIds.has(binding.fromId)
const hasTo = shapeIds.has(binding.toId)
if (hasFrom && hasTo) {
bindingsWithBoth.add(binding.id)
continue
}
if (!hasFrom || !hasTo) {
bindingsToRemove.add(binding.id)
}
}
}
}
editor.deleteBindings([...bindingsToRemove], { isolateShapes: true })
editor.deleteBindings([...bindingsToRemove], { isolateShapes: true })
try {
result = Result.ok(callback(bindingsWithBoth))
} catch (error) {
result = Result.err(error)
}
})
try {
result = Result.ok(callback(bindingsWithBoth))
} catch (error) {
result = Result.err(error)
}
})
editor.store.applyDiff(reverseRecordsDiff(changes))
})
editor.store.applyDiff(reverseRecordsDiff(changes))
},
{ history: 'ignore' }
)
if (result.ok) {
return result.value

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -142,7 +142,7 @@ export class PointingShape extends StateNode {
textLabel.bounds.containsPoint(pointInShapeSpace, 0) &&
textLabel.hitTestPoint(pointInShapeSpace)
) {
this.editor.batch(() => {
this.editor.run(() => {
this.editor.mark('editing on pointer up')
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))
})
}

View file

@ -250,7 +250,7 @@ export const DefaultPageMenu = memo(function DefaultPageMenu() {
const handleCreatePageClick = useCallback(() => {
if (isReadonlyMode) return
editor.batch(() => {
editor.run(() => {
editor.mark('creating page')
const newPageId = PageRecordType.createId()
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)
}
} else {
editor.batch(() => {
editor.run(() => {
setIsEditing(true)
editor.setCurrentPage(page.id)
})

View file

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

View file

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

View file

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

View file

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

View file

@ -17,7 +17,7 @@ export function removeFrame(editor: Editor, ids: TLShapeId[]) {
if (!frames.length) return
const allChildren: TLShapeId[] = []
editor.batch(() => {
editor.run(() => {
frames.map((frame) => {
const children = editor.getSortedChildIdsForParent(frame.id)
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
const diff = new Vec(dx, dy).rot(frame.rotation)
editor.batch(() => {
editor.run(() => {
const changes: TLShapePartial[] = childIds.map((child) => {
const shape = editor.getShape(child)!
return {

View file

@ -34,7 +34,7 @@ const TLDRAW_V1_VERSION = 15.5
/** @internal */
export function buildFromV1Document(editor: Editor, _document: unknown) {
let document = _document as TLV1Document
editor.batch(() => {
editor.run(() => {
document = migrate(document, TLDRAW_V1_VERSION)
// Cancel any interactions / states
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
editor.setCurrentPage(firstPageId)
editor.history.clear()
editor.clearHistory()
editor.selectNone()
const bounds = editor.getCurrentPageBounds()

View file

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

View file

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

View file

@ -172,3 +172,137 @@ describe('Unlocking', () => {
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', () => {
editor.history.clear()
expect(editor.history.getNumUndos()).toBe(0)
editor.getHistory().clear()
expect(editor.getHistory().getNumUndos()).toBe(0)
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', () => {
editor.history.clear()
editor.getHistory().clear()
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', () => {
editor.history.clear()
editor.getHistory().clear()
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', () => {
editor.history.clear()
editor.getHistory().clear()
editor.moveShapesToPage([ids.box1], ids.page1)
expect(editor.history.getNumUndos()).toBe(0)
expect(editor.getHistory().getNumUndos()).toBe(0)
})
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.mark('start')
const startHistoryLength = editor.history.getNumUndos()
const startHistoryLength = editor.getHistory().getNumUndos()
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 })
expect(editor.history.getNumUndos()).toBe(startHistoryLength + 1)
expect(editor.getHistory().getNumUndos()).toBe(startHistoryLength + 1)
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({
w: 800,

View file

@ -46,14 +46,14 @@ describe('setCurrentPage', () => {
})
it('squashes', () => {
const page1Id = editor.getCurrentPageId()
const page2Id = PageRecordType.createId('page2')
editor.createPage({ name: 'New Page 2', id: page2Id })
editor.history.clear()
editor.setCurrentPage(editor.getPages()[1].id)
editor.setCurrentPage(editor.getPages()[0].id)
editor.setCurrentPage(editor.getPages()[0].id)
expect(editor.history.getNumUndos()).toBe(1)
editor.setCurrentPage(page1Id)
editor.setCurrentPage(page2Id)
editor.setCurrentPage(page2Id)
editor.undo()
expect(editor.getCurrentPageId()).toBe(page1Id)
})
it('preserves the undo stack', () => {
@ -61,14 +61,14 @@ describe('setCurrentPage', () => {
const page2Id = PageRecordType.createId('page2')
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.undo()
editor.setCurrentPage(editor.getPages()[1].id)
editor.setCurrentPage(editor.getPages()[0].id)
editor.setCurrentPage(editor.getPages()[0].id)
expect(editor.getShape(boxId)).toBeUndefined()
expect(editor.history.getNumUndos()).toBe(1)
// expect(editor.history.getNumUndos()).toBe(1)
editor.redo()
expect(editor.getShape(boxId)).not.toBeUndefined()
})