@tldraw/editor
Version:
tldraw infinite canvas SDK (editor).
835 lines (716 loc) • 26.7 kB
text/typescript
import {
Box,
Geometry2d,
RecordProps,
Rectangle2d,
ShapeUtil,
T,
TLBaseShape,
createShapeId,
createTLStore,
} from '../..'
import { Editor } from './Editor'
type ICustomShape = TLBaseShape<
'my-custom-shape',
{
w: number
h: number
text: string | undefined
isFilled: boolean
}
>
class CustomShape extends ShapeUtil<ICustomShape> {
static override type = 'my-custom-shape' as const
static override props: RecordProps<ICustomShape> = {
w: T.number,
h: T.number,
text: T.string.optional(),
isFilled: T.boolean,
}
getDefaultProps(): ICustomShape['props'] {
return {
w: 200,
h: 200,
text: '',
isFilled: false,
}
}
getGeometry(shape: ICustomShape): Geometry2d {
return new Rectangle2d({
width: shape.props.w,
height: shape.props.h,
isFilled: shape.props.isFilled,
})
}
indicator() {}
component() {}
}
let editor: Editor
beforeEach(() => {
editor = new Editor({
shapeUtils: [CustomShape],
bindingUtils: [],
tools: [],
store: createTLStore({ shapeUtils: [CustomShape], bindingUtils: [] }),
getContainer: () => document.body,
})
editor.setCameraOptions({ isLocked: true })
editor.setCamera = jest.fn()
editor.user.getAnimationSpeed = jest.fn()
})
describe('centerOnPoint', () => {
it('no-op when isLocked is set', () => {
editor.centerOnPoint({ x: 0, y: 0 })
expect(editor.setCamera).not.toHaveBeenCalled()
})
it('sets camera when isLocked is set and force flag is set', () => {
editor.centerOnPoint({ x: 0, y: 0 }, { force: true })
expect(editor.setCamera).toHaveBeenCalled()
})
})
describe('updateShape', () => {
it('updates shape props to undefined', () => {
const id = createShapeId('sample')
editor.createShape({
id,
type: 'my-custom-shape',
props: { w: 100, h: 100, text: 'Hello' },
})
const shape = editor.getShape(id) as ICustomShape
expect(shape.props).toEqual({ w: 100, h: 100, text: 'Hello', isFilled: false })
editor.updateShape({ ...shape, props: { ...shape.props, text: undefined } })
const updatedShape = editor.getShape(id) as ICustomShape
expect(updatedShape.props).toEqual({ w: 100, h: 100, text: undefined, isFilled: false })
})
})
describe('zoomToFit', () => {
it('no-op when isLocked is set', () => {
editor.getCurrentPageShapeIds = jest.fn(() => new Set([createShapeId('box1')]))
editor.zoomToFit()
expect(editor.setCamera).not.toHaveBeenCalled()
})
it('sets camera when isLocked is set and force flag is set', () => {
editor.getCurrentPageShapeIds = jest.fn(() => new Set([createShapeId('box1')]))
editor.zoomToFit({ force: true })
expect(editor.setCamera).toHaveBeenCalled()
})
})
describe('resetZoom', () => {
it('no-op when isLocked is set', () => {
editor.resetZoom()
expect(editor.setCamera).not.toHaveBeenCalled()
})
it('sets camera when isLocked is set and force flag is set', () => {
editor.resetZoom(undefined, { force: true })
expect(editor.setCamera).toHaveBeenCalled()
})
})
describe('zoomIn', () => {
it('no-op when isLocked is set', () => {
editor.zoomIn()
expect(editor.setCamera).not.toHaveBeenCalled()
})
it('sets camera when isLocked is set and force flag is set', () => {
editor.zoomIn(undefined, { force: true })
expect(editor.setCamera).toHaveBeenCalled()
})
})
describe('zoomOut', () => {
it('no-op when isLocked is set', () => {
editor.zoomOut()
expect(editor.setCamera).not.toHaveBeenCalled()
})
it('sets camera when isLocked is set and force flag is set', () => {
editor.zoomOut(undefined, { force: true })
expect(editor.setCamera).toHaveBeenCalled()
})
})
describe('zoomToSelection', () => {
it('no-op when isLocked is set', () => {
editor.getSelectionPageBounds = jest.fn(() => Box.From({ x: 0, y: 0, w: 100, h: 100 }))
editor.zoomToSelection()
expect(editor.setCamera).not.toHaveBeenCalled()
})
it('sets camera when isLocked is set and force flag is set', () => {
editor.getSelectionPageBounds = jest.fn(() => Box.From({ x: 0, y: 0, w: 100, h: 100 }))
editor.zoomToSelection({ force: true })
expect(editor.setCamera).toHaveBeenCalled()
})
})
describe('slideCamera', () => {
it('no-op when isLocked is set', () => {
editor.slideCamera({ speed: 1, direction: { x: 1, y: 1 } })
expect(editor.user.getAnimationSpeed).not.toHaveBeenCalled()
})
it('performs animation when isLocked is set and force flag is set', () => {
editor.slideCamera({ speed: 1, direction: { x: 1, y: 1 }, force: true })
expect(editor.user.getAnimationSpeed).toHaveBeenCalled()
})
})
describe('zoomToBounds', () => {
it('no-op when isLocked is set', () => {
editor.zoomToBounds({ x: 0, y: 0, w: 100, h: 100 })
expect(editor.setCamera).not.toHaveBeenCalled()
})
it('sets camera when isLocked is set and force flag is set', () => {
editor.zoomToBounds({ x: 0, y: 0, w: 100, h: 100 }, { force: true })
expect(editor.setCamera).toHaveBeenCalled()
})
})
describe('getShapesAtPoint', () => {
const ids = {
shape1: createShapeId('shape1'),
shape2: createShapeId('shape2'),
shape3: createShapeId('shape3'),
shape4: createShapeId('shape4'),
shape5: createShapeId('shape5'),
overlap1: createShapeId('overlap1'),
overlap2: createShapeId('overlap2'),
filledShape: createShapeId('filledShape'),
hollowShape: createShapeId('hollowShape'),
hiddenShape: createShapeId('hiddenShape'),
}
beforeEach(() => {
// Create test shapes with different z-index positions
// Shape 1: Bottom layer, large square
editor.createShape({
id: ids.shape1,
type: 'my-custom-shape',
x: 0,
y: 0,
props: { w: 200, h: 200, text: 'Bottom' },
})
// Shape 2: Middle layer, overlapping square
editor.createShape({
id: ids.shape2,
type: 'my-custom-shape',
x: 100,
y: 0,
props: { w: 200, h: 200, text: 'Middle' },
})
// Shape 3: Top layer, small square
editor.createShape({
id: ids.shape3,
type: 'my-custom-shape',
x: 50,
y: 50,
props: { w: 100, h: 100, text: 'Top' },
})
// Shape 4: Separate area, no overlap
editor.createShape({
id: ids.shape4,
type: 'my-custom-shape',
x: 50,
y: 100,
props: { w: 100, h: 100, text: 'Separate' },
})
})
it('returns shapes at a point in reverse z-index order', () => {
// Point at (50, 50) should hit shape3's edge (since it's at 50,50 with size 100x100)
// This point is exactly at the top-left corner of shape3
const shapes = editor.getShapesAtPoint({ x: 50, y: 50 })
const shapeIds = shapes.map((s) => s.id)
expect(shapeIds).toEqual([ids.shape3])
expect(shapes).toHaveLength(1)
})
it('returns empty array when no shapes at point', () => {
const shapes = editor.getShapesAtPoint({ x: 1000, y: 1000 })
expect(shapes).toEqual([])
})
it('returns single shape when point hits only one shape', () => {
// Point at right edge of shape2 where it doesn't overlap with other shapes
// Shape2 is at (100,0) with size 200x200, so right edge is at x=300
const shapes = editor.getShapesAtPoint({ x: 300, y: 100 })
expect(shapes).toHaveLength(1)
expect(shapes[0].id).toBe(ids.shape2)
})
it('returns shapes on edge when point is exactly on boundary', () => {
// Point at exact edge of shape1
const shapes = editor.getShapesAtPoint({ x: 0, y: 0 })
expect(shapes).toHaveLength(1)
expect(shapes[0].id).toBe(ids.shape1)
})
it('respects hitInside option when false (default)', () => {
// Point inside shape1 (at 0,0 with size 200x200) but with hitInside false should not hit
const shapes = editor.getShapesAtPoint({ x: 25, y: 25 }, { hitInside: false })
expect(shapes).toEqual([])
})
it('respects hitInside option when true', () => {
// Point inside shape1 (at 0,0 with size 200x200) with hitInside true should hit
const shapes = editor.getShapesAtPoint({ x: 25, y: 25 }, { hitInside: true })
expect(shapes).toHaveLength(1)
expect(shapes[0].id).toBe(ids.shape1)
})
it('respects margin option', () => {
// Point slightly outside shape1 at bottom edge but within margin should hit only shape1
// Shape1 is at (0,0) with size 200x200, shape2 goes to (300,200) so avoid overlap at (200,200)
const shapes = editor.getShapesAtPoint({ x: 205, y: 100 }, { margin: 10 })
expect(shapes).toHaveLength(1)
expect(shapes[0].id).toBe(ids.shape1)
})
it('filters out hidden shapes', () => {
// Create a spy to mock isShapeHidden
const isShapeHiddenSpy = jest.spyOn(editor, 'isShapeHidden')
isShapeHiddenSpy.mockImplementation((shape) => {
return typeof shape === 'string' ? shape === ids.shape3 : shape.id === ids.shape3
})
const shapes = editor.getShapesAtPoint({ x: 50, y: 50 })
const shapeIds = shapes.map((s) => s.id)
// Should not include shape3 since it's hidden, and no other shapes are at this point
expect(shapeIds).toEqual([])
expect(shapes).toHaveLength(0)
isShapeHiddenSpy.mockRestore()
})
it('handles point exactly at shape corner', () => {
// Point at bottom-left corner of shape1 where it doesn't overlap with other shapes
const shapes = editor.getShapesAtPoint({ x: 0, y: 200 })
expect(shapes).toHaveLength(1)
expect(shapes[0].id).toBe(ids.shape1)
})
it('handles overlapping shapes with different hit areas', () => {
// Point that hits both shape1 and shape2 edges (they overlap at x=100,y=0)
const shapes = editor.getShapesAtPoint({ x: 100, y: 0 })
const shapeIds = shapes.map((s) => s.id)
// Both shapes should be detected at this overlapping point (reversed order - top-most first)
expect(shapeIds).toEqual([ids.shape2, ids.shape1])
expect(shapes).toHaveLength(2)
})
it('maintains reverse shape order and responds to z-index changes', () => {
// Create filled shape that overlaps with shape2
editor.createShape({
id: ids.shape5,
type: 'my-custom-shape',
x: 110,
y: 110,
props: { w: 200, h: 200, isFilled: true, text: 'Shape5' },
})
// Test with hitInside to detect multiple shapes
// Point (120,120) will hit shape1, shape2, shape3, shape4, and shape5 with hitInside: true
const shapes = editor.getShapesAtPoint({ x: 120, y: 120 }, { hitInside: true })
const shapeIds = shapes.map((s) => s.id)
// All shapes that contain this point should be returned in reverse z-index order (top-most first)
expect(shapeIds).toEqual([ids.shape5, ids.shape4, ids.shape3, ids.shape2, ids.shape1])
// After bringing shape2 to front, order should change (shape2 becomes top-most)
editor.bringToFront([ids.shape2])
const shapes2 = editor.getShapesAtPoint({ x: 120, y: 120 }, { hitInside: true })
const shapeIds2 = shapes2.map((s) => s.id)
expect(shapeIds2).toEqual([ids.shape2, ids.shape5, ids.shape4, ids.shape3, ids.shape1])
})
it('combines hitInside and margin options', () => {
// Point inside shape1 (at 0,0 with size 200x200) with hitInside and margin
const shapes = editor.getShapesAtPoint({ x: 25, y: 25 }, { hitInside: true, margin: 5 })
expect(shapes).toHaveLength(1)
expect(shapes[0].id).toBe(ids.shape1)
})
it('returns empty array when all shapes are hidden', () => {
// Mock all shapes as hidden
const isShapeHiddenSpy = jest.spyOn(editor, 'isShapeHidden')
isShapeHiddenSpy.mockReturnValue(true)
const shapes = editor.getShapesAtPoint({ x: 50, y: 50 })
expect(shapes).toEqual([])
isShapeHiddenSpy.mockRestore()
})
it('returns multiple shapes at same point in reverse z-index order', () => {
// Create two shapes at exactly the same position (away from existing shapes)
editor.createShape({
id: ids.overlap1,
type: 'my-custom-shape',
x: 600,
y: 600,
props: { w: 100, h: 100, text: 'First' },
})
editor.createShape({
id: ids.overlap2,
type: 'my-custom-shape',
x: 600,
y: 600,
props: { w: 100, h: 100, text: 'Second' },
})
// Test at corner where both shapes' edges meet
const shapes = editor.getShapesAtPoint({ x: 600, y: 600 })
const shapeIds = shapes.map((s) => s.id)
// Should return both shapes in reverse z-index order (top-most first)
expect(shapeIds).toEqual([ids.overlap2, ids.overlap1])
expect(shapes).toHaveLength(2)
})
it('respects isFilled property for hit detection', () => {
// Create a filled shape
editor.createShape({
id: ids.filledShape,
type: 'my-custom-shape',
x: 300,
y: 300,
props: { w: 100, h: 100, isFilled: true, text: 'Filled' },
})
// Create a hollow shape at the same position
editor.createShape({
id: ids.hollowShape,
type: 'my-custom-shape',
x: 400,
y: 300,
props: { w: 100, h: 100, isFilled: false, text: 'Hollow' },
})
// Test point inside filled shape - should hit without hitInside option
const filledShapes = editor.getShapesAtPoint({ x: 350, y: 350 })
expect(filledShapes).toHaveLength(1)
expect(filledShapes[0].id).toBe(ids.filledShape)
// Test point inside hollow shape - should not hit without hitInside option
const hollowShapes = editor.getShapesAtPoint({ x: 450, y: 350 })
expect(hollowShapes).toHaveLength(0)
// Test point inside hollow shape with hitInside - should hit
const hollowShapesWithHitInside = editor.getShapesAtPoint(
{ x: 450, y: 350 },
{ hitInside: true }
)
expect(hollowShapesWithHitInside).toHaveLength(1)
expect(hollowShapesWithHitInside[0].id).toBe(ids.hollowShape)
})
})
describe('selectAll', () => {
const selectAllIds = {
pageShape1: createShapeId('pageShape1'),
pageShape2: createShapeId('pageShape2'),
pageShape3: createShapeId('pageShape3'),
container1: createShapeId('container1'),
containerChild1: createShapeId('containerChild1'),
containerChild2: createShapeId('containerChild2'),
containerChild3: createShapeId('containerChild3'),
containerGrandchild1: createShapeId('containerGrandchild1'),
container2: createShapeId('container2'),
container2Child1: createShapeId('container2Child1'),
container2Child2: createShapeId('container2Child2'),
container2Grandchild1: createShapeId('container2Grandchild1'),
lockedShape: createShapeId('lockedShape'),
}
beforeEach(() => {
// Clear any existing shapes
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
// Create shapes directly on the page (no parentId means they're children of the page)
editor.createShapes([
{
id: selectAllIds.pageShape1,
type: 'my-custom-shape',
x: 100,
y: 100,
props: { w: 100, h: 100 },
},
{
id: selectAllIds.pageShape2,
type: 'my-custom-shape',
x: 300,
y: 100,
props: { w: 100, h: 100 },
},
{
id: selectAllIds.pageShape3,
type: 'my-custom-shape',
x: 500,
y: 100,
props: { w: 100, h: 100 },
},
{
id: selectAllIds.lockedShape,
type: 'my-custom-shape',
x: 700,
y: 100,
props: { w: 100, h: 100 },
isLocked: true,
},
])
// Create a container shape (simulating a frame or group)
editor.createShape({
id: selectAllIds.container1,
type: 'my-custom-shape',
x: 100,
y: 300,
props: { w: 400, h: 200 },
})
// Create children inside the container (parentId set to container1)
editor.createShapes([
{
id: selectAllIds.containerChild1,
type: 'my-custom-shape',
parentId: selectAllIds.container1,
x: 120,
y: 320,
props: { w: 50, h: 50 },
},
{
id: selectAllIds.containerChild2,
type: 'my-custom-shape',
parentId: selectAllIds.container1,
x: 200,
y: 320,
props: { w: 50, h: 50 },
},
{
id: selectAllIds.containerChild3,
type: 'my-custom-shape',
parentId: selectAllIds.container1,
x: 280,
y: 320,
props: { w: 50, h: 50 },
},
])
// Create a grandchild inside one of the container children
editor.createShape({
id: selectAllIds.containerGrandchild1,
type: 'my-custom-shape',
parentId: selectAllIds.containerChild3,
x: 290,
y: 330,
props: { w: 30, h: 30 },
})
// Create a second container (simulating a group)
editor.createShape({
id: selectAllIds.container2,
type: 'my-custom-shape',
x: 600,
y: 300,
props: { w: 200, h: 200 },
})
// Create children inside the second container
editor.createShapes([
{
id: selectAllIds.container2Child1,
type: 'my-custom-shape',
parentId: selectAllIds.container2,
x: 620,
y: 320,
props: { w: 50, h: 50 },
},
{
id: selectAllIds.container2Child2,
type: 'my-custom-shape',
parentId: selectAllIds.container2,
x: 680,
y: 320,
props: { w: 50, h: 50 },
},
])
// Create a grandchild in the second container
editor.createShape({
id: selectAllIds.container2Grandchild1,
type: 'my-custom-shape',
parentId: selectAllIds.container2Child1,
x: 630,
y: 330,
props: { w: 30, h: 30 },
})
// Clear selection
editor.selectNone()
})
it('when no shapes are selected, selects all page-level shapes (excluding locked ones)', () => {
// Initially no shapes selected
expect(editor.getSelectedShapeIds()).toEqual([])
// Call selectAll
editor.selectAll()
// Should select all page-level shapes (excluding locked ones)
const selectedIds = editor.getSelectedShapeIds()
expect(Array.from(selectedIds).sort()).toEqual(
[
selectAllIds.pageShape1,
selectAllIds.pageShape2,
selectAllIds.pageShape3,
selectAllIds.container1,
selectAllIds.container2,
].sort()
)
// Should NOT include locked shape or children/grandchildren
expect(selectedIds).not.toContain(selectAllIds.lockedShape)
expect(selectedIds).not.toContain(selectAllIds.containerChild1)
expect(selectedIds).not.toContain(selectAllIds.containerChild2)
expect(selectedIds).not.toContain(selectAllIds.containerChild3)
expect(selectedIds).not.toContain(selectAllIds.containerGrandchild1)
expect(selectedIds).not.toContain(selectAllIds.container2Child1)
expect(selectedIds).not.toContain(selectAllIds.container2Child2)
expect(selectedIds).not.toContain(selectAllIds.container2Grandchild1)
})
it('when shapes are selected only on the page, all children of the page should be selected (but not their descendants)', () => {
// Select some page-level shapes
editor.select(selectAllIds.pageShape1, selectAllIds.pageShape2)
// Call selectAll
editor.selectAll()
// Should select all page-level shapes (excluding locked ones), but not descendants
const selectedIds = editor.getSelectedShapeIds()
expect(Array.from(selectedIds).sort()).toEqual(
[
selectAllIds.pageShape1,
selectAllIds.pageShape2,
selectAllIds.pageShape3,
selectAllIds.container1,
selectAllIds.container2,
].sort()
)
// Should NOT include children or grandchildren or locked shapes
expect(selectedIds).not.toContain(selectAllIds.containerChild1)
expect(selectedIds).not.toContain(selectAllIds.containerChild2)
expect(selectedIds).not.toContain(selectAllIds.containerChild3)
expect(selectedIds).not.toContain(selectAllIds.containerGrandchild1)
expect(selectedIds).not.toContain(selectAllIds.container2Child1)
expect(selectedIds).not.toContain(selectAllIds.container2Child2)
expect(selectedIds).not.toContain(selectAllIds.container2Grandchild1)
expect(selectedIds).not.toContain(selectAllIds.lockedShape)
})
it('when shapes are selected within a container, only children of the container should be selected (not their descendants)', () => {
// Select some container children
editor.select(selectAllIds.containerChild1, selectAllIds.containerChild2)
// Call selectAll
editor.selectAll()
// Should select all container children (but not their descendants)
const selectedIds = editor.getSelectedShapeIds()
expect(Array.from(selectedIds).sort()).toEqual(
[
selectAllIds.containerChild1,
selectAllIds.containerChild2,
selectAllIds.containerChild3,
].sort()
)
// Should NOT include page-level shapes or grandchildren
expect(selectedIds).not.toContain(selectAllIds.pageShape1)
expect(selectedIds).not.toContain(selectAllIds.pageShape2)
expect(selectedIds).not.toContain(selectAllIds.pageShape3)
expect(selectedIds).not.toContain(selectAllIds.container1)
expect(selectedIds).not.toContain(selectAllIds.container2)
expect(selectedIds).not.toContain(selectAllIds.containerGrandchild1)
expect(selectedIds).not.toContain(selectAllIds.container2Child1)
expect(selectedIds).not.toContain(selectAllIds.container2Child2)
expect(selectedIds).not.toContain(selectAllIds.container2Grandchild1)
})
it('when shapes are selected within a second container, only children of that container should be selected', () => {
// Select some second container children
editor.select(selectAllIds.container2Child1)
// Call selectAll
editor.selectAll()
// Should select all second container children (but not their descendants)
const selectedIds = editor.getSelectedShapeIds()
expect(Array.from(selectedIds).sort()).toEqual(
[selectAllIds.container2Child1, selectAllIds.container2Child2].sort()
)
// Should NOT include page-level shapes or other container's children or grandchildren
expect(selectedIds).not.toContain(selectAllIds.pageShape1)
expect(selectedIds).not.toContain(selectAllIds.pageShape2)
expect(selectedIds).not.toContain(selectAllIds.pageShape3)
expect(selectedIds).not.toContain(selectAllIds.container1)
expect(selectedIds).not.toContain(selectAllIds.container2)
expect(selectedIds).not.toContain(selectAllIds.containerChild1)
expect(selectedIds).not.toContain(selectAllIds.containerChild2)
expect(selectedIds).not.toContain(selectAllIds.containerChild3)
expect(selectedIds).not.toContain(selectAllIds.containerGrandchild1)
expect(selectedIds).not.toContain(selectAllIds.container2Grandchild1)
})
it('when shapes are selected that belong to different parents, no change/history entry should be made', () => {
// Select shapes from different parents (page and container)
editor.select(selectAllIds.pageShape1, selectAllIds.containerChild1)
const initialSelectedIds = editor.getSelectedShapeIds()
// Spy on setSelectedShapes to verify it's not called
const setSelectedShapesSpy = jest.spyOn(editor, 'setSelectedShapes')
// Call selectAll
editor.selectAll()
// Selection should remain unchanged
expect(editor.getSelectedShapeIds()).toEqual(initialSelectedIds)
// setSelectedShapes should not have been called (the method returns early)
expect(setSelectedShapesSpy).not.toHaveBeenCalled()
setSelectedShapesSpy.mockRestore()
})
it('when shapes are selected that belong to different containers, no change/history entry should be made', () => {
// Select shapes from different containers
editor.select(selectAllIds.containerChild1, selectAllIds.container2Child1)
const initialSelectedIds = editor.getSelectedShapeIds()
// Spy on setSelectedShapes to verify it's not called
const setSelectedShapesSpy = jest.spyOn(editor, 'setSelectedShapes')
// Call selectAll
editor.selectAll()
// Selection should remain unchanged
expect(editor.getSelectedShapeIds()).toEqual(initialSelectedIds)
// setSelectedShapes should not have been called
expect(setSelectedShapesSpy).not.toHaveBeenCalled()
setSelectedShapesSpy.mockRestore()
})
it('should not select locked shapes', () => {
// Select a page-level shape
editor.select(selectAllIds.pageShape1)
// Call selectAll
editor.selectAll()
// Should select all page-level shapes except locked ones
const selectedIds = editor.getSelectedShapeIds()
expect(selectedIds).not.toContain(selectAllIds.lockedShape)
expect(selectedIds).toContain(selectAllIds.pageShape1)
expect(selectedIds).toContain(selectAllIds.pageShape2)
expect(selectedIds).toContain(selectAllIds.pageShape3)
expect(selectedIds).toContain(selectAllIds.container1)
expect(selectedIds).toContain(selectAllIds.container2)
})
it('should handle empty container by selecting all siblings at the same level', () => {
// Create an empty container
const emptyContainerId = createShapeId('emptyContainer')
editor.createShape({
id: emptyContainerId,
type: 'my-custom-shape',
x: 800,
y: 400,
props: { w: 100, h: 100 },
})
// Clear selection first
editor.selectNone()
// Select the empty container
editor.select(emptyContainerId)
// Call selectAll - since the empty container has no children, it should select all siblings (page-level shapes)
editor.selectAll()
// Should select all page-level shapes (including the empty container itself)
const selectedIds = editor.getSelectedShapeIds()
expect(Array.from(selectedIds).sort()).toEqual(
[
selectAllIds.pageShape1,
selectAllIds.pageShape2,
selectAllIds.pageShape3,
selectAllIds.container1,
selectAllIds.container2,
emptyContainerId,
].sort()
)
// Should NOT include locked shapes or children/grandchildren
expect(selectedIds).not.toContain(selectAllIds.lockedShape)
expect(selectedIds).not.toContain(selectAllIds.containerChild1)
expect(selectedIds).not.toContain(selectAllIds.containerChild2)
expect(selectedIds).not.toContain(selectAllIds.containerChild3)
expect(selectedIds).not.toContain(selectAllIds.containerGrandchild1)
expect(selectedIds).not.toContain(selectAllIds.container2Child1)
expect(selectedIds).not.toContain(selectAllIds.container2Child2)
expect(selectedIds).not.toContain(selectAllIds.container2Grandchild1)
})
it('should work correctly when selecting all shapes of same parent type', () => {
// Select all container children
editor.select(
selectAllIds.containerChild1,
selectAllIds.containerChild2,
selectAllIds.containerChild3
)
// Call selectAll - should maintain the same selection since all children are already selected
editor.selectAll()
// Should still have all container children selected
const selectedIds = editor.getSelectedShapeIds()
expect(Array.from(selectedIds).sort()).toEqual(
[
selectAllIds.containerChild1,
selectAllIds.containerChild2,
selectAllIds.containerChild3,
].sort()
)
})
it('should handle mixed selection levels gracefully by doing nothing', () => {
// Select a mix: page shape (parent=page), container (parent=page), and container child (parent=container1)
// These all have different parent IDs so selectAll should do nothing
editor.select(selectAllIds.pageShape1, selectAllIds.containerChild1)
const initialSelectedIds = Array.from(editor.getSelectedShapeIds())
// Spy on setSelectedShapes to verify it's not called
const setSelectedShapesSpy = jest.spyOn(editor, 'setSelectedShapes')
// Call selectAll
editor.selectAll()
// Selection should remain unchanged since shapes have different parents
expect(Array.from(editor.getSelectedShapeIds())).toEqual(initialSelectedIds)
// setSelectedShapes should not have been called
expect(setSelectedShapesSpy).not.toHaveBeenCalled()
setSelectedShapesSpy.mockRestore()
})
})