kopia lustrzana https://github.com/Tldraw/Tldraw
429 wiersze
11 KiB
TypeScript
429 wiersze
11 KiB
TypeScript
import { BaseRecord, RecordId, Store, StoreSchema, createRecordType } from '@tldraw/store'
|
|
import { TLHistoryBatchOptions } from '../types/history-types'
|
|
import { HistoryManager } from './HistoryManager'
|
|
import { stack } from './Stack'
|
|
|
|
interface TestRecord extends BaseRecord<'test', TestRecordId> {
|
|
value: number | string
|
|
}
|
|
type TestRecordId = RecordId<TestRecord>
|
|
const testSchema = StoreSchema.create<TestRecord, null>({
|
|
test: createRecordType<TestRecord>('test', { scope: 'document' }),
|
|
})
|
|
|
|
const ids = {
|
|
count: testSchema.types.test.createId('count'),
|
|
name: testSchema.types.test.createId('name'),
|
|
age: testSchema.types.test.createId('age'),
|
|
a: testSchema.types.test.createId('a'),
|
|
b: testSchema.types.test.createId('b'),
|
|
}
|
|
|
|
function createCounterHistoryManager() {
|
|
const store = new Store({ schema: testSchema, props: null })
|
|
store.put([
|
|
testSchema.types.test.create({ id: ids.count, value: 0 }),
|
|
testSchema.types.test.create({ id: ids.name, value: 'David' }),
|
|
testSchema.types.test.create({ id: ids.age, value: 35 }),
|
|
])
|
|
|
|
const manager = new HistoryManager<TestRecord>({ store })
|
|
|
|
function getCount() {
|
|
return store.get(ids.count)!.value as number
|
|
}
|
|
function getName() {
|
|
return store.get(ids.name)!.value as string
|
|
}
|
|
function getAge() {
|
|
return store.get(ids.age)!.value as number
|
|
}
|
|
function _setCount(n: number) {
|
|
store.update(ids.count, (c) => ({ ...c, value: n }))
|
|
}
|
|
function _setName(name: string) {
|
|
store.update(ids.name, (c) => ({ ...c, value: name }))
|
|
}
|
|
function _setAge(age: number) {
|
|
store.update(ids.age, (c) => ({ ...c, value: age }))
|
|
}
|
|
|
|
const increment = (n = 1) => {
|
|
_setCount(getCount() + n)
|
|
}
|
|
|
|
const decrement = (n = 1) => {
|
|
_setCount(getCount() - n)
|
|
}
|
|
|
|
const setName = (name = 'David') => {
|
|
manager.ignore(() => _setName(name))
|
|
}
|
|
|
|
const setAge = (age = 35) => {
|
|
manager.recordPreservingRedoStack(() => _setAge(age))
|
|
}
|
|
|
|
const incrementTwice = () => {
|
|
increment()
|
|
increment()
|
|
}
|
|
|
|
return {
|
|
increment,
|
|
incrementTwice,
|
|
decrement,
|
|
setName,
|
|
setAge,
|
|
history: manager,
|
|
getCount,
|
|
getName,
|
|
getAge,
|
|
}
|
|
}
|
|
|
|
describe(HistoryManager, () => {
|
|
let editor = createCounterHistoryManager()
|
|
beforeEach(() => {
|
|
editor = createCounterHistoryManager()
|
|
})
|
|
it('creates a serializable undo stack', () => {
|
|
expect(editor.getCount()).toBe(0)
|
|
editor.increment()
|
|
editor.increment()
|
|
editor.history.mark('stop at 2')
|
|
editor.increment()
|
|
editor.increment()
|
|
editor.decrement()
|
|
expect(editor.getCount()).toBe(3)
|
|
|
|
const undos = [...editor.history.stacks.get().undos]
|
|
const parsedUndos = JSON.parse(JSON.stringify(undos))
|
|
editor.history.stacks.update(({ redos }) => ({ undos: stack(parsedUndos), redos }))
|
|
|
|
editor.history.undo()
|
|
|
|
expect(editor.getCount()).toBe(2)
|
|
})
|
|
|
|
it('allows undoing and redoing', () => {
|
|
expect(editor.getCount()).toBe(0)
|
|
editor.increment()
|
|
editor.history.mark('stop at 1')
|
|
editor.increment()
|
|
editor.history.mark('stop at 2')
|
|
editor.increment()
|
|
editor.increment()
|
|
editor.history.mark('stop at 4')
|
|
editor.increment()
|
|
editor.increment()
|
|
editor.increment()
|
|
expect(editor.getCount()).toBe(7)
|
|
|
|
editor.history.undo()
|
|
expect(editor.getCount()).toBe(4)
|
|
editor.history.undo()
|
|
expect(editor.getCount()).toBe(2)
|
|
editor.history.undo()
|
|
expect(editor.getCount()).toBe(1)
|
|
editor.history.undo()
|
|
expect(editor.getCount()).toBe(0)
|
|
editor.history.undo()
|
|
editor.history.undo()
|
|
editor.history.undo()
|
|
expect(editor.getCount()).toBe(0)
|
|
|
|
editor.history.redo()
|
|
expect(editor.getCount()).toBe(1)
|
|
editor.history.redo()
|
|
expect(editor.getCount()).toBe(2)
|
|
editor.history.redo()
|
|
expect(editor.getCount()).toBe(4)
|
|
editor.history.redo()
|
|
expect(editor.getCount()).toBe(7)
|
|
})
|
|
|
|
it('clears the redo stack if you execute commands, but not if you mark stopping points', () => {
|
|
expect(editor.getCount()).toBe(0)
|
|
editor.increment()
|
|
editor.history.mark('stop at 1')
|
|
editor.increment()
|
|
editor.history.mark('stop at 2')
|
|
editor.increment()
|
|
editor.increment()
|
|
editor.history.mark('stop at 4')
|
|
editor.increment()
|
|
editor.increment()
|
|
editor.increment()
|
|
expect(editor.getCount()).toBe(7)
|
|
editor.history.undo()
|
|
editor.history.undo()
|
|
expect(editor.getCount()).toBe(2)
|
|
editor.history.mark('wayward stopping point')
|
|
editor.history.redo()
|
|
editor.history.redo()
|
|
expect(editor.getCount()).toBe(7)
|
|
|
|
editor.history.undo()
|
|
editor.history.undo()
|
|
expect(editor.getCount()).toBe(2)
|
|
editor.increment()
|
|
expect(editor.getCount()).toBe(3)
|
|
editor.history.redo()
|
|
expect(editor.getCount()).toBe(3)
|
|
editor.history.redo()
|
|
expect(editor.getCount()).toBe(3)
|
|
})
|
|
|
|
it('allows squashing of commands', () => {
|
|
editor.increment()
|
|
|
|
editor.history.mark('stop at 1')
|
|
expect(editor.getCount()).toBe(1)
|
|
|
|
editor.increment(1)
|
|
editor.increment(1)
|
|
editor.increment(1)
|
|
editor.increment(1)
|
|
|
|
expect(editor.getCount()).toBe(5)
|
|
|
|
expect(editor.history.getNumUndos()).toBe(3)
|
|
})
|
|
it('allows ignore commands that do not affect the stack', () => {
|
|
editor.increment()
|
|
editor.history.mark('stop at 1')
|
|
editor.increment()
|
|
editor.setName('wilbur')
|
|
editor.increment()
|
|
expect(editor.getCount()).toBe(3)
|
|
expect(editor.getName()).toBe('wilbur')
|
|
editor.history.undo()
|
|
expect(editor.getCount()).toBe(1)
|
|
expect(editor.getName()).toBe('wilbur')
|
|
})
|
|
|
|
it('allows inconsequential commands that do not clear the redo stack', () => {
|
|
editor.increment()
|
|
editor.history.mark('stop at 1')
|
|
editor.increment()
|
|
expect(editor.getCount()).toBe(2)
|
|
editor.history.undo()
|
|
expect(editor.getCount()).toBe(1)
|
|
editor.history.mark('stop at age 35')
|
|
editor.setAge(23)
|
|
editor.history.mark('stop at age 23')
|
|
expect(editor.getCount()).toBe(1)
|
|
editor.history.redo()
|
|
expect(editor.getCount()).toBe(2)
|
|
expect(editor.getAge()).toBe(23)
|
|
editor.history.undo()
|
|
expect(editor.getCount()).toBe(1)
|
|
expect(editor.getAge()).toBe(23)
|
|
editor.history.undo()
|
|
expect(editor.getCount()).toBe(1)
|
|
expect(editor.getAge()).toBe(35)
|
|
editor.history.undo()
|
|
expect(editor.getCount()).toBe(0)
|
|
expect(editor.getAge()).toBe(35)
|
|
})
|
|
|
|
it('does not allow new history entries to be pushed if a command invokes them while doing or undoing', () => {
|
|
editor.incrementTwice()
|
|
expect(editor.history.getNumUndos()).toBe(1)
|
|
expect(editor.getCount()).toBe(2)
|
|
editor.history.undo()
|
|
expect(editor.getCount()).toBe(0)
|
|
expect(editor.history.getNumUndos()).toBe(0)
|
|
})
|
|
|
|
it('does not allow new history entries to be pushed if a command invokes them while bailing', () => {
|
|
editor.history.mark('0')
|
|
editor.incrementTwice()
|
|
editor.history.mark('2')
|
|
editor.incrementTwice()
|
|
editor.incrementTwice()
|
|
expect(editor.history.getNumUndos()).toBe(4)
|
|
expect(editor.getCount()).toBe(6)
|
|
editor.history.bail()
|
|
expect(editor.getCount()).toBe(2)
|
|
expect(editor.history.getNumUndos()).toBe(2)
|
|
editor.history.bailToMark('0')
|
|
expect(editor.history.getNumUndos()).toBe(0)
|
|
expect(editor.getCount()).toBe(0)
|
|
})
|
|
|
|
it('supports bailing to a particular mark', () => {
|
|
editor.increment()
|
|
editor.history.mark('1')
|
|
editor.increment()
|
|
editor.history.mark('2')
|
|
editor.increment()
|
|
editor.history.mark('3')
|
|
editor.increment()
|
|
|
|
expect(editor.getCount()).toBe(4)
|
|
editor.history.bailToMark('2')
|
|
expect(editor.getCount()).toBe(2)
|
|
})
|
|
})
|
|
|
|
describe('history options', () => {
|
|
let manager: HistoryManager<TestRecord>
|
|
|
|
let getState: () => { a: number; b: number }
|
|
let setA: (n: number, historyOptions?: TLHistoryBatchOptions) => any
|
|
let setB: (n: number, historyOptions?: TLHistoryBatchOptions) => any
|
|
|
|
beforeEach(() => {
|
|
const store = new Store({ schema: testSchema, props: null })
|
|
store.put([
|
|
testSchema.types.test.create({ id: ids.a, value: 0 }),
|
|
testSchema.types.test.create({ id: ids.b, value: 0 }),
|
|
])
|
|
|
|
manager = new HistoryManager<TestRecord>({ store })
|
|
|
|
getState = () => {
|
|
return { a: store.get(ids.a)!.value as number, b: store.get(ids.b)!.value as number }
|
|
}
|
|
|
|
setA = (n: number, opts?: TLHistoryBatchOptions) => {
|
|
manager.runInMode(opts?.history, () => store.update(ids.a, (s) => ({ ...s, value: n })))
|
|
}
|
|
|
|
setB = (n: number, opts?: TLHistoryBatchOptions) => {
|
|
manager.runInMode(opts?.history, () => store.update(ids.b, (s) => ({ ...s, value: n })))
|
|
}
|
|
})
|
|
|
|
it('undos, redoes, separate marks', () => {
|
|
manager.mark()
|
|
setA(1)
|
|
manager.mark()
|
|
setB(1)
|
|
manager.mark()
|
|
setB(2)
|
|
|
|
expect(getState()).toMatchObject({ a: 1, b: 2 })
|
|
|
|
manager.undo()
|
|
|
|
expect(getState()).toMatchObject({ a: 1, b: 1 })
|
|
|
|
manager.redo()
|
|
|
|
expect(getState()).toMatchObject({ a: 1, b: 2 })
|
|
})
|
|
|
|
it('undos, redos, squashing', () => {
|
|
manager.mark()
|
|
setA(1)
|
|
manager.mark()
|
|
setB(1)
|
|
manager.mark()
|
|
setB(2)
|
|
setB(3)
|
|
setB(4)
|
|
|
|
expect(getState()).toMatchObject({ a: 1, b: 4 })
|
|
|
|
manager.undo()
|
|
|
|
expect(getState()).toMatchObject({ a: 1, b: 1 })
|
|
|
|
manager.redo()
|
|
|
|
expect(getState()).toMatchObject({ a: 1, b: 4 })
|
|
})
|
|
|
|
it('undos, redos, ignore', () => {
|
|
manager.mark()
|
|
setA(1)
|
|
manager.mark()
|
|
setB(1) // B 0->1
|
|
manager.mark()
|
|
setB(2, { history: 'ignore' }) // B 0->2, but ignore
|
|
|
|
expect(getState()).toMatchObject({ a: 1, b: 2 })
|
|
|
|
manager.undo() // undoes B 2->0
|
|
|
|
expect(getState()).toMatchObject({ a: 1, b: 0 })
|
|
|
|
manager.redo() // redoes B 0->1, but not B 1-> 2
|
|
|
|
expect(getState()).toMatchObject({ a: 1, b: 1 }) // no change, b 1->2 was ignore
|
|
})
|
|
|
|
it('squashing, undos, redos', () => {
|
|
manager.mark()
|
|
setA(1)
|
|
manager.mark()
|
|
setB(1)
|
|
setB(2) // squashes with the previous command
|
|
setB(3) // squashes with the previous command
|
|
|
|
expect(getState()).toMatchObject({ a: 1, b: 3 })
|
|
|
|
manager.undo()
|
|
|
|
expect(getState()).toMatchObject({ a: 1, b: 0 })
|
|
|
|
manager.redo()
|
|
|
|
expect(getState()).toMatchObject({ a: 1, b: 3 })
|
|
})
|
|
|
|
it('squashing, undos, redos, ignore', () => {
|
|
manager.mark()
|
|
setA(1)
|
|
manager.mark()
|
|
setB(1)
|
|
setB(2) // squashes with the previous command
|
|
setB(3, { history: 'ignore' }) // squashes with the previous command
|
|
|
|
expect(getState()).toMatchObject({ a: 1, b: 3 })
|
|
|
|
manager.undo()
|
|
|
|
expect(getState()).toMatchObject({ a: 1, b: 0 })
|
|
|
|
manager.redo()
|
|
|
|
expect(getState()).toMatchObject({ a: 1, b: 2 }) // B2->3 was ignore
|
|
})
|
|
|
|
it('nested ignore', () => {
|
|
manager.mark()
|
|
manager.ignore(() => {
|
|
setA(1)
|
|
manager.record(() => setB(1))
|
|
setA(2)
|
|
})
|
|
expect(getState()).toMatchObject({ a: 2, b: 1 })
|
|
|
|
// changes to A were ignore, but changes to B were recorded:
|
|
manager.undo()
|
|
expect(getState()).toMatchObject({ a: 2, b: 0 })
|
|
|
|
manager.mark()
|
|
manager.recordPreservingRedoStack(() => {
|
|
setA(3)
|
|
manager.ignore(() => setB(2))
|
|
})
|
|
expect(getState()).toMatchObject({ a: 3, b: 2 })
|
|
|
|
// changes to A were recorded, but changes to B were ignore:
|
|
manager.undo()
|
|
expect(getState()).toMatchObject({ a: 2, b: 2 })
|
|
|
|
// We can still redo because we preserved the redo stack:
|
|
manager.redo()
|
|
expect(getState()).toMatchObject({ a: 3, b: 2 })
|
|
|
|
manager.redo()
|
|
expect(getState()).toMatchObject({ a: 3, b: 1 })
|
|
})
|
|
})
|