tldraw
Version:
A tiny little drawing editor.
1,573 lines (1,379 loc) • 71.8 kB
text/typescript
import {
Box,
GroupShapeUtil,
TLArrowShape,
TLGroupShape,
TLLineShape,
TLShape,
TLShapeId,
TLShapePartial,
VecLike,
approximately,
assert,
compact,
createShapeId,
mockUniqueId,
sortByIndex,
} from '@tldraw/editor'
import { getArrowBindings } from '../lib/shapes/arrow/shared'
import { TestEditor } from './TestEditor'
let nextNanoId = 0
mockUniqueId(() => `${nextNanoId++}`)
const ids = {
boxA: createShapeId('boxA'),
boxB: createShapeId('boxB'),
boxC: createShapeId('boxC'),
boxD: createShapeId('boxD'),
boxE: createShapeId('boxE'),
boxF: createShapeId('boxF'),
boxX: createShapeId('boxX'),
lineA: createShapeId('lineA'),
groupA: createShapeId('groupA'),
}
const box = (
id: TLShapeId,
x: number,
y: number,
w = 10,
h = 10,
fill = 'solid'
): TLShapePartial => ({
type: 'geo',
id,
x,
y,
// index: bumpIndex(),
props: {
w,
h,
fill,
},
})
const arrow = (id: TLShapeId, start: VecLike, end: VecLike): TLShapePartial => ({
type: 'arrow',
id,
// index: bumpIndex(),
props: {
start: { x: start.x, y: start.y },
end: { x: end.x, y: end.y },
},
})
const randomRotation = () => Math.random() * Math.PI * 2
const randomCoord = () => Math.random() * 100 - 50
const randomSize = () => Math.random() * 99 + 1
let editor: TestEditor
beforeEach(() => {
editor = new TestEditor()
})
afterEach(() => {
editor?.dispose()
})
const getAllShapes = () => editor.getCurrentPageShapes()
const onlySelectedId = () => {
expect(editor.getSelectedShapeIds()).toHaveLength(1)
return editor.getSelectedShapeIds()[0]
}
const onlySelectedShape = () => {
const id = onlySelectedId()
return editor.getShape(id)!
}
const children = (shape: TLShape) => {
return new Set(
compact(editor.getSortedChildIdsForParent(shape.id).map((id) => editor.getShape(id)))
)
}
const isRemoved = (shape: TLShape) => {
return !editor.getShape(shape.id)
}
describe('creating groups', () => {
it('works if there are multiple shapes in the selection', () => {
// 0 10 20 30 40 50
// ┌───┐ ┌───┐ ┌───┐
// │ A │ │ B │ │ C │
// └───┘ └───┘ └───┘
editor.createShapes([box(ids.boxA, 0, 0), box(ids.boxB, 20, 0), box(ids.boxC, 40, 0)])
editor.select(ids.boxA, ids.boxB)
expect(getAllShapes()).toHaveLength(3)
expect(editor.getSelectedShapeIds().length).toBe(2)
editor.groupShapes(editor.getSelectedShapeIds())
expect(getAllShapes()).toHaveLength(4)
expect(editor.getSelectedShapeIds().length).toBe(1)
expect(editor.getShape(ids.boxA)).toBeTruthy()
expect(editor.getShape(ids.boxB)).toBeTruthy()
const group = onlySelectedShape()
expect(group.type).toBe(GroupShapeUtil.type)
expect(editor.getShapePageBounds(group.id)!).toCloselyMatchObject({
x: 0,
y: 0,
w: 30,
h: 10,
})
expect(children(group).has(editor.getShape(ids.boxA)!)).toBe(true)
expect(children(group).has(editor.getShape(ids.boxB)!)).toBe(true)
expect(children(group).has(editor.getShape(ids.boxC)!)).toBe(false)
})
it('does not work if there are zero or one shape in the selection ', () => {
// 0 10 20 30 40 50
// ┌───┐ ┌───┐ ┌───┐
// │ A │ │ B │ │ C │
// └───┘ └───┘ └───┘
editor.createShapes([box(ids.boxA, 0, 0), box(ids.boxB, 20, 0), box(ids.boxC, 40, 0)])
expect(getAllShapes()).toHaveLength(3)
editor.groupShapes(editor.getSelectedShapeIds())
expect(getAllShapes()).toHaveLength(3)
editor.select(ids.boxA)
editor.groupShapes(editor.getSelectedShapeIds())
expect(getAllShapes()).toHaveLength(3)
expect(onlySelectedId()).toBe(ids.boxA)
})
it('preserves the page positions and rotations of the grouped shapes', () => {
for (let i = 0; i < 100; i++) {
const shapes = [
{
...box(ids.boxA, randomCoord(), randomCoord(), randomSize(), randomSize()),
rotation: randomRotation(),
},
{
...box(ids.boxB, randomCoord(), randomCoord(), randomSize(), randomSize()),
rotation: randomRotation(),
},
{
...box(ids.boxC, randomCoord(), randomCoord(), randomSize(), randomSize()),
rotation: randomRotation(),
},
]
editor.createShapes(shapes)
const initialPageBounds = {
A: editor.getShapePageBounds(ids.boxA)!.clone(),
B: editor.getShapePageBounds(ids.boxB)!.clone(),
C: editor.getShapePageBounds(ids.boxC)!.clone(),
}
const initialPageRotations = {
A: editor.getPageRotationById(ids.boxA),
B: editor.getPageRotationById(ids.boxB),
C: editor.getPageRotationById(ids.boxC),
}
editor.select(ids.boxA, ids.boxB, ids.boxC)
editor.groupShapes(editor.getSelectedShapeIds())
try {
expect({
A: editor.getShapePageBounds(ids.boxA)!.clone(),
B: editor.getShapePageBounds(ids.boxB)!.clone(),
C: editor.getShapePageBounds(ids.boxC)!.clone(),
}).toCloselyMatchObject(initialPageBounds)
expect({
A: editor.getPageRotationById(ids.boxA),
B: editor.getPageRotationById(ids.boxB),
C: editor.getPageRotationById(ids.boxC),
}).toCloselyMatchObject(initialPageRotations)
} catch (e) {
console.error('Failing nodes', JSON.stringify(shapes))
throw e
}
}
})
it('works with nested groups', () => {
// 0 10 20 30 40 50 60 70
// ┌───┐ ┌───┐ ┌───┐ ┌───┐
// │ A │ │ B │ │ C │ │ D │
// └───┘ └───┘ └───┘ └───┘
editor.createShapes([
box(ids.boxA, 0, 0),
box(ids.boxB, 20, 0),
box(ids.boxC, 40, 0),
box(ids.boxD, 60, 0),
])
editor.select(ids.boxA, ids.boxB)
editor.groupShapes(editor.getSelectedShapeIds())
const groupAId = onlySelectedId()
editor.select(ids.boxC, ids.boxD)
editor.groupShapes(editor.getSelectedShapeIds())
const groupBId = onlySelectedId()
editor.select(groupAId, groupBId)
editor.groupShapes(editor.getSelectedShapeIds())
const uberGroup = onlySelectedShape()
expect(uberGroup.type).toBe(GroupShapeUtil.type)
expect(editor.getShapePageBounds(uberGroup.id)!).toCloselyMatchObject({
x: 0,
y: 0,
w: 70,
h: 10,
})
expect(children(uberGroup).size).toBe(2)
expect(children(uberGroup).has(editor.getShape(groupAId)!)).toBe(true)
expect(children(uberGroup).has(editor.getShape(groupBId)!)).toBe(true)
})
it('works with shapes inside individual nested groups', () => {
// 0 10 20 30 40 50 60 70 80 90 100 110
//
// ┌───┐ ┌───┐ ┌───┐ ┌───┐
// │ A │ │ C │ │ D │ │ F │
// 10 └───┘ └───┘ └───┘ └───┘
//
// 20 ┌───┐ ┌───┐
// │ B │ │ E │
// 30 └───┘ └───┘
editor.createShapes([
box(ids.boxA, 0, 0),
box(ids.boxB, 20, 20),
box(ids.boxC, 40, 0),
box(ids.boxD, 60, 0),
box(ids.boxE, 80, 20),
box(ids.boxF, 100, 0),
])
editor.select(ids.boxA, ids.boxB, ids.boxC)
editor.groupShapes(editor.getSelectedShapeIds())
const groupA = onlySelectedShape()
editor.select(ids.boxD, ids.boxE, ids.boxF)
editor.groupShapes(editor.getSelectedShapeIds())
const groupB = onlySelectedShape()
editor.select(ids.boxB, ids.boxE)
editor.groupShapes(editor.getSelectedShapeIds())
const groupC = onlySelectedShape()
expect(children(groupA).size).toBe(2)
expect(children(groupB).size).toBe(2)
expect(children(groupC).size).toBe(2)
expect(groupA.parentId).toBe(editor.getCurrentPageId())
expect(groupB.parentId).toBe(editor.getCurrentPageId())
expect(groupC.parentId).toBe(editor.getCurrentPageId())
expect(editor.getShape(ids.boxA)!.parentId).toBe(groupA.id)
expect(editor.getShape(ids.boxC)!.parentId).toBe(groupA.id)
expect(editor.getShape(ids.boxB)!.parentId).toBe(groupC.id)
expect(editor.getShape(ids.boxE)!.parentId).toBe(groupC.id)
expect(editor.getShape(ids.boxD)!.parentId).toBe(groupB.id)
expect(editor.getShape(ids.boxF)!.parentId).toBe(groupB.id)
})
it('does not work if the scene is in readonly mode', () => {
// 0 10 20 30 40 50
// ┌───┐ ┌───┐ ┌───┐
// │ A │ │ B │ │ C │
// └───┘ └───┘ └───┘
editor.createShapes([box(ids.boxA, 0, 0), box(ids.boxB, 20, 0), box(ids.boxC, 40, 0)])
editor.updateInstanceState({ isReadonly: true })
editor.setCurrentTool('hand')
editor.selectAll()
expect(editor.getSelectedShapeIds().length).toBe(3)
editor.groupShapes(editor.getSelectedShapeIds())
expect(editor.getSelectedShapeIds().length).toBe(3)
})
it('keeps order correct simple', () => {
// 0 10 20 30 40 50 60 70
// ┌───┐ ┌───┐ ┌───┐ ┌───┐
// │ A │ │ B │ │ C │ │ D │
// └───┘ └───┘ └───┘ └───┘
editor.createShapes([
box(ids.boxA, 0, 0),
box(ids.boxB, 20, 0),
box(ids.boxC, 40, 0),
box(ids.boxD, 60, 0),
])
editor.select(ids.boxC, ids.boxB)
editor.groupShapes(editor.getSelectedShapeIds())
const groupAId = onlySelectedId()
const sortedGroupChildrenIds = editor
.getSortedChildIdsForParent(groupAId)
.map((id) => editor.getShape(id)!)
.sort(sortByIndex)
.map((shape) => shape.id)
const sortedIds = editor.getSortedChildIdsForParent(editor.getCurrentPageId())
expect(sortedIds.length).toBe(3)
expect(sortedIds[0]).toBe(ids.boxA)
expect(sortedIds[1]).toBe(groupAId)
expect(sortedIds[2]).toBe(ids.boxD)
expect(sortedGroupChildrenIds.length).toBe(2)
expect(sortedGroupChildrenIds[0]).toBe(ids.boxB)
expect(sortedGroupChildrenIds[1]).toBe(ids.boxC)
})
it('keeps order correct complex', () => {
// 0 10 20 30 40 50 60 70
// ┌───┐ ┌───┐ ┌───┐ ┌───┐
// │ A │ │ B │ │ C │ │ D │
// └───┘ └───┘ └───┘ └───┘
editor.createShapes([
box(ids.boxA, 0, 0),
box(ids.boxB, 20, 0),
box(ids.boxC, 40, 0),
box(ids.boxD, 60, 0),
])
editor.select(ids.boxC, ids.boxA)
editor.groupShapes(editor.getSelectedShapeIds())
const groupAId = onlySelectedId()
const sortedGroupChildrenIds = editor
.getSortedChildIdsForParent(groupAId)
.map((id) => editor.getShape(id)!)
.sort(sortByIndex)
.map((shape) => shape.id)
const sortedIds = editor.getSortedChildIdsForParent(editor.getCurrentPageId())
expect(sortedIds.length).toBe(3)
expect(sortedIds[0]).toBe(ids.boxB)
expect(sortedIds[1]).toBe(groupAId)
expect(sortedIds[2]).toBe(ids.boxD)
expect(sortedGroupChildrenIds.length).toBe(2)
expect(sortedGroupChildrenIds[0]).toBe(ids.boxA)
expect(sortedGroupChildrenIds[1]).toBe(ids.boxC)
})
})
describe('ungrouping shapes', () => {
it('works if there is one selected shape and that shape is a group', () => {
// 0 10 20 30 40 50
// ┌───┐ ┌───┐ ┌───┐
// │ A │ │ B │ │ C │
// └───┘ └───┘ └───┘
editor.createShapes([box(ids.boxA, 0, 0), box(ids.boxB, 20, 0), box(ids.boxC, 40, 0)])
editor.select(ids.boxA, ids.boxB)
editor.groupShapes(editor.getSelectedShapeIds())
const groupA = onlySelectedShape()
editor.ungroupShapes(editor.getSelectedShapeIds())
expect(isRemoved(groupA)).toBe(true)
expect(new Set(editor.getSelectedShapeIds())).toEqual(new Set([ids.boxA, ids.boxB]))
expect(editor.getShapePageBounds(ids.boxA)!).toCloselyMatchObject({
x: 0,
y: 0,
w: 10,
h: 10,
})
expect(editor.getShapePageBounds(ids.boxB)!).toCloselyMatchObject({
x: 20,
y: 0,
w: 10,
h: 10,
})
})
it('selects the groups children and other non-group shapes on ungroup', () => {
editor.createShapes([box(ids.boxA, 0, 0), box(ids.boxB, 20, 0), box(ids.boxC, 40, 0)])
editor.select(ids.boxA, ids.boxB)
editor.groupShapes(editor.getSelectedShapeIds())
const groupA = onlySelectedShape()
editor.select(groupA.id, ids.boxC)
editor.ungroupShapes(editor.getSelectedShapeIds())
expect(new Set(editor.getSelectedShapeIds())).toMatchObject(
new Set([ids.boxA, ids.boxB, ids.boxC])
)
})
it('preserves the page positions and rotations of the ungrouped shapes', () => {
for (let i = 0; i < 100; i++) {
const shapes = [
{
...box(ids.boxA, randomCoord(), randomCoord(), randomSize(), randomSize()),
rotation: randomRotation(),
},
{
...box(ids.boxB, randomCoord(), randomCoord(), randomSize(), randomSize()),
rotation: randomRotation(),
},
{
...box(ids.boxC, randomCoord(), randomCoord(), randomSize(), randomSize()),
rotation: randomRotation(),
},
]
editor.createShapes(shapes)
const initialPageBounds = {
A: editor.getShapePageBounds(ids.boxA)!.clone(),
B: editor.getShapePageBounds(ids.boxB)!.clone(),
C: editor.getShapePageBounds(ids.boxC)!.clone(),
}
const initialPageRotations = {
A: editor.getPageRotationById(ids.boxA),
B: editor.getPageRotationById(ids.boxB),
C: editor.getPageRotationById(ids.boxC),
}
editor.select(ids.boxA, ids.boxB, ids.boxC)
editor.groupShapes(editor.getSelectedShapeIds())
editor.ungroupShapes(editor.getSelectedShapeIds())
expect(editor.getSelectedShapeIds().length).toBe(3)
try {
expect({
A: editor.getShapePageBounds(ids.boxA)!.clone(),
B: editor.getShapePageBounds(ids.boxB)!.clone(),
C: editor.getShapePageBounds(ids.boxC)!.clone(),
}).toCloselyMatchObject(initialPageBounds)
expect({
A: editor.getPageRotationById(ids.boxA),
B: editor.getPageRotationById(ids.boxB),
C: editor.getPageRotationById(ids.boxC),
}).toCloselyMatchObject(initialPageRotations)
} catch (e) {
console.error('Failing shapes', JSON.stringify(shapes))
throw e
}
}
})
it('does not ungroup nested groups', () => {
// 0 10 20 30 40 50 60 70
// ┌───┐ ┌───┐ ┌───┐ ┌───┐
// │ A │ │ B │ │ C │ │ D │
// └───┘ └───┘ └───┘ └───┘
editor.createShapes([
box(ids.boxA, 0, 0),
box(ids.boxB, 20, 0),
box(ids.boxC, 40, 0),
box(ids.boxD, 60, 0),
])
editor.select(ids.boxA, ids.boxB)
editor.groupShapes(editor.getSelectedShapeIds())
const groupAId = onlySelectedId()
editor.select(ids.boxC, ids.boxD)
editor.groupShapes(editor.getSelectedShapeIds())
const groupBId = onlySelectedId()
editor.select(groupAId, groupBId)
editor.groupShapes(editor.getSelectedShapeIds())
expect(editor.getSelectedShapeIds().length).toBe(1)
editor.ungroupShapes(editor.getSelectedShapeIds())
expect(editor.getSelectedShapeIds().length).toBe(2)
expect(editor.getShape(groupAId)).not.toBe(undefined)
expect(editor.getShape(groupBId)).not.toBe(undefined)
})
it('does not work if the scene is in readonly mode', () => {
// 0 10 20 30 40 50
// ┌───┐ ┌───┐ ┌───┐
// │ A │ │ B │ │ C │
// └───┘ └───┘ └───┘
editor.createShapes([box(ids.boxA, 0, 0), box(ids.boxB, 20, 0), box(ids.boxC, 40, 0)])
editor.selectAll()
expect(editor.getSelectedShapeIds().length).toBe(3)
editor.groupShapes(editor.getSelectedShapeIds())
expect(editor.getSelectedShapeIds().length).toBe(1)
editor.updateInstanceState({ isReadonly: true })
editor.setCurrentTool('hand')
editor.ungroupShapes(editor.getSelectedShapeIds())
expect(editor.getSelectedShapeIds().length).toBe(1)
expect(onlySelectedShape().type).toBe(GroupShapeUtil.type)
})
it('keeps order correct simple', () => {
// 0 10 20 30 40 50 60 70
// ┌───┐ ┌───┐ ┌───┐ ┌───┐
// │ A │ │ B │ │ C │ │ D │
// └───┘ └───┘ └───┘ └───┘
editor.createShapes([
box(ids.boxA, 0, 0),
box(ids.boxB, 20, 0),
box(ids.boxC, 40, 0),
box(ids.boxD, 60, 0),
])
editor.select(ids.boxC, ids.boxB)
editor.groupShapes(editor.getSelectedShapeIds())
editor.ungroupShapes(editor.getSelectedShapeIds())
const sortedShapesOnCurrentPage = editor
.getCurrentPageShapes()
.sort(sortByIndex)
.map((shape) => shape.id)
expect(sortedShapesOnCurrentPage.length).toBe(4)
expect(sortedShapesOnCurrentPage[0]).toBe(ids.boxA)
expect(sortedShapesOnCurrentPage[1]).toBe(ids.boxB)
expect(sortedShapesOnCurrentPage[2]).toBe(ids.boxC)
expect(sortedShapesOnCurrentPage[3]).toBe(ids.boxD)
})
it('keeps order correct complex', () => {
// 0 10 20 30 40 50 60 70
// ┌───┐ ┌───┐ ┌───┐ ┌───┐
// │ A │ │ B │ │ C │ │ D │
// └───┘ └───┘ └───┘ └───┘
editor.createShapes([
box(ids.boxA, 0, 0),
box(ids.boxB, 20, 0),
box(ids.boxC, 40, 0),
box(ids.boxD, 60, 0),
])
editor.select(ids.boxC, ids.boxA)
editor.groupShapes(editor.getSelectedShapeIds())
editor.ungroupShapes(editor.getSelectedShapeIds())
const sortedShapesOnCurrentPage = editor
.getCurrentPageShapes()
.sort(sortByIndex)
.map((shape) => shape.id)
expect(sortedShapesOnCurrentPage.length).toBe(4)
expect(sortedShapesOnCurrentPage[0]).toBe(ids.boxB)
expect(sortedShapesOnCurrentPage[1]).toBe(ids.boxA)
expect(sortedShapesOnCurrentPage[2]).toBe(ids.boxC)
expect(sortedShapesOnCurrentPage[3]).toBe(ids.boxD)
})
})
describe('the bounds of a group', () => {
it('changes when the children rotate', () => {
editor.createShapes([
box(ids.boxA, 0, 0, 100, 100),
{
id: ids.boxB,
type: 'geo',
x: 200,
y: 200,
props: {
geo: 'ellipse',
w: 100,
h: 100,
},
},
])
editor.select(ids.boxA, ids.boxB)
editor.groupShapes(editor.getSelectedShapeIds())
const group = onlySelectedShape()
expect(editor.getShapePageBounds(group.id)!.minX).toBe(0)
editor.select(ids.boxA).rotateSelection(Math.PI / 4)
// pythagoras to the rescue
const expectedLeftBound = 50 - Math.sqrt(2 * (100 * 100)) / 2
expect(editor.getShapePageBounds(group.id)!.minX).toBeCloseTo(expectedLeftBound)
// rotating the circle doesn't move the right edge because it's outline doesn't change
expect(editor.getShapePageBounds(group.id)!.maxX).toBe(300)
editor.select(ids.boxB).rotateSelection(Math.PI / 4)
expect(approximately(editor.getShapePageBounds(group.id)!.maxX, 300, 1)).toBe(true)
})
it('changes when shapes translate', () => {
// 0 10 20 30 40 50
// ┌───┐ ┌───┐ ┌───┐
// │ A │ │ B │ │ C │
// └───┘ └───┘ └───┘
editor.createShapes([box(ids.boxA, 0, 0), box(ids.boxB, 20, 0), box(ids.boxC, 40, 0)])
editor.select(ids.boxA, ids.boxB, ids.boxC)
editor.groupShapes(editor.getSelectedShapeIds())
const group = onlySelectedShape()
expect(editor.getShapePageBounds(group.id)!).toCloselyMatchObject({
x: 0,
y: 0,
w: 50,
h: 10,
})
// move A to the left
editor.select(ids.boxA).translateSelection(-10, 0)
expect(editor.getShapePageBounds(group.id)!).toCloselyMatchObject({
x: -10,
y: 0,
w: 60,
h: 10,
})
// move C up and to the right
editor.select(ids.boxC).translateSelection(10, -10)
expect(editor.getShapePageBounds(group.id)!).toCloselyMatchObject({
x: -10,
y: -10,
w: 70,
h: 20,
})
})
it('changes when shapes resize', () => {
// 0 10 20 30 40 50
// ┌───┐ ┌───┐ ┌───┐
// │ A │ │ B │ │ C │
// └───┘ └───┘ └───┘
editor.createShapes([box(ids.boxA, 0, 0), box(ids.boxB, 20, 0), box(ids.boxC, 40, 0)])
editor.select(ids.boxA, ids.boxB, ids.boxC)
editor.groupShapes(editor.getSelectedShapeIds())
const group = onlySelectedShape()
expect(editor.getShapePageBounds(group.id)!).toCloselyMatchObject({
x: 0,
y: 0,
w: 50,
h: 10,
})
// resize A to the left
editor.select(ids.boxA).resizeSelection({ scaleX: 2 }, 'left')
expect(editor.getShapePageBounds(group.id)!).toCloselyMatchObject({
x: -10,
y: 0,
w: 60,
h: 10,
})
// resize C up and to the right
editor.select(ids.boxC).resizeSelection({ scaleY: 2, scaleX: 2 }, 'top_right')
expect(editor.getShapePageBounds(group.id)!).toCloselyMatchObject({
x: -10,
y: -10,
w: 70,
h: 20,
})
})
})
describe('the bounds of a rotated group', () => {
it('changes when the children rotate', () => {
editor.createShapes([
box(ids.boxA, 0, 0, 100, 100),
{
id: ids.boxB,
type: 'geo',
x: 200,
y: 200,
props: {
geo: 'ellipse',
w: 100,
h: 100,
},
},
])
editor.select(ids.boxA, ids.boxB)
editor.groupShapes(editor.getSelectedShapeIds())
const group = onlySelectedShape()
editor.rotateSelection(Math.PI / 2)
expect(editor.getShapePageBounds(group.id)!).toCloselyMatchObject({
x: 0,
y: 0,
w: 300,
h: 300,
})
editor.select(ids.boxA).rotateSelection(Math.PI / 4)
// pythagoras to the rescue
const expectedTopBound = 50 - Math.sqrt(2 * (100 * 100)) / 2
expect(editor.getShapePageBounds(group.id)!.minY).toBeCloseTo(expectedTopBound)
// rotating the circle doesn't move the right edge because it's outline doesn't change
expect(editor.getShapePageBounds(group.id)!.maxY).toBe(300)
editor.select(ids.boxB).rotateSelection(Math.PI / 4)
expect(approximately(editor.getShapePageBounds(group.id)!.maxY, 300, 1)).toBe(true)
})
it('changes when shapes translate', () => {
// 0 10 20 30 40 50
// ┌───┐ ┌───┐ ┌───┐
// │ A │ │ B │ │ C │
// └───┘ └───┘ └───┘
// rotate this all 90 degrees
editor.createShapes([box(ids.boxA, 0, 0), box(ids.boxB, 20, 0), box(ids.boxC, 40, 0)])
editor.select(ids.boxA, ids.boxB, ids.boxC)
editor.groupShapes(editor.getSelectedShapeIds())
const group = onlySelectedShape()
editor.updateShapes([{ id: group.id, type: 'group', rotation: Math.PI / 2, x: 10, y: 0 }])
expect(editor.getShapePageBounds(group.id)!).toCloselyMatchObject({
x: 0,
y: 0,
w: 10,
h: 50,
})
// move A up and to the left
editor.select(ids.boxA).translateSelection(-10, -10)
expect(editor.getShapePageBounds(group.id)!).toCloselyMatchObject({
x: -10,
y: -10,
w: 20,
h: 60,
})
// move C up and to the right
editor.select(ids.boxC).translateSelection(10, -10)
expect(editor.getShapePageBounds(group.id)!).toCloselyMatchObject({
x: -10,
y: -10,
w: 30,
h: 50,
})
})
it('changes when shapes resize', () => {
// 0 10 20 30 40 50
// ┌───┐ ┌───┐ ┌───┐
// │ A │ │ B │ │ C │
// └───┘ └───┘ └───┘
// rotate this all 90 degrees
editor.createShapes([box(ids.boxA, 0, 0), box(ids.boxB, 20, 0), box(ids.boxC, 40, 0)])
expect(editor.getShapeGeometry(ids.boxA)!.bounds).toMatchObject({ x: 0, y: 0, w: 10, h: 10 })
editor.select(ids.boxA, ids.boxB, ids.boxC)
editor.groupShapes(editor.getSelectedShapeIds())
const group = onlySelectedShape()
editor.updateShapes([{ id: group.id, type: 'group', rotation: Math.PI / 2, x: 10, y: 0 }])
expect(editor.getShapePageBounds(group.id)!).toCloselyMatchObject({
x: 0,
y: 0,
w: 10,
h: 50,
})
// resize A to up
editor.select(ids.boxA).resizeSelection({ scaleX: 2 }, 'left')
expect(editor.getShapePageBounds(group.id)!).toCloselyMatchObject({
x: 0,
y: -10,
w: 10,
h: 60,
})
// resize C up and to the right
editor.select(ids.boxC).resizeSelection({ scaleY: 2, scaleX: 2 }, 'top_right')
expect(editor.getShapePageBounds(group.id)!).toCloselyMatchObject({
x: 0,
y: -10,
w: 20,
h: 70,
})
})
})
describe('focus layers', () => {
let groupAId: TLShapeId
let groupBId: TLShapeId
let groupCId: TLShapeId
beforeEach(() => {
// group C
// ┌─────────────────────────────────────────────────────────┐
// │ group A group B │
// │ ┌────────────────────────┐ ┌──────────────────────┐ │
// │ │ ┌───┐ ┌───┐ │ │ ┌───┐ ┌───┐ │ │
// │ │ │ A │ │ B │ │ │ │ C │ │ D │ │ │
// │ │ └───┘ └───┘ │ │ └───┘ └───┘ │ │
// │ └────────────────────────┘ └──────────────────────┘ │
// └─────────────────────────────────────────────────────────┘
editor.createShapes([
box(ids.boxA, 0, 0),
box(ids.boxB, 20, 0),
box(ids.boxC, 40, 0),
box(ids.boxD, 60, 0),
])
editor.select(ids.boxA, ids.boxB)
editor.groupShapes(editor.getSelectedShapeIds())
groupAId = onlySelectedId()
editor.select(ids.boxC, ids.boxD)
editor.groupShapes(editor.getSelectedShapeIds())
groupBId = onlySelectedId()
editor.select(groupAId, groupBId)
editor.groupShapes(editor.getSelectedShapeIds())
groupCId = onlySelectedId()
editor.selectNone()
})
it('should adjust to the parent layer of any selected shape', () => {
expect(editor.getFocusedGroupId()).toBe(editor.getCurrentPageId())
editor.select(ids.boxA)
expect(editor.getFocusedGroupId()).toBe(groupAId)
editor.select(ids.boxB)
expect(editor.getFocusedGroupId()).toBe(groupAId)
editor.select(ids.boxC)
expect(editor.getFocusedGroupId()).toBe(groupBId)
editor.select(ids.boxD)
expect(editor.getFocusedGroupId()).toBe(groupBId)
editor.select(groupAId)
expect(editor.getFocusedGroupId()).toBe(groupCId)
})
it('should adjust to the common ancestor of selected shapes in multiple groups', () => {
expect(editor.getFocusedGroupId()).toBe(editor.getCurrentPageId())
editor.select(ids.boxA)
expect(editor.getFocusedGroupId()).toBe(groupAId)
editor.setSelectedShapes([...editor.getSelectedShapeIds(), ids.boxC])
expect(editor.getFocusedGroupId()).toBe(groupCId)
editor.deselect(ids.boxA)
expect(editor.getFocusedGroupId()).toBe(groupBId)
editor.setSelectedShapes([...editor.getSelectedShapeIds(), ids.boxB])
expect(editor.getFocusedGroupId()).toBe(groupCId)
})
it('should not adjust the focus layer when clearing the selection', () => {
expect(editor.getFocusedGroupId()).toBe(editor.getCurrentPageId())
editor.select(ids.boxA)
expect(editor.getFocusedGroupId()).toBe(groupAId)
editor.deselect(ids.boxA)
expect(editor.getFocusedGroupId()).toBe(groupAId)
editor.select(ids.boxB, ids.boxC)
expect(editor.getFocusedGroupId()).toBe(groupCId)
editor.selectNone()
expect(editor.getFocusedGroupId()).toBe(groupCId)
})
})
describe('right clicking in detail', () => {
const groupId = createShapeId('groupA')
beforeEach(() => {
// A - fill: none
// B - fill: solid
// C - fill: none
// ┌──────────────────┐
// │ 0 20 │
// │ ┌───┐ ┌───┐ │
// │ │ A │ │ B │ │
// │ └───┘ └───┘ │
// └──────────────────┘
// ┌───┐
// | C |
// └───┘
editor.createShapes([
box(ids.boxA, 0, 0, 10, 10, 'none'),
box(ids.boxB, 20, 0, 10, 10, 'solid'),
box(ids.boxC, 0, 50, 10, 10, 'none'),
])
editor.groupShapes([ids.boxA, ids.boxB], { groupId: groupId })
editor.selectNone()
})
// TODO: fix this
it('should select the group by right-clicking the margin of a hollow shape', () => {
editor.pointerMove(4, 4).pointerDown(4, 4, { target: 'canvas', button: 2 }).pointerUp()
expect(onlySelectedId()).toBe(groupId)
})
})
describe('the select tool', () => {
const groupAId = createShapeId('groupA')
const groupBId = createShapeId('groupB')
const groupCId = createShapeId('groupC')
beforeEach(() => {
// group C
// ┌─────────────────────────────────────────────────────────┐
// │ group A group B │
// │ ┌────────────────────────┐ ┌──────────────────────┐ │
// │ │ 0 20 │ │ 40 60 │ │
// │ │ ┌───┐ ┌───┐ │ │ ┌───┐ ┌───┐ │ │
// │ │ │ A │ │ B │ │ │ │ C │ │ D │ │ │
// │ │ └───┘ └───┘ │ │ └───┘ └───┘ │ │
// │ └────────────────────────┘ └──────────────────────┘ │
// └─────────────────────────────────────────────────────────┘
editor.createShapes([
box(ids.boxA, 0, 0, 10, 10),
box(ids.boxB, 30, 0, 10, 10),
box(ids.boxC, 60, 0, 10, 10),
box(ids.boxD, 90, 0, 10, 10),
])
editor.groupShapes([ids.boxA, ids.boxB], { groupId: groupAId })
editor.groupShapes([ids.boxC, ids.boxD], { groupId: groupBId })
editor.groupShapes([groupAId, groupBId], { groupId: groupCId })
editor.selectNone()
})
it('should select the outermost non-selected group when you click on one of the shapes in that group', () => {
editor.pointerDown(0, 0, ids.boxA).pointerUp(0, 0)
expect(onlySelectedId()).toBe(groupCId)
expect(editor.getFocusedGroupId()).toBe(editor.getCurrentPageId())
editor.pointerDown(0, 0, ids.boxA)
editor.pointerUp(0, 0, ids.boxA)
expect(onlySelectedId()).toBe(groupAId)
expect(editor.getFocusedGroupId()).toBe(groupCId)
editor.pointerDown(0, 0, ids.boxA).pointerUp(0, 0, ids.boxA)
expect(onlySelectedId()).toBe(ids.boxA)
expect(editor.getFocusedGroupId()).toBe(groupAId)
})
it('should select the outermost non-selected group when you right-click on one of the shapes in that group', () => {
const boxA = editor.getShape(ids.boxA)
editor
.pointerDown(0, 0, { target: 'shape', shape: boxA, button: 2 })
.pointerUp(0, 0, { button: 2 })
expect(onlySelectedId()).toBe(groupCId)
expect(editor.getFocusedGroupId()).toBe(editor.getCurrentPageId())
editor
.pointerDown(0, 0, { target: 'shape', shape: boxA, button: 2 })
.pointerUp(0, 0, { button: 2 })
expect(onlySelectedId()).toBe(groupCId)
expect(editor.getFocusedGroupId()).toBe(editor.getCurrentPageId())
editor.select(groupAId)
expect(editor.getFocusedGroupId()).toBe(groupCId)
editor
.pointerDown(0, 0, { target: 'shape', shape: boxA, button: 2 })
.pointerUp(0, 0, { button: 2 })
expect(onlySelectedId()).toBe(groupAId)
expect(editor.getFocusedGroupId()).toBe(groupCId)
})
it('should allow to shift-select other shapes outside of the current focus layer', () => {
editor.pointerDown(0, 0, ids.boxA).pointerUp(0, 0)
editor.pointerDown(0, 0, ids.boxA).pointerUp(0, 0)
editor.pointerDown(0, 0, ids.boxA).pointerUp(0, 0)
expect(onlySelectedId()).toBe(ids.boxA)
expect(editor.getFocusedGroupId()).toBe(groupAId)
editor
.pointerDown(60, 0, ids.boxC, { shiftKey: true })
.pointerUp(0, 0, ids.boxC, { shiftKey: true })
expect(editor.getSelectedShapeIds().includes(ids.boxA)).toBe(true)
expect(editor.getSelectedShapeIds().includes(groupBId)).toBe(true)
expect(editor.getFocusedGroupId()).toBe(groupCId)
editor.pointerDown(60, 0, ids.boxC, { shiftKey: true }).pointerUp()
expect(editor.getSelectedShapeIds().includes(ids.boxA)).toBe(true)
expect(editor.getSelectedShapeIds().includes(groupBId)).toBe(false)
expect(editor.getSelectedShapeIds().includes(ids.boxC)).toBe(true)
expect(editor.getFocusedGroupId()).toBe(groupCId)
})
it('if a shape inside a focused group is selected and you click outside the group it should clear the selection and focus the page', () => {
editor.select(ids.boxA)
expect(editor.getFocusedGroupId()).toBe(groupAId)
// click outside the focused group, but inside another group
editor.pointerDown(-235, 5, { target: 'canvas' }).pointerUp(-235, 5)
expect(editor.getFocusedGroupId()).toBe(editor.getCurrentPageId())
expect(editor.getSelectedShapeIds()).toHaveLength(0)
editor.select(ids.boxA)
expect(editor.getFocusedGroupId()).toBe(groupAId)
// click the empty canvas
editor.pointerDown(-235, 50, { target: 'canvas' }).pointerUp(-235, 50)
expect(editor.getFocusedGroupId()).toBe(editor.getCurrentPageId())
expect(editor.getSelectedShapeIds()).toHaveLength(0)
})
// ! Removed with hollow shape clicking feature
// it('if a shape inside a focused group is selected and you click an empty space inside the group it should deselect the shape', () => {
// editor.select(ids.boxA)
// expect(editor.focusedGroupId).toBe(groupAId)
// expect(editor.selectedShapeIds).toEqual([ids.boxA])
// editor.pointerDown(20, 5)
// editor.pointerUp(20, 5)
// expect(editor.focusedGroupId).toBe(groupAId)
// expect(editor.selectedShapeIds).toEqual([])
// })
// ! Removed
// it('if you click inside the empty space of a focused group while there are no selected shapes, it should pop the focus layer and select the group', () => {
// editor.select(ids.boxA)
// editor.pointerDown(15, 5, groupAId).pointerUp(15, 5, groupAId)
// expect(editor.focusedGroupId).toBe(groupAId)
// expect(editor.selectedShapeIds.length).toBe(0)
// editor.pointerDown(15, 5, groupAId).pointerUp(15, 5, groupAId)
// expect(editor.focusedGroupId).toBe(groupCId)
// expect(onlySelectedId()).toBe(groupAId)
// })
it('should pop the focus layer when escape is pressed in idle state', () => {
editor.select(ids.boxA)
expect(editor.getSelectedShapeIds()).toMatchObject([ids.boxA]) // box1
expect(editor.getFocusedGroupId()).toBe(groupAId)
// deselct
editor.cancel()
expect(editor.getSelectedShapeIds()).toMatchObject([groupAId]) // groupA
expect(editor.getFocusedGroupId()).toBe(groupCId)
// pop focus layer
editor.cancel()
expect(editor.getSelectedShapeIds().length).toBe(1) // Group C
expect(editor.getFocusedGroupId()).toBe(editor.getCurrentPageId())
editor.cancel()
expect(editor.getSelectedShapeIds().length).toBe(0)
expect(editor.getFocusedGroupId()).toBe(editor.getCurrentPageId())
})
// ! Removed: pointing a group is impossible; you'd be pointing the selection instead.
// it('should work while focused in a group if you start the drag from within the group', () => {
// editor.select(ids.boxA)
// editor.pointerDown(15, 5, groupAId).pointerMove(25, 9, ids.boxB)
// expect(editor.getPath()).toBe(`select.brushing`)
// expect(editor.selectedShapeIds.includes(ids.boxA)).toBe(false)
// expect(editor.selectedShapeIds.includes(ids.boxB)).toBe(true)
// editor.keyDown('Shift')
// expect(editor.selectedShapeIds.includes(ids.boxA)).toBe(true)
// expect(editor.selectedShapeIds.includes(ids.boxB)).toBe(true)
// })
it('should work while focused in a group if you start the drag from outside of the group', () => {
editor.select(ids.boxA)
expect(editor.getShapesAtPoint({ x: -305, y: -5 })).toMatchObject([])
editor.pointerDown(-305, -5, { target: 'canvas' }).pointerMove(35, 9, ids.boxB)
editor.expectToBeIn(`select.brushing`)
expect(editor.getSelectedShapeIds().includes(ids.boxA)).toBe(true)
expect(editor.getSelectedShapeIds().includes(ids.boxB)).toBe(true)
// this one didn't make sense as written—the forced event on canvas won't work now
// that we're doing hit testing manually—we'll catch that it was inside a shape
// editor.keyUp('Shift')
// jest.advanceTimersByTime(200)
// expect(editor.selectedShapeIds.includes(ids.boxA)).toBe(false)
// expect(editor.selectedShapeIds.includes(ids.boxB)).toBe(true)
})
it('should not select the group until you hit one of its child shapes', () => {
// ┌────┐
// group C │ │
// ┌───────────┼────┼────────────────────────────────────────┐
// │ group A │ │ group B │
// │ ┌─────────┼────┼─────────┐ ┌──────────────────────┐ │
// │ │ ┌───┐ │ │ ┌───┐ │ │ ┌───┐ ┌───┐ │ │
// │ │ │ A │ │ │ │ B │ │ │ │ C │ │ D │ │ │
// │ │ └───┘ │ │ └───┘ │ │ └───┘ └───┘ │ │
// │ └─────────┼────┼─────────┘ └──────────────────────┘ │
// └───────────┼────┼────────────────────────────────────────┘
// │ │
// └────┘
// ▲
// │ mouse selection
editor.pointerDown(12.5, -15, undefined).pointerMove(17.5, 15, ids.boxB)
expect(editor.getSelectedShapeIds().length).toBe(0)
editor.pointerMove(35, 15)
expect(onlySelectedId()).toBe(groupCId)
})
})
describe("when a group's children are deleted", () => {
let groupAId: TLShapeId
let groupBId: TLShapeId
let groupCId: TLShapeId
beforeEach(() => {
// group C
// ┌─────────────────────────────────────────────────────────┐
// │ group A group B │
// │ ┌────────────────────────┐ ┌──────────────────────┐ │
// │ │ 0 20 │ │ 40 60 │ │
// │ │ ┌───┐ ┌───┐ │ │ ┌───┐ ┌───┐ │ │
// │ │ │ A │ │ B │ │ │ │ C │ │ D │ │ │
// │ │ └───┘ └───┘ │ │ └───┘ └───┘ │ │
// │ └────────────────────────┘ └──────────────────────┘ │
// └─────────────────────────────────────────────────────────┘
editor.createShapes([
box(ids.boxA, 0, 0),
box(ids.boxB, 20, 0),
box(ids.boxC, 40, 0),
box(ids.boxD, 60, 0),
])
editor.select(ids.boxA, ids.boxB)
editor.groupShapes(editor.getSelectedShapeIds())
groupAId = onlySelectedId()
editor.select(ids.boxC, ids.boxD)
editor.groupShapes(editor.getSelectedShapeIds())
groupBId = onlySelectedId()
editor.select(groupAId, groupBId)
editor.groupShapes(editor.getSelectedShapeIds())
groupCId = onlySelectedId()
editor.selectNone()
})
it('should ungroup if there is only one shape left', () => {
editor.deleteShapes([ids.boxD])
expect(editor.getShape(groupBId)).toBeUndefined()
expect(editor.getShape(ids.boxC)?.parentId).toBe(groupCId)
})
it('should remove the group if there are no shapes left', () => {
editor.deleteShapes([ids.boxC, ids.boxD])
expect(editor.getShape(groupBId)).toBeUndefined()
expect(editor.getShape(groupCId)).toBeUndefined()
expect(editor.getShape(groupAId)).not.toBeUndefined()
})
})
describe('creating new shapes', () => {
let groupA: TLGroupShape
beforeEach(() => {
// group A
// ┌──────────────────────────────┐
// │ 0 10 90 100 │
// │ ┌───┐ │
// │ │ A │ │
// │ 10 └───┘ │
// │ │
// │ │
// │ │
// │ │
// │ 90 ┌───┐ │
// │ │ B │ │
// │ 100 └───┘ │
// └──────────────────────────────┘
editor.createShapes([box(ids.boxA, 0, 0), box(ids.boxB, 90, 90)])
editor.select(ids.boxA, ids.boxB)
editor.groupShapes(editor.getSelectedShapeIds())
groupA = onlySelectedShape() as TLGroupShape
editor.selectNone()
})
describe('boxes', () => {
it('does not create inside the group if the group is only selected and not focused', () => {
editor.select(groupA.id)
editor.setCurrentTool('geo')
editor.pointerDown(20, 20).pointerMove(80, 80).pointerUp(80, 80)
const boxC = onlySelectedShape()
expect(boxC.parentId).toBe(editor.getCurrentPageId())
expect(editor.getShapePageBounds(boxC.id)).toCloselyMatchObject({
x: 20,
y: 20,
w: 60,
h: 60,
})
})
it('does create inside the group if the group is focused', () => {
editor.select(ids.boxA)
expect(editor.getFocusedGroupId() === groupA.id).toBe(true)
editor.setCurrentTool('geo')
editor.pointerDown(20, 20).pointerMove(80, 80).pointerUp(80, 80)
const boxC = onlySelectedShape()
expect(boxC.parentId).toBe(groupA.id)
expect(editor.getShapePageBounds(boxC.id)).toCloselyMatchObject({
x: 20,
y: 20,
w: 60,
h: 60,
})
expect(editor.getFocusedGroupId() === groupA.id).toBe(true)
})
it('will reisze the group appropriately if the new shape changes the group bounds', () => {
editor.select(ids.boxA)
expect(editor.getFocusedGroupId() === groupA.id).toBe(true)
editor.setCurrentTool('geo')
editor.pointerDown(20, 20).pointerMove(-10, -10)
expect(editor.getShapePageBounds(groupA.id)).toCloselyMatchObject({
x: -10,
y: -10,
w: 110,
h: 110,
})
editor.pointerMove(-20, -20).pointerUp(-20, -20)
expect(editor.getShapePageBounds(groupA.id)).toCloselyMatchObject({
x: -20,
y: -20,
w: 120,
h: 120,
})
const boxC = onlySelectedShape()
expect(editor.getShapePageBounds(boxC.id)).toCloselyMatchObject({
x: -20,
y: -20,
w: 40,
h: 40,
})
})
it('works if the shape drawing begins outside of the current group bounds', () => {
editor.select(ids.boxA)
expect(editor.getFocusedGroupId() === groupA.id).toBe(true)
editor.setCurrentTool('geo')
editor.pointerDown(-50, -50).pointerMove(-100, -100).pointerUp()
expect(editor.getShapePageBounds(groupA.id)).toCloselyMatchObject({
x: -100,
y: -100,
w: 200,
h: 200,
})
const boxC = onlySelectedShape()
expect(editor.getShapePageBounds(boxC.id)).toCloselyMatchObject({
x: -100,
y: -100,
w: 50,
h: 50,
})
})
})
describe('pencil', () => {
it('does not draw inside the group if the group is only selected and not focused', () => {
editor.select(groupA.id)
editor.setCurrentTool('draw')
editor.pointerDown(20, 20).pointerMove(80, 80).pointerUp(80, 80)
const lineC = onlySelectedShape()
expect(lineC.parentId).toBe(editor.getCurrentPageId())
})
it('does draw inside the group if the group is focused', () => {
editor.select(ids.boxA)
expect(editor.getFocusedGroupId() === groupA.id).toBe(true)
editor.setCurrentTool('draw')
editor.pointerDown(20, 20).pointerMove(80, 80).pointerUp(80, 80)
const lineC = onlySelectedShape()
expect(lineC.parentId).toBe(groupA.id)
})
it('will resize the group appropriately if the new shape changes the group bounds', () => {
editor.select(ids.boxA)
expect(editor.getFocusedGroupId() === groupA.id).toBe(true)
editor.setCurrentTool('draw')
editor.pointerDown(20, 20)
for (let i = 20; i >= -20; i--) {
editor.pointerMove(i, i)
}
editor.pointerUp()
const roundToNearestTen = (vals: Box) => {
return {
x: Math.round(vals.x / 10) * 10,
y: Math.round(vals.y / 10) * 10,
w: Math.round(vals.w / 10) * 10,
h: Math.round(vals.h / 10) * 10,
}
}
expect(roundToNearestTen(editor.getShapePageBounds(groupA.id)!)).toCloselyMatchObject({
x: -20,
y: -20,
w: 120,
h: 120,
})
})
it('works if the shape drawing begins outside of the current group bounds', () => {
editor.select(ids.boxA)
expect(editor.getFocusedGroupId() === groupA.id).toBe(true)
editor.setCurrentTool('draw')
editor.pointerDown(-20, -20)
for (let i = -20; i >= -100; i--) {
editor.pointerMove(i, i)
}
editor.pointerUp()
const roundToNearestTen = (vals: Box) => {
return {
x: Math.round(vals.x / 10) * 10,
y: Math.round(vals.y / 10) * 10,
w: Math.round(vals.w / 10) * 10,
h: Math.round(vals.h / 10) * 10,
}
}
expect(roundToNearestTen(editor.getShapePageBounds(groupA.id)!)).toCloselyMatchObject({
x: -100,
y: -100,
w: 200,
h: 200,
})
})
describe('lines', () => {
it('does not draw inside the group if the group is only selected and not focused', () => {
editor.select(groupA.id)
editor.setCurrentTool('line')
editor.pointerDown(20, 20)
editor.pointerMove(80, 80)
editor.pointerUp(80, 80)
const lineC = onlySelectedShape()
expect(lineC.type).toBe('line')
expect(lineC.parentId).toBe(editor.getCurrentPageId())
})
it('does draw inside the group if the group is focused', () => {
editor.select(ids.boxA)
expect(editor.getFocusedGroupId() === groupA.id).toBe(true)
editor.setCurrentTool('line')
editor.pointerDown(20, 20).pointerMove(80, 80).pointerUp(80, 80)
const lineC = onlySelectedShape() as TLLineShape
expect(lineC.type).toBe('line')
expect(lineC.parentId).toBe(groupA.id)
})
it('will reisze the group appropriately if the new shape changes the group bounds', () => {
editor.select(ids.boxA)
expect(editor.getFocusedGroupId() === groupA.id).toBe(true)
editor.setCurrentTool('line')
editor.pointerDown(20, 20).pointerMove(-10, -10)
expect(editor.getShapePageBounds(groupA.id)).toMatchSnapshot('group with line shape')
editor.pointerMove(-20, -20).pointerUp(-20, -20)
expect(editor.getShapePageBounds(groupA.id)!.toJson()).toMatchSnapshot(
'group shape after second resize'
)
const boxC = onlySelectedShape()
expect(editor.getShapePageBounds(boxC.id)!.toJson()).toMatchSnapshot(
'box shape after second resize'
)
})
it('works if the shape drawing begins outside of the current group bounds', () => {
editor.select(ids.boxA)
expect(editor.getFocusedGroupId() === groupA.id).toBe(true)
editor.setCurrentTool('line')
editor.pointerDown(-50, -50).pointerMove(-100, -100).pointerUp()
expect(editor.getShapePageBounds(groupA.id)!.toJson()).toMatchSnapshot('group with line')
const boxC = onlySelectedShape()
expect(editor.getShapePageBounds(boxC.id)!.toJson()).toMatchSnapshot(
'box shape after resize'
)
})
})
describe('sticky notes', () => {
it('does not draw inside the group if the group is only selected and not focused', () => {
editor.select(groupA.id)
expect(editor.getFocusedGroupId() === editor.getCurrentPageId()).toBe(true)
editor.setCurrentTool('note')
editor.pointerDown(20, 20).pointerUp()
const postit = onlySelectedShape()
expect(postit.parentId).toBe(editor.getCurrentPageId())
})
it('does draw inside the group if the group is focused', () => {
editor.select(ids.boxA)
expect(editor.getFocusedGroupId() === groupA.id).toBe(true)
editor.setCurrentTool('note')
editor.pointerDown(20, 20).pointerUp()
const postit = onlySelectedShape()
expect(postit.parentId).toBe(groupA.id)
})
it('will reisze the group appropriately if the new shape changes the group bounds', () => {
editor.select(ids.boxA)
expect(editor.getFocusedGroupId() === groupA.id).toBe(true)
expect(editor.getShapePageBounds(groupA.id)).toCloselyMatchObject({
x: 0,
y: 0,
w: 100,
h: 100,
})
editor.setCurrentTool('note')
editor.pointerDown(80, 80)
editor.pointerUp()
// default size is 200x200, and it centers it, so add 100px around the pointer
expect(editor.getShapePageBounds(groupA.id)).toCloselyMatchObject({
x: -20,
y: -20,
w: 200,
h: 200,
})
editor.pointerMove(20, 20)
editor.pointerUp(20, 20)
expect(editor.getShapePageBounds(groupA.id)).toCloselyMatchObject({
x: -20,
y: -20,
w: 200,
h: 200,
})
})
it('works if the shape drawing begins outside of the current group bounds', () => {
editor.select(ids.boxA)
expect(editor.getFocusedGroupId() === groupA.id).toBe(true)
editor.setCurrentTool('note')
expect(editor.getShapePageBounds(groupA.id)).toCloselyMatchObject({
x: 0,
y: 0,
w: 100,
h: 100,
})
editor.pointerDown(-20, -20).pointerUp(-20, -20)
expect(editor.getShapePageBounds(groupA.id)).toCloselyMatchObject({
x: -120,
y: -120,
w: 220,
h: 220,
})
})
})
})
})
describe('erasing', () => {
let groupAId: TLShapeId
let groupBId: TLShapeId
let groupCId: TLShapeId
beforeEach(() => {
// group C
// ┌─────────────────────────────────────────────────────────┐
// │ group A group B │
// │ ┌────────────────────────┐ ┌──────────────────────┐ │
// │ │ 0 20 │ │ 40 60 │ │
// │ │ ┌───┐ ┌───┐ │ │ ┌───┐ ┌───┐ │ │
// │ │ │ A │ │ B │ │ │ │ C │ │ D │ │ │
// │ │ └───┘ └───┘ │ │ └───┘ └───┘ │ │
// │ └────────────────────────┘ └──────────────────────┘ │
// └─────────────────────────────────────────────────────────┘
//
// 20 ┌───┐
// │ E │
// └───┘
editor.createShapes([
box(ids.boxA, 0, 0),
box(ids.boxB, 20, 0),
box(ids.boxC, 60, 0),
box(ids.boxD, 80, 0),
box(ids.boxE, 0, 20),
])
editor.select(ids.boxA, ids.boxB)
editor.groupShapes(editor.getSelectedShapeIds())
groupAId = onlySelectedId()
editor.select(ids.boxC, ids.boxD)
editor.groupShapes(editor.getSelectedShapeIds())
groupBId = onlySelectedId()
editor.select(groupAId, groupBId)
editor.groupShapes(editor.getSelectedShapeIds())
groupCId = onlySelectedId()
editor.selectNone()
})
it('erases whole groups if you hit one of their shapes', () => {
editor.setCurrentTool('eraser')
// erase D
editor.pointerDown(65, 5, ids.boxD)
expect(editor.getCurrentPageState().erasingShapeIds.length).toBe(1)
expect(editor.getCurrentPageState().erasingShapeIds[0]).toBe(groupCId)
editor.pointerUp()
expect(editor.getShape(groupCId)).toBeFalsy()
})
it('does not erase whole groups if you do not hit on one of their shapes', () => {
editor.setCurrentTool('eraser')
editor.pointerDown(40, 5)
expect(editor.getErasingShapeIds()).toEqual([])
})
it('works inside of groups', () => {
editor.select(ids.boxA)
expect(editor.getFocusedGroupId() === groupAId).toBe(true)
const groupA = editor.getShape(groupAId)!
editor.setCurrentTool('eraser')
// erase B
editor.pointerDown(25, 5, ids.boxB)
expect(editor.getCurrentPageState().erasingShapeIds.length).toBe(1)
expect(editor.getCurrentPageState().erasingShapeIds[0]).toBe(ids.boxB)
editor.pointerUp()
// group A disappears
expect(isRemoved(groupA)).toBe(true)
})
it('works outside of the focus layer', () => {
editor.select(ids.boxA)
expect(editor.getFocusedGroupId() === groupAId).toBe(true)
editor.setCurrentTool('eraser')
// erase E
editor.pointerDown(5, 25, ids.boxE)
expect(editor.getCurrentPageState().erasingShapeIds.length).toBe(1)
expect(editor.getCurrentPageState().erasingShapeIds[0]).toBe(ids.boxE)
// move to group B
editor.pointerMove(65, 5)
expect(editor.getErasingShapeIds().length).toBe(2)
})
})
describe('binding bug', () => {
beforeEach(() => {
editor.createShapes([box(ids.boxA, 0, 0, 10, 10), box(ids.boxB, 50, 0, 10, 10)])
editor.select(ids.boxA, ids.boxB)
editor.groupShapes(editor.getSelectedShapeIds())
editor.selectNone()
})
it('can not be made from some child shape to a group shape', () => {
editor.setCurrentTool('arrow')
// go from A to group A
editor.pointerDown(5, 5).pointerMove(25, 5).pointerUp()
const arrow = onlySelectedShape() as TLArrowShape
const bindings = getArrowBindings(editor, arrow)
expect(bindings.start).toMatchObject({ toId: ids.boxA })
expect(bindings.end).toBeUndefined()
})
})
describe('bindings', () => {
let groupAId: TLShapeId
let groupBId: TLShapeId
beforeEach(() => {
// group C
// ┌─────────────────────────────────────────────────────────┐
// │ group A group B │
// │ ┌────────────────────────┐ ┌──────────────────────┐ │
// │ │ 0