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:
parent
f4ceb581dd
commit
01bc73e750
30 changed files with 572 additions and 286 deletions
|
@ -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.run(
|
||||
() => {
|
||||
editor.setCurrentPage(newPageId as TLPageId)
|
||||
})
|
||||
},
|
||||
{ history: 'ignore' }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 })
|
||||
|
|
|
@ -144,7 +144,7 @@ const CameraOptionsControlPanel = track(() => {
|
|||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
editor.batch(() => {
|
||||
editor.run(() => {
|
||||
editor.setCameraOptions(cameraOptions)
|
||||
editor.setCamera(editor.getCamera(), {
|
||||
immediate: true,
|
||||
|
|
|
@ -115,7 +115,7 @@ export function ImageAnnotationEditor({
|
|||
)
|
||||
|
||||
// Reset the history
|
||||
editor.history.clear()
|
||||
editor.clearHistory()
|
||||
setImageShapeId(shapeId)
|
||||
|
||||
return () => {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -497,10 +497,15 @@ export function useOnMount(onMount?: TLOnMountHandler) {
|
|||
|
||||
const onMountEvent = useEvent((editor: Editor) => {
|
||||
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)
|
||||
editor.emit('mount')
|
||||
})
|
||||
},
|
||||
{ history: 'ignore' }
|
||||
)
|
||||
window.tldrawReady = true
|
||||
return teardown
|
||||
})
|
||||
|
|
|
@ -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,7 +679,8 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
)
|
||||
this.disposables.add(this.history.dispose)
|
||||
|
||||
this.history.ignore(() => {
|
||||
this.run(
|
||||
() => {
|
||||
this.store.ensureStoreIsUsable()
|
||||
|
||||
// clear ephemeral state
|
||||
|
@ -680,7 +689,9 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
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 {
|
||||
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.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,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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.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.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,7 +2092,8 @@ 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(() => {
|
||||
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;
|
||||
|
@ -2026,7 +2108,9 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
// if the ids are a different length, then we know they're different.
|
||||
this._updateCurrentPageState({ erasingShapeIds: ids })
|
||||
}
|
||||
})
|
||||
},
|
||||
{ history: 'ignore' }
|
||||
)
|
||||
|
||||
return this
|
||||
}
|
||||
|
@ -2059,6 +2143,8 @@ 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()) {
|
||||
this.run(
|
||||
() => {
|
||||
if (!id) {
|
||||
this.updateCurrentPageState({ croppingShapeId: null })
|
||||
} else {
|
||||
|
@ -2068,6 +2154,9 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
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.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(() => {
|
||||
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(() => {
|
||||
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.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,15 +5122,10 @@ 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)
|
||||
|
||||
if (lockedShapes.length) {
|
||||
// If we have locked shapes, unlock them before we update them
|
||||
this.updateShapes(lockedShapes.map(({ id, type }) => ({ id, type, isLocked: false })))
|
||||
}
|
||||
|
||||
// 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]
|
||||
|
||||
|
@ -5048,11 +5146,13 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
y: newPoint.y,
|
||||
rotation: newRotation,
|
||||
index: indices[i],
|
||||
isLocked: shape.isLocked, // this will re-lock locked shapes
|
||||
})
|
||||
}
|
||||
|
||||
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,7 +8499,8 @@ 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.run(
|
||||
() => {
|
||||
this.store.put([
|
||||
{
|
||||
id: TLPOINTER_ID,
|
||||
|
@ -8386,7 +8517,9 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
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,7 +9327,8 @@ function withIsolatedShapes<T>(
|
|||
): T {
|
||||
let result!: Result<T, unknown>
|
||||
|
||||
editor.history.ignore(() => {
|
||||
editor.run(
|
||||
() => {
|
||||
const changes = editor.store.extractingChanges(() => {
|
||||
const bindingsWithBoth = new Set<TLBindingId>()
|
||||
const bindingsToRemove = new Set<TLBindingId>()
|
||||
|
@ -9226,7 +9360,9 @@ function withIsolatedShapes<T>(
|
|||
})
|
||||
|
||||
editor.store.applyDiff(reverseRecordsDiff(changes))
|
||||
})
|
||||
},
|
||||
{ history: 'ignore' }
|
||||
)
|
||||
|
||||
if (result.ok) {
|
||||
return result.value
|
||||
|
|
|
@ -56,7 +56,7 @@ function createCounterHistoryManager() {
|
|||
}
|
||||
|
||||
const setName = (name = 'David') => {
|
||||
manager.ignore(() => _setName(name))
|
||||
manager.batch(() => _setName(name), { history: 'ignore' })
|
||||
}
|
||||
|
||||
const setAge = (age = 35) => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
|
|
|
@ -234,7 +234,7 @@ const createBookmarkAssetOnUrlChange = debounce(async (editor: Editor, shape: TL
|
|||
return
|
||||
}
|
||||
|
||||
editor.batch(() => {
|
||||
editor.run(() => {
|
||||
// Create the new asset
|
||||
editor.createAssets([asset])
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -298,7 +298,7 @@ function createNShapes(editor: Editor, n: number) {
|
|||
}
|
||||
}
|
||||
|
||||
editor.batch(() => {
|
||||
editor.run(() => {
|
||||
editor.createShapes(shapesToCreate).setSelectedShapes(shapesToCreate.map((s) => s.id))
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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}` })
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -131,6 +131,10 @@ export class TestEditor extends Editor {
|
|||
})
|
||||
}
|
||||
|
||||
getHistory() {
|
||||
return this.history
|
||||
}
|
||||
|
||||
private _lastCreatedShapes: TLShape[] = []
|
||||
|
||||
/**
|
||||
|
|
|
@ -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()
|
||||
})
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
})
|
||||
|
|
Loading…
Reference in a new issue