tldraw
Version:
A tiny little drawing editor.
668 lines (590 loc) • 15.6 kB
text/typescript
import { TLFrameShape, TLGeoShape, approximately, createShapeId } from '@tldraw/editor'
import { TestEditor } from './TestEditor'
let editor: TestEditor
const ids = {
box1: createShapeId('box1'),
box2: createShapeId('box2'),
box3: createShapeId('box3'),
frame1: createShapeId('frame1'),
frame2: createShapeId('frame2'),
frame3: createShapeId('frame3'),
frame4: createShapeId('frame4'),
}
beforeEach(() => {
editor = new TestEditor()
editor.createShapes([
{
id: ids.frame1,
type: 'frame',
x: 0,
y: 0,
props: {
w: 100, // ! we're using w to identify the clones
h: 100,
},
},
{
id: ids.frame2,
type: 'frame',
x: 0,
y: 100,
props: {
w: 101,
h: 100,
},
},
{
id: ids.frame3,
type: 'frame',
x: 0,
y: 200,
props: {
w: 102,
h: 100,
},
},
{
id: ids.frame4,
type: 'frame',
x: 0,
y: 300,
props: {
w: 103,
h: 100,
},
},
{
id: ids.box1,
type: 'geo',
x: 500,
y: 500,
props: {
w: 88,
},
},
{
id: ids.box2,
type: 'geo',
x: 600,
y: 600,
props: {
w: 89,
},
},
{
id: ids.box3,
type: 'geo',
x: 700,
y: 700,
props: {
w: 90,
},
},
])
})
function getShapes() {
const arr = editor.getCurrentPageShapes() as any[]
const results = { old: {}, new: {} } as {
old: Record<string, TLGeoShape | TLFrameShape>
new: Record<string, TLGeoShape | TLFrameShape>
}
Object.entries(ids).map(([normalId, shapeId]) => {
const shape = editor.getShape(shapeId as any) as any
const newShape = arr.find((s) => s.id !== shapeId && s.props.w === shape?.props.w)
results.old[normalId] = shape
results.new[normalId] = newShape
})
return results
}
it('Gets pasted shapes correctly', () => {
editor.select(ids.box1, ids.box2, ids.frame1, ids.box3)
editor.copy()
editor.selectNone()
let shapes = getShapes()
expect(editor.getCurrentPageShapesSorted().map((m) => m.id)).toStrictEqual([
shapes.old.frame1.id,
shapes.old.frame2.id,
shapes.old.frame3.id,
shapes.old.frame4.id,
shapes.old.box1.id,
shapes.old.box2.id,
shapes.old.box3.id,
])
editor.paste()
shapes = getShapes()
// The pasted frame (at 0,0) merely touches frame2's edge (at 0,100),
// so it stays at the page level rather than being reparented.
expect(editor.getCurrentPageShapesSorted().map((m) => m.id)).toStrictEqual([
shapes.old.frame1.id,
shapes.old.frame2.id,
shapes.old.frame3.id,
shapes.old.frame4.id,
shapes.old.box1.id,
shapes.old.box2.id,
shapes.old.box3.id,
shapes.new.frame1.id,
shapes.new.box1.id,
shapes.new.box2.id,
shapes.new.box3.id,
])
})
describe('When pasting', () => {
it('pastes shapes onto the page', () => {
/*
Before:
page
- frame1
- frame2
- frame3
- frame4
- box1
- box2
- box3
After:
page
- frame1
- frame2
- frame3
- frame4
- box1
- box2
- box3
- box1copy
- box2copy
*/
editor.select(ids.box1, ids.box2)
editor.copy()
editor.selectNone()
editor.paste()
const shapes = getShapes()
expect(shapes.new.box1?.parentId).toBe(editor.getCurrentPageId())
expect(shapes.new.box2?.parentId).toBe(editor.getCurrentPageId())
expect(editor.getCurrentPageShapesSorted().map((m) => m.id)).toStrictEqual([
shapes.old.frame1.id,
shapes.old.frame2.id,
shapes.old.frame3.id,
shapes.old.frame4.id,
shapes.old.box1.id,
shapes.old.box2.id,
shapes.old.box3.id,
shapes.new.box1.id,
shapes.new.box2.id,
])
})
it('pastes shapes as children of the selected shape when shape is a frame', () => {
/*
Before:
page
- frame1 *
- frame2
- frame3
- frame4
- box1
- box2
- box3
After:
page
- frame1
- box1copy *
- box2copy *
- frame2
- frame3
- frame4
- box1
- box2
- box3
*/
editor.select(ids.box1, ids.box2)
editor.copy()
editor.select(ids.frame1)
editor.paste()
const shapes = getShapes()
// Should make the pasted shapes the children of the frame
expect(shapes.new.box1?.parentId).toBe(shapes.old.frame1.id)
expect(shapes.new.box2?.parentId).toBe(shapes.old.frame1.id)
// Should put the pasted shapes centered in the frame
editor.select(shapes.new.box1!.id, shapes.new.box1!.id)
expect(editor.getSelectionPageCenter()).toMatchObject(
editor.getPageCenter(editor.getShape(ids.frame1)!)!
)
})
it('pastes shapes as children of the most common ancestor', () => {
editor.reparentShapes([ids.frame3], ids.frame1)
editor.reparentShapes([ids.frame4], ids.frame2)
editor.reparentShapes([ids.box1], ids.frame3)
editor.reparentShapes([ids.box2], ids.frame4)
/*
Before:
page
- frame1
- frame3
- box1 *
- frame2
- frame4
- box2 *
- box3
After:
page
- frame1
- frame3
- box1
- frame2
- frame4
- box2
- box3
- box1copy *
- box2copy *
*/
editor.select(ids.box1, ids.box2)
editor.copy()
editor.paste()
const shapes = getShapes()
// Should make the pasted shapes the children of the frame
expect(shapes.new.box1?.parentId).toBe(editor.getCurrentPageId())
expect(shapes.new.box2?.parentId).toBe(editor.getCurrentPageId())
// Should put the pasted shapes centered in the frame
editor.select(shapes.new.box1!.id, shapes.new.box1!.id)
expect(editor.getShapePageBounds(shapes.old.box1)).toMatchObject(
editor.getShapePageBounds(shapes.new.box1)!
)
})
it('pastes shapes as children of the most common ancestor', () => {
editor.reparentShapes([ids.frame3], ids.frame1)
editor.reparentShapes([ids.frame4], ids.frame1)
editor.reparentShapes([ids.box1], ids.frame3)
editor.reparentShapes([ids.box2], ids.frame4)
/*
Before:
page
- frame1
- frame3
- box1 *
- frame4
- box2 *
- frame2
- box3
After:
page
- frame1
- frame3
- box1
- frame4
- box2
- box1copy *
- box2copy *
- frame2
- box2
- box3
*/
editor.select(ids.box1, ids.box2)
editor.copy()
editor.paste()
const shapes = getShapes()
// Should make the pasted shapes the children of the frame
expect(shapes.new.box1?.parentId).toBe(shapes.old.frame1.id)
expect(shapes.new.box2?.parentId).toBe(shapes.old.frame1.id)
// Should put the pasted shapes centered in the frame
editor.select(shapes.new.box1!.id, shapes.new.box1!.id)
expect(editor.getSelectionPageCenter()).toMatchObject(editor.getPageCenter(shapes.old.frame1)!)
})
})
it('pastes shapes with children', () => {
editor.reparentShapes([ids.box1, ids.box2], ids.frame3)
/*
Before:
page
- frame1
- frame2
- frame3 *
- box1
- box2
- frame4
- box3
After:
page
- frame1
- frame2
- frame3
- box1
- box2
- frame4
- box3
- frame3copy
- box1copy
- box2copy
*/
editor.select(ids.frame3)
editor.copy()
editor.paste()
const shapes = getShapes()
// Should make the pasted shapes the children of the frame
expect(shapes.new.box1.parentId).toBe(shapes.new.frame3.id)
expect(shapes.new.box2.parentId).toBe(shapes.new.frame3.id)
expect(shapes.new.frame3.parentId).toBe(editor.getCurrentPageId())
})
describe('When pasting into frames...', () => {
it('Does not paste into a clipped frame', () => {
// clear the page
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
editor
// move the two frames far from all other shapes
.createShapes([
{
id: ids.frame1,
type: 'frame',
x: 2000,
y: 2000,
props: {
w: 100,
h: 100,
},
},
{
id: ids.frame2,
type: 'frame',
x: 2000,
y: 2000,
props: {
w: 100,
h: 100,
},
},
{
id: ids.box1,
type: 'geo',
x: 500,
y: 500,
},
])
.setScreenBounds({ x: 0, y: 0, w: 1000, h: 1000 })
// put frame2 inside frame1
editor.reparentShapes([ids.frame2], ids.frame1)
// move frame 2 so that it's clipped AND so that it covers the whole viewport
editor
.updateShapes([
{
id: ids.frame2,
type: 'frame',
x: 50,
y: 50,
props: {
w: 2000,
h: 2000,
},
},
])
// Make sure that frame 1 is brought to front
.select(ids.frame1)
.bringToFront(editor.getSelectedShapeIds())
editor.setCamera({ x: -2000, y: -2000, z: 1 })
// Copy box 1 (should be out of viewport)
editor.select(ids.box1).copy()
const shapesBefore = editor.getCurrentPageShapes()
// Paste it
editor.paste()
const newShape = editor.getCurrentPageShapes().find((s) => !shapesBefore.includes(s))!
// it should be on the canvas, NOT a child of frame2
expect(newShape.parentId).not.toBe(ids.frame2)
})
it('keeps things in the right place', () => {
// clear the page
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
// create a small box and copy it
editor.createShapes([
{
type: 'geo',
x: 0,
y: 0,
props: {
geo: 'rectangle',
w: 10,
h: 10,
},
},
])
editor.selectAll().copy()
// now delete it
editor.deleteShapes(editor.getSelectedShapeIds())
// create a big frame away from the origin, the size of the viewport
editor
.createShapes([
{
id: ids.frame1,
type: 'frame',
x: editor.getViewportScreenBounds().w,
y: editor.getViewportScreenBounds().h,
props: {
w: editor.getViewportScreenBounds().w,
h: editor.getViewportScreenBounds().h,
},
},
])
.selectAll()
// rotate the frame for hard mode
editor.rotateSelection(45)
// center on the center of the frame
editor.setCamera({
x: -editor.getViewportScreenBounds().w,
y: -editor.getViewportScreenBounds().h,
z: 1,
})
// paste the box
editor.paste()
const boxId = editor.getOnlySelectedShape()!.id
// it should be a child of the frame
expect(editor.getOnlySelectedShape()?.parentId).toBe(ids.frame1)
// it should have pageBounds of 10x10 because it is not rotated relative to the viewport
expect(editor.getShapePageBounds(boxId)).toMatchObject({ w: 10, h: 10 })
// it should be in the middle of the frame
const framePageCenter = editor.getPageCenter(editor.getShape(ids.frame1)!)!
const boxPageCenter = editor.getPageCenter(editor.getShape(boxId)!)!
expect(approximately(framePageCenter.x, boxPageCenter.x)).toBe(true)
expect(approximately(framePageCenter.y, boxPageCenter.y)).toBe(true)
})
it('Reparents pasted shapes into a frame at the viewport center when nothing is selected', () => {
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
// Create a frame centered in the viewport
const viewportCenter = editor.getViewportPageBounds().center
const frameW = 400
const frameH = 400
editor.createShapes([
{
id: ids.frame1,
type: 'frame',
x: viewportCenter.x - frameW / 2,
y: viewportCenter.y - frameH / 2,
props: { w: frameW, h: frameH },
},
])
// Create a small shape outside the frame, copy it, then delete it
editor.createShapes([
{
id: ids.box1,
type: 'geo',
x: -500,
y: -500,
props: { w: 10, h: 10 },
},
])
editor.select(ids.box1).copy()
editor.deleteShapes([ids.box1])
editor.selectNone()
// Paste with nothing selected — should land in viewport center, inside the frame
editor.paste()
const pastedShape = editor
.getCurrentPageShapes()
.find((s) => s.type === 'geo' && s.id !== ids.box1)!
expect(pastedShape.parentId).toBe(ids.frame1)
})
it('Does not reparent pasted shapes when they land outside any frame', () => {
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
// Create a frame far from the viewport center
editor.createShapes([
{
id: ids.frame1,
type: 'frame',
x: 5000,
y: 5000,
props: { w: 100, h: 100 },
},
])
// Create a small shape, copy it, then delete it
editor.createShapes([
{
id: ids.box1,
type: 'geo',
x: -500,
y: -500,
props: { w: 10, h: 10 },
},
])
editor.select(ids.box1).copy()
editor.deleteShapes([ids.box1])
editor.selectNone()
// Paste — should land at viewport center, which is NOT inside the frame
editor.paste()
const pastedShape = editor
.getCurrentPageShapes()
.find((s) => s.type === 'geo' && s.id !== ids.box1)!
expect(pastedShape.parentId).toBe(editor.getCurrentPageId())
})
it('Reparents pasted shapes into a frame when preservePosition places them inside it', () => {
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
// Create a frame at the origin
editor.createShapes([
{
id: ids.frame1,
type: 'frame',
x: 0,
y: 0,
props: { w: 400, h: 400 },
},
])
// Create a small shape inside the frame bounds, copy it, then delete it
editor.createShapes([
{
id: ids.box1,
type: 'geo',
x: 150,
y: 150,
props: { w: 10, h: 10 },
},
])
editor.select(ids.box1).copy()
editor.deleteShapes([ids.box1])
editor.selectNone()
// Set camera so that the original position is in the viewport
editor.setCamera({ x: 0, y: 0, z: 1 })
// Paste with preservePosition — shape should land at original position, inside the frame
editor.putContentOntoCurrentPage(editor.clipboard!, {
preservePosition: true,
select: true,
})
const [pastedId] = editor.getSelectedShapeIds()
expect(editor.getShape(pastedId)?.parentId).toBe(ids.frame1)
})
it('Kicks out pasted shapes that do not overlap with the paste-parent frame', () => {
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
// Create three 100x100 rectangles spaced 200px apart (200px gap between edges)
// rect1: x=0..100, rect2: x=300..400, rect3: x=600..700
// All at y=0, so selection bounds = 700x100, center = (350, 50)
editor.createShapes([
{ id: ids.box1, type: 'geo', x: 0, y: 0, props: { w: 100, h: 100 } },
{ id: ids.box2, type: 'geo', x: 300, y: 0, props: { w: 100, h: 100 } },
{ id: ids.box3, type: 'geo', x: 600, y: 0, props: { w: 100, h: 100 } },
])
editor.select(ids.box1, ids.box2, ids.box3)
editor.copy()
// Delete the originals and create a 150x150 frame centered in the viewport
editor.deleteShapes([ids.box1, ids.box2, ids.box3])
const viewportCenter = editor.getViewportPageBounds().center
editor.createShapes([
{
id: ids.frame1,
type: 'frame',
x: viewportCenter.x - 75,
y: viewportCenter.y - 75,
props: { w: 150, h: 150 },
},
])
// Select the frame and paste
editor.select(ids.frame1)
editor.paste()
// Find the three pasted geo shapes (the new ones, not the originals which were deleted)
const pastedGeos = editor
.getCurrentPageShapes()
.filter((s) => s.type === 'geo')
.sort((a, b) => {
const aBounds = editor.getShapePageBounds(a)!
const bBounds = editor.getShapePageBounds(b)!
return aBounds.x - bBounds.x
})
expect(pastedGeos).toHaveLength(3)
const [left, middle, right] = pastedGeos
// The middle shape's center lands at the frame center → stays as child of the frame
expect(middle.parentId).toBe(ids.frame1)
// The left and right shapes are far outside the frame → kicked out to page
expect(left.parentId).toBe(editor.getCurrentPageId())
expect(right.parentId).toBe(editor.getCurrentPageId())
})
})