import { act } from '@testing-library/react'
import { createShapeId, Editor, TLShapeId } from '@tldraw/editor'
import { useEffect } from 'react'
import { Tldraw } from '../../lib/Tldraw'
import { TLUiActionsContextType, useActions } from '../../lib/ui/context/actions'
import { renderTldrawComponentWithEditor } from '../testutils/renderTldrawComponent'
function ActionCapturer({ onCapture }: { onCapture(actions: TLUiActionsContextType): void }) {
const actions = useActions()
useEffect(() => onCapture(actions), [actions, onCapture])
return null
}
async function setup() {
const captured: TLUiActionsContextType[] = []
const { editor } = await renderTldrawComponentWithEditor(
(onMount) => (
{
captured.push(a)
}}
/>
),
{ waitForPatterns: false }
)
return { editor, captured, actions: () => captured[captured.length - 1] }
}
const a = createShapeId('a')
const b = createShapeId('b')
const c = createShapeId('c')
function seed(editor: Editor) {
act(() => {
editor.createShapes([
{ id: a, type: 'geo', x: 0, y: 0 },
{ id: b, type: 'geo', x: 200, y: 0 },
{ id: c, type: 'geo', x: 400, y: 0 },
])
})
}
function enabledMap(editor: Editor, actions: TLUiActionsContextType, actionIds: string[]) {
return Object.fromEntries(
actionIds.map((id) => [id, actions[id].isEnabled ? actions[id].isEnabled!(editor) : true])
)
}
describe('built-in action isEnabled', () => {
it('selection actions need the select tool and unlocked shapes', async () => {
const { editor, actions } = await setup()
seed(editor)
const ids = [
'delete',
'cut',
'duplicate',
'bring-to-front',
'rotate-cw',
'align-left',
'group',
'toggle-lock',
'copy',
]
act(() => editor.select(a, b))
expect(enabledMap(editor, actions(), ids)).toEqual({
delete: true,
cut: true,
duplicate: true,
'bring-to-front': true,
'rotate-cw': true,
'align-left': true,
group: true,
'toggle-lock': true,
copy: true,
})
act(() => editor.setCurrentTool('hand'))
expect(enabledMap(editor, actions(), ids)).toEqual({
delete: false,
cut: false,
duplicate: false,
'bring-to-front': false,
'rotate-cw': false,
'align-left': false,
group: false,
'toggle-lock': false,
copy: false,
})
act(() => {
editor.setCurrentTool('select')
editor.updateShapes([{ id: a, type: 'geo', isLocked: true }])
editor.select(a, b)
})
expect(enabledMap(editor, actions(), ids)).toEqual({
delete: true,
cut: true,
duplicate: true,
'bring-to-front': true,
'rotate-cw': true,
'align-left': false,
group: false,
'toggle-lock': true,
copy: true,
})
})
describe('select-tool gating', () => {
const geo = (id: TLShapeId, x = 0) => ({ id, type: 'geo', x, y: 0 }) as const
const [g1, g2, g3] = [a, b, c]
const setups: Record void> = {
ungroup(editor) {
editor.createShapes([geo(g1), geo(g2, 200)])
editor.groupShapes([g1, g2], { groupId: createShapeId('grp') })
editor.select(createShapeId('grp'))
},
'edit-link'(editor) {
editor.createShapes([geo(g1)])
editor.select(g1)
},
'toggle-auto-size'(editor) {
editor.createShape({ id: g1, type: 'text', props: { autoSize: false } })
editor.select(g1)
},
'frame-selection'(editor) {
editor.createShapes([geo(g1)])
editor.select(g1)
},
'remove-frame'(editor) {
editor.createShape({ id: g1, type: 'frame' })
editor.select(g1)
},
'fit-frame-to-content'(editor) {
editor.createShape({ id: g1, type: 'frame' })
editor.createShape({ ...geo(g2), parentId: g1 })
editor.select(g1)
},
'convert-to-bookmark'(editor) {
editor.createShape({
id: g1,
type: 'embed',
props: { url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' },
})
editor.select(g1)
},
'convert-to-embed'(editor) {
editor.createShape({
id: g1,
type: 'bookmark',
props: { url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' },
})
editor.select(g1)
},
'flip-horizontal'(editor) {
editor.createShapes([geo(g1), geo(g2, 200)])
editor.select(g1, g2)
},
'flip-vertical'(editor) {
editor.createShapes([geo(g1), geo(g2, 200)])
editor.select(g1, g2)
},
'stack-horizontal'(editor) {
editor.createShapes([geo(g1), geo(g2, 200), geo(g3, 400)])
editor.select(g1, g2, g3)
},
'stack-vertical'(editor) {
editor.createShapes([geo(g1), geo(g2, 200), geo(g3, 400)])
editor.select(g1, g2, g3)
},
pack(editor) {
editor.createShapes([geo(g1), geo(g2, 200)])
editor.select(g1, g2)
},
'bring-to-front': oneGeo,
'bring-forward': oneGeo,
'send-backward': oneGeo,
'send-to-back': oneGeo,
'rotate-cw': oneGeo,
'rotate-ccw': oneGeo,
'enlarge-shapes': oneGeo,
'shrink-shapes': oneGeo,
duplicate: oneGeo,
delete: oneGeo,
cut: oneGeo,
copy: oneGeo,
'toggle-lock': oneGeo,
'select-none': oneGeo,
'zoom-to-selection': oneGeo,
group: twoGeo,
'align-left': twoGeo,
'align-center-horizontal': twoGeo,
'align-right': twoGeo,
'align-top': twoGeo,
'align-center-vertical': twoGeo,
'align-bottom': twoGeo,
'stretch-horizontal': twoGeo,
'stretch-vertical': twoGeo,
'distribute-horizontal': threeGeo,
'distribute-vertical': threeGeo,
}
function oneGeo(editor: Editor) {
editor.createShapes([geo(g1)])
editor.select(g1)
}
function twoGeo(editor: Editor) {
editor.createShapes([geo(g1), geo(g2, 200)])
editor.select(g1, g2)
}
function threeGeo(editor: Editor) {
editor.createShapes([geo(g1), geo(g2, 200), geo(g3, 400)])
editor.select(g1, g2, g3)
}
it.each(Object.keys(setups))(
'%s is enabled in select and disabled in another tool',
async (id) => {
const { editor, actions } = await setup()
act(() => setups[id](editor))
expect(actions()[id].isEnabled!(editor)).toBe(true)
act(() => editor.setCurrentTool('hand'))
expect(editor.getSelectedShapeIds().length).toBeGreaterThan(0)
expect(actions()[id].isEnabled!(editor)).toBe(false)
}
)
it('flip is off for a single locked shape', async () => {
const { editor, actions } = await setup()
act(() => {
editor.createShapes([{ ...geo(g1), isLocked: true }])
editor.select(g1)
})
expect(actions()['flip-horizontal'].isEnabled!(editor)).toBe(false)
})
})
it('select-all needs shapes but not the select tool', async () => {
const { editor, actions } = await setup()
expect(actions()['select-all'].isEnabled!(editor)).toBe(false)
seed(editor)
act(() => editor.setCurrentTool('hand'))
expect(actions()['select-all'].isEnabled!(editor)).toBe(true)
})
it('page-level actions', async () => {
const { editor, actions } = await setup()
const ids = ['unlock-all', 'export-all-as-svg', 'zoom-to-fit', 'print']
expect(enabledMap(editor, actions(), ids)).toEqual({
'unlock-all': false,
'export-all-as-svg': false,
'zoom-to-fit': false,
print: false,
})
seed(editor)
act(() => editor.updateShapes([{ id: c, type: 'geo', isLocked: true }]))
expect(enabledMap(editor, actions(), ids)).toEqual({
'unlock-all': true,
'export-all-as-svg': true,
'zoom-to-fit': true,
print: true,
})
})
it('move-to-new-page stops at max pages', async () => {
const { editor, actions } = await setup()
seed(editor)
act(() => editor.select(a))
expect(actions()['move-to-new-page'].isEnabled!(editor)).toBe(true)
act(() => {
for (let i = editor.getPages().length; i < editor.options.maxPages; i++) {
editor.createPage({ name: `p${i}` })
}
})
expect(actions()['move-to-new-page'].isEnabled!(editor)).toBe(false)
})
})
describe('built-in action isAvailable', () => {
it('splits availability from enablement', async () => {
const { editor, actions } = await setup()
const state = (id: string) => ({
available: actions()[id].isAvailable!(editor),
enabled: actions()[id].isEnabled!(editor),
})
expect(state('copy-as-json')).toEqual({ available: false, enabled: false })
act(() => editor.updateInstanceState({ isDebugMode: true }))
expect(state('copy-as-json')).toEqual({ available: true, enabled: false })
seed(editor)
expect(state('copy-as-json')).toEqual({ available: true, enabled: true })
expect(actions()['exit-pen-mode'].isAvailable!(editor)).toBe(false)
act(() => editor.updateInstanceState({ isPenMode: true }))
expect(actions()['exit-pen-mode'].isAvailable!(editor)).toBe(true)
})
})
describe('built-in action isChecked', () => {
it('reads user and instance state', async () => {
const { editor, actions } = await setup()
const checked = (id: string) => actions()[id].isChecked!(editor)
expect(checked('toggle-grid')).toBe(false)
act(() => editor.updateInstanceState({ isGridMode: true }))
expect(checked('toggle-grid')).toBe(true)
expect(checked('toggle-transparent')).toBe(!editor.getInstanceState().exportBackground)
act(() => editor.user.updateUserPreferences({ edgeScrollSpeed: 2 }))
expect(checked('toggle-edge-scrolling')).toBe(true)
act(() => editor.user.updateUserPreferences({ edgeScrollSpeed: 0 }))
expect(checked('toggle-edge-scrolling')).toBe(false)
})
it('every checkbox action has isChecked', async () => {
const { actions } = await setup()
const missing = Object.values(actions())
.filter((action) => action.checkbox && !action.isChecked)
.map((action) => action.id)
expect(missing).toEqual([])
})
})
describe('purity and stability', () => {
it('evaluating every isEnabled and isChecked changes nothing', async () => {
const { editor, actions } = await setup()
seed(editor)
act(() => {
editor.select(a, b)
editor.setCurrentTool('hand')
})
const before = { tool: editor.getCurrentToolId(), records: editor.store.serialize('all') }
for (const action of Object.values(actions())) {
action.isAvailable?.(editor)
action.isEnabled?.(editor)
action.isChecked?.(editor)
}
expect({ tool: editor.getCurrentToolId(), records: editor.store.serialize('all') }).toEqual(
before
)
})
it('selection changes do not rebuild the actions object', async () => {
const { editor, captured } = await setup()
seed(editor)
const count = captured.length
act(() => editor.select(a))
act(() => editor.select(a, b))
act(() => editor.selectNone())
expect(captured.length).toBe(count)
})
})