Tldraw/packages/editor/src/lib/test/Editor.test.tsx

444 wiersze
13 KiB
TypeScript
Czysty Zwykły widok Historia

import { PageRecordType, createShapeId } from '@tldraw/tlschema'
2023-04-25 11:01:25 +00:00
import { structuredClone } from '@tldraw/utils'
import { TestEditor } from './TestEditor'
import { TL } from './jsx'
2023-04-25 11:01:25 +00:00
let editor: TestEditor
2023-04-25 11:01:25 +00:00
const ids = {
box1: createShapeId('box1'),
box2: createShapeId('box2'),
box3: createShapeId('box3'),
frame1: createShapeId('frame1'),
group1: createShapeId('group1'),
2023-04-25 11:01:25 +00:00
Independent instance state persistence (#1493) This PR - Removes UserDocumentRecordType - moving isSnapMode to user preferences - moving isGridMode and isPenMode to InstanceRecordType - deleting the other properties which are no longer needed. - Creates a separate pipeline for persisting instance state. Previously the instance state records were stored alongside the document state records, and in order to load the state for a particular instance (in our case, a particular tab) you needed to pass the 'instanceId' prop. This prop ended up totally pervading the public API and people ran into all kinds of issues with it, e.g. using the same instance id in multiple editor instances. There was also an issue whereby it was hard for us to clean up old instance state so the idb table ended up bloating over time. This PR makes it so that rather than passing an instanceId, you load the instance state yourself while creating the store. It provides tools to make that easy. - Undoes the assumption that we might have more than one instance's state in the store. - Like `document`, `instance` now has a singleton id `instance:instance`. - Page state ids and camera ids are no longer random, but rather derive from the page they belong to. This is like having a foreign primary key in SQL databases. It's something i'd love to support fully as part of the RecordType/Store api. Tests to do - [x] Test Migrations - [x] Test Store.listen filtering - [x] Make type sets in Store public and readonly - [x] Test RecordType.createId - [x] Test Instance state snapshot loading/exporting - [x] Manual test File I/O - [x] Manual test Vscode extension with multiple tabs - [x] Audit usages of store.query - [x] Audit usages of changed types: InstanceRecordType, 'instance', InstancePageStateRecordType, 'instance_page_state', 'user_document', 'camera', CameraRecordType, InstancePresenceRecordType, 'instance_presence' - [x] Test user preferences - [x] Manual test isSnapMode and isGridMode and isPenMode - [ ] Test indexedDb functions - [x] Add instanceId stuff back ### Change Type - [x] `major` — Breaking Change ### Test Plan 1. Add a step-by-step description of how to test your PR here. 2. - [ ] Unit Tests - [ ] Webdriver tests ### Release Notes - Add a brief release note for your PR here.
2023-06-05 14:11:07 +00:00
page2: PageRecordType.createId('page2'),
2023-04-25 11:01:25 +00:00
}
beforeEach(() => {
editor = new TestEditor()
2023-04-25 11:01:25 +00:00
editor.createShapes([
2023-04-25 11:01:25 +00:00
// on it's own
{ id: ids.box1, type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } },
// in a frame
{ id: ids.frame1, type: 'frame', x: 100, y: 100, props: { w: 100, h: 100 } },
{ id: ids.box2, type: 'geo', x: 700, y: 700, props: { w: 100, h: 100 }, parentId: ids.frame1 },
{ id: ids.group1, type: 'group', x: 100, y: 100, props: {} },
{ id: ids.box3, type: 'geo', x: 500, y: 500, props: { w: 100, h: 100 }, parentId: ids.group1 },
])
const page1 = editor.currentPageId
editor.createPage('page 2', ids.page2)
editor.setCurrentPageId(page1)
2023-04-25 11:01:25 +00:00
})
const moveShapesToPage2 = () => {
// directly maniuplate parentId like would happen in multiplayer situations
editor.updateShapes([
2023-04-25 11:01:25 +00:00
{ id: ids.box1, type: 'geo', parentId: ids.page2 },
{ id: ids.box2, type: 'geo', parentId: ids.page2 },
{ id: ids.group1, type: 'group', parentId: ids.page2 },
])
}
describe('shapes that are moved to another page', () => {
it("should be excluded from the previous page's focusLayerId", () => {
editor.setFocusLayer(ids.group1)
expect(editor.focusLayerId).toBe(ids.group1)
2023-04-25 11:01:25 +00:00
moveShapesToPage2()
expect(editor.focusLayerId).toBe(editor.currentPageId)
2023-04-25 11:01:25 +00:00
})
describe("should be excluded from the previous page's hintingIds", () => {
test('[boxes]', () => {
editor.setHintingIds([ids.box1, ids.box2, ids.box3])
expect(editor.hintingIds).toEqual([ids.box1, ids.box2, ids.box3])
2023-04-25 11:01:25 +00:00
moveShapesToPage2()
expect(editor.hintingIds).toEqual([])
2023-04-25 11:01:25 +00:00
})
test('[frame that does not move]', () => {
editor.setHintingIds([ids.frame1])
expect(editor.hintingIds).toEqual([ids.frame1])
2023-04-25 11:01:25 +00:00
moveShapesToPage2()
expect(editor.hintingIds).toEqual([ids.frame1])
2023-04-25 11:01:25 +00:00
})
})
describe("should be excluded from the previous page's editingId", () => {
test('[root shape]', () => {
editor.setEditingId(ids.box1)
expect(editor.editingId).toBe(ids.box1)
2023-04-25 11:01:25 +00:00
moveShapesToPage2()
expect(editor.editingId).toBe(null)
2023-04-25 11:01:25 +00:00
})
test('[child of frame]', () => {
editor.setEditingId(ids.box2)
expect(editor.editingId).toBe(ids.box2)
2023-04-25 11:01:25 +00:00
moveShapesToPage2()
expect(editor.editingId).toBe(null)
2023-04-25 11:01:25 +00:00
})
test('[child of group]', () => {
editor.setEditingId(ids.box3)
expect(editor.editingId).toBe(ids.box3)
2023-04-25 11:01:25 +00:00
moveShapesToPage2()
expect(editor.editingId).toBe(null)
2023-04-25 11:01:25 +00:00
})
test('[frame that doesnt move]', () => {
editor.setEditingId(ids.frame1)
expect(editor.editingId).toBe(ids.frame1)
2023-04-25 11:01:25 +00:00
moveShapesToPage2()
expect(editor.editingId).toBe(ids.frame1)
2023-04-25 11:01:25 +00:00
})
})
describe("should be excluded from the previous page's erasingIds", () => {
test('[boxes]', () => {
editor.setErasingIds([ids.box1, ids.box2, ids.box3])
expect(editor.erasingIds).toEqual([ids.box1, ids.box2, ids.box3])
2023-04-25 11:01:25 +00:00
moveShapesToPage2()
expect(editor.erasingIds).toEqual([])
2023-04-25 11:01:25 +00:00
})
test('[frame that does not move]', () => {
editor.setErasingIds([ids.frame1])
expect(editor.erasingIds).toEqual([ids.frame1])
2023-04-25 11:01:25 +00:00
moveShapesToPage2()
expect(editor.erasingIds).toEqual([ids.frame1])
2023-04-25 11:01:25 +00:00
})
})
describe("should be excluded from the previous page's selectedIds", () => {
test('[boxes]', () => {
editor.setSelectedIds([ids.box1, ids.box2, ids.box3])
expect(editor.selectedIds).toEqual([ids.box1, ids.box2, ids.box3])
2023-04-25 11:01:25 +00:00
moveShapesToPage2()
expect(editor.selectedIds).toEqual([])
2023-04-25 11:01:25 +00:00
})
test('[frame that does not move]', () => {
editor.setSelectedIds([ids.frame1])
expect(editor.selectedIds).toEqual([ids.frame1])
2023-04-25 11:01:25 +00:00
moveShapesToPage2()
expect(editor.selectedIds).toEqual([ids.frame1])
2023-04-25 11:01:25 +00:00
})
})
})
it('Begins dragging from pointer move', () => {
editor.pointerDown(0, 0)
editor.pointerMove(2, 2)
expect(editor.inputs.isDragging).toBe(false)
editor.pointerMove(10, 10)
expect(editor.inputs.isDragging).toBe(true)
2023-04-25 11:01:25 +00:00
})
it('Begins dragging from wheel', () => {
editor.pointerDown(0, 0)
editor.wheel(2, 2)
expect(editor.inputs.isDragging).toBe(false)
editor.wheel(10, 10)
expect(editor.inputs.isDragging).toBe(true)
2023-04-25 11:01:25 +00:00
})
it('Does not create an undo stack item when first clicking on an empty canvas', () => {
editor = new TestEditor()
editor.pointerMove(50, 50)
editor.click(0, 0)
expect(editor.canUndo).toBe(false)
2023-04-25 11:01:25 +00:00
})
describe('Editor.setProp', () => {
2023-04-25 11:01:25 +00:00
it('Does not set non-style props on propsForNextShape', () => {
const initialPropsForNextShape = structuredClone(editor.instanceState.propsForNextShape)
editor.setProp('w', 100)
editor.setProp('url', 'https://example.com')
expect(editor.instanceState.propsForNextShape).toStrictEqual(initialPropsForNextShape)
2023-04-25 11:01:25 +00:00
})
})
describe('Editor.opacity', () => {
it('should return the current opacity', () => {
expect(editor.opacity).toBe(1)
editor.setOpacity(0.5)
expect(editor.opacity).toBe(0.5)
})
it('should return opacity for a single selected shape', () => {
const { A } = editor.createShapesFromJsx(<TL.geo ref="A" opacity={0.3} x={0} y={0} />)
editor.setSelectedIds([A])
expect(editor.opacity).toBe(0.3)
})
it('should return opacity for multiple selected shapes', () => {
const { A, B } = editor.createShapesFromJsx([
<TL.geo ref="A" opacity={0.3} x={0} y={0} />,
<TL.geo ref="B" opacity={0.3} x={0} y={0} />,
])
editor.setSelectedIds([A, B])
expect(editor.opacity).toBe(0.3)
})
it('should return null when multiple selected shapes have different opacity', () => {
const { A, B } = editor.createShapesFromJsx([
<TL.geo ref="A" opacity={0.3} x={0} y={0} />,
<TL.geo ref="B" opacity={0.5} x={0} y={0} />,
])
editor.setSelectedIds([A, B])
expect(editor.opacity).toBe(null)
})
it('ignores the opacity of groups and returns the opacity of their children', () => {
const ids = editor.createShapesFromJsx([
<TL.group ref="group" x={0} y={0}>
<TL.geo ref="A" opacity={0.3} x={0} y={0} />
</TL.group>,
])
editor.setSelectedIds([ids.group])
expect(editor.opacity).toBe(0.3)
})
})
describe('Editor.setOpacity', () => {
it('should set opacity for selected shapes', () => {
const ids = editor.createShapesFromJsx([
<TL.geo ref="A" opacity={0.3} x={0} y={0} />,
<TL.geo ref="B" opacity={0.4} x={0} y={0} />,
])
editor.setSelectedIds([ids.A, ids.B])
editor.setOpacity(0.5)
expect(editor.getShapeById(ids.A)!.opacity).toBe(0.5)
expect(editor.getShapeById(ids.B)!.opacity).toBe(0.5)
})
it('should traverse into groups and set opacity in their children', () => {
const ids = editor.createShapesFromJsx([
<TL.geo ref="boxA" x={0} y={0} />,
<TL.group ref="groupA" x={0} y={0}>
<TL.geo ref="boxB" x={0} y={0} />
<TL.group ref="groupB" x={0} y={0}>
<TL.geo ref="boxC" x={0} y={0} />
<TL.geo ref="boxD" x={0} y={0} />
</TL.group>
</TL.group>,
])
editor.setSelectedIds([ids.groupA])
editor.setOpacity(0.5)
// a wasn't selected...
expect(editor.getShapeById(ids.boxA)!.opacity).toBe(1)
// b, c, & d were within a selected group...
expect(editor.getShapeById(ids.boxB)!.opacity).toBe(0.5)
expect(editor.getShapeById(ids.boxC)!.opacity).toBe(0.5)
expect(editor.getShapeById(ids.boxD)!.opacity).toBe(0.5)
// groups get skipped
expect(editor.getShapeById(ids.groupA)!.opacity).toBe(1)
expect(editor.getShapeById(ids.groupB)!.opacity).toBe(1)
})
it('stores opacity on opacityForNextShape', () => {
editor.setOpacity(0.5)
expect(editor.instanceState.opacityForNextShape).toBe(0.5)
editor.setOpacity(0.6)
expect(editor.instanceState.opacityForNextShape).toBe(0.6)
})
})
describe('Editor.TickManager', () => {
2023-04-25 11:01:25 +00:00
it('Does not produce NaN values when elapsed is 0', () => {
// a helper that calls update pointer velocity with a given elapsed time.
// usually this is called by the app's tick manager, using the elapsed time
// between two animation frames, but we're calling it directly here.
const tick = (ms: number) => {
// @ts-expect-error
editor._tickManager.updatePointerVelocity(ms)
2023-04-25 11:01:25 +00:00
}
// 1. pointer velocity should be 0 when there is no movement
expect(editor.inputs.pointerVelocity.toJson()).toCloselyMatchObject({ x: 0, y: 0 })
2023-04-25 11:01:25 +00:00
editor.pointerMove(10, 10)
2023-04-25 11:01:25 +00:00
// 2. moving is not enough, we also need to wait a frame before the velocity is updated
expect(editor.inputs.pointerVelocity.toJson()).toCloselyMatchObject({ x: 0, y: 0 })
2023-04-25 11:01:25 +00:00
// 3. once time passes, the pointer velocity should be updated
tick(16)
expect(editor.inputs.pointerVelocity.toJson()).toCloselyMatchObject({ x: 0.3125, y: 0.3125 })
2023-04-25 11:01:25 +00:00
// 4. let's do it again, it should be updated again. move, tick, measure
editor.pointerMove(20, 20)
2023-04-25 11:01:25 +00:00
tick(16)
expect(editor.inputs.pointerVelocity.toJson()).toCloselyMatchObject({ x: 0.46875, y: 0.46875 })
2023-04-25 11:01:25 +00:00
// 5. if we tick again without movement, the velocity should decay
tick(16)
expect(editor.inputs.pointerVelocity.toJson()).toCloselyMatchObject({ x: 0.23437, y: 0.23437 })
2023-04-25 11:01:25 +00:00
// 6. if updatePointerVelocity is (for whatever reason) called with an elapsed time of zero milliseconds, it should be ignored
tick(0)
expect(editor.inputs.pointerVelocity.toJson()).toCloselyMatchObject({ x: 0.23437, y: 0.23437 })
2023-04-25 11:01:25 +00:00
})
})
describe("App's default tool", () => {
it('Is select for regular app', () => {
editor = new TestEditor()
expect(editor.currentToolId).toBe('select')
2023-04-25 11:01:25 +00:00
})
it('Is hand for readonly mode', () => {
editor = new TestEditor()
editor.setReadOnly(true)
expect(editor.currentToolId).toBe('hand')
2023-04-25 11:01:25 +00:00
})
})
describe('currentToolId', () => {
it('is select by default', () => {
expect(editor.currentToolId).toBe('select')
2023-04-25 11:01:25 +00:00
})
it('is set to the last used tool', () => {
editor.setSelectedTool('draw')
expect(editor.currentToolId).toBe('draw')
2023-04-25 11:01:25 +00:00
editor.setSelectedTool('geo')
expect(editor.currentToolId).toBe('geo')
2023-04-25 11:01:25 +00:00
})
it('stays the selected tool during shape creation interactions that technically use the select tool', () => {
expect(editor.currentToolId).toBe('select')
2023-04-25 11:01:25 +00:00
editor.setSelectedTool('geo')
editor.pointerDown(0, 0)
editor.pointerMove(100, 100)
2023-04-25 11:01:25 +00:00
expect(editor.currentToolId).toBe('geo')
expect(editor.root.path.value).toBe('root.select.resizing')
2023-04-25 11:01:25 +00:00
})
it('reverts back to select if we finish the interaction', () => {
expect(editor.currentToolId).toBe('select')
2023-04-25 11:01:25 +00:00
editor.setSelectedTool('geo')
editor.pointerDown(0, 0)
editor.pointerMove(100, 100)
2023-04-25 11:01:25 +00:00
expect(editor.currentToolId).toBe('geo')
expect(editor.root.path.value).toBe('root.select.resizing')
2023-04-25 11:01:25 +00:00
editor.pointerUp(100, 100)
2023-04-25 11:01:25 +00:00
expect(editor.currentToolId).toBe('select')
2023-04-25 11:01:25 +00:00
})
it('stays on the selected tool if we cancel the interaction', () => {
expect(editor.currentToolId).toBe('select')
2023-04-25 11:01:25 +00:00
editor.setSelectedTool('geo')
editor.pointerDown(0, 0)
editor.pointerMove(100, 100)
2023-04-25 11:01:25 +00:00
expect(editor.currentToolId).toBe('geo')
expect(editor.root.path.value).toBe('root.select.resizing')
2023-04-25 11:01:25 +00:00
editor.cancel()
2023-04-25 11:01:25 +00:00
expect(editor.currentToolId).toBe('geo')
2023-04-25 11:01:25 +00:00
})
})
describe('isFocused', () => {
it('is false by default', () => {
expect(editor.isFocused).toBe(false)
2023-04-25 11:01:25 +00:00
})
it('becomes true when you call .focus()', () => {
editor.focus()
expect(editor.isFocused).toBe(true)
2023-04-25 11:01:25 +00:00
})
it('becomes false when you call .blur()', () => {
editor.focus()
expect(editor.isFocused).toBe(true)
2023-04-25 11:01:25 +00:00
editor.blur()
expect(editor.isFocused).toBe(false)
2023-04-25 11:01:25 +00:00
})
it('remains false when you call .blur()', () => {
expect(editor.isFocused).toBe(false)
editor.blur()
expect(editor.isFocused).toBe(false)
2023-04-25 11:01:25 +00:00
})
it('becomes true when the container div receives a focus event', () => {
expect(editor.isFocused).toBe(false)
2023-04-25 11:01:25 +00:00
editor.elm.focus()
2023-04-25 11:01:25 +00:00
expect(editor.isFocused).toBe(true)
2023-04-25 11:01:25 +00:00
})
it('becomes false when the container div receives a blur event', () => {
editor.focus()
expect(editor.isFocused).toBe(true)
2023-04-25 11:01:25 +00:00
editor.elm.blur()
2023-04-25 11:01:25 +00:00
expect(editor.isFocused).toBe(false)
2023-04-25 11:01:25 +00:00
})
it('becomes true when a child of the app container div receives a focusin event', () => {
editor.elm.blur()
2023-04-25 11:01:25 +00:00
const child = document.createElement('div')
editor.elm.appendChild(child)
2023-04-25 11:01:25 +00:00
expect(editor.isFocused).toBe(false)
2023-04-25 11:01:25 +00:00
child.dispatchEvent(new FocusEvent('focusin', { bubbles: true }))
expect(editor.isFocused).toBe(true)
2023-04-25 11:01:25 +00:00
child.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))
expect(editor.isFocused).toBe(false)
2023-04-25 11:01:25 +00:00
})
it('becomes false when a child of the app container div receives a focusout event', () => {
const child = document.createElement('div')
editor.elm.appendChild(child)
2023-04-25 11:01:25 +00:00
editor.focus()
2023-04-25 11:01:25 +00:00
expect(editor.isFocused).toBe(true)
2023-04-25 11:01:25 +00:00
child.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))
expect(editor.isFocused).toBe(false)
2023-04-25 11:01:25 +00:00
})
it('calls .focus() and .blur() on the container div when you call .focus() and .blur() on the editor', () => {
const focusMock = jest.spyOn(editor.elm, 'focus').mockImplementation()
const blurMock = jest.spyOn(editor.elm, 'blur').mockImplementation()
2023-04-25 11:01:25 +00:00
expect(focusMock).not.toHaveBeenCalled()
expect(blurMock).not.toHaveBeenCalled()
editor.focus()
2023-04-25 11:01:25 +00:00
expect(focusMock).toHaveBeenCalled()
expect(blurMock).not.toHaveBeenCalled()
editor.blur()
2023-04-25 11:01:25 +00:00
expect(blurMock).toHaveBeenCalled()
})
})