/* eslint-disable no-restricted-globals */
import { Driver } from '@tldraw/driver'
import {
Box,
BoxModel,
Editor,
HALF_PI,
IdOf,
RequiredKeys,
TLContent,
TLEditorOptions,
TLMeasureTextOpts,
TLShape,
TLShapePartial,
TLStoreOptions,
createShapeId,
createTLStore,
} from '@tldraw/editor'
import { vi } from 'vitest'
import { defaultBindingUtils } from '../lib/defaultBindingUtils'
import { defaultShapeTools } from '../lib/defaultShapeTools'
import { BrushOverlayUtil } from '../lib/overlays/BrushOverlayUtil'
import { SelectionForegroundOverlayUtil } from '../lib/overlays/SelectionForegroundOverlayUtil'
import { SnapIndicatorOverlayUtil } from '../lib/overlays/SnapIndicatorOverlayUtil'
import { ZoomBrushOverlayUtil } from '../lib/overlays/ZoomBrushOverlayUtil'
/**
* Curated set of overlay utils for tests that need canvas hit-testing of
* resize/rotate/crop handles. Excludes ArrowHint, ShapeHandle, and scribble
* overlays which can cause circular imports or noisy reactivity in tests.
*
* @internal
*/
export const defaultHandleOverlays = [
SelectionForegroundOverlayUtil,
BrushOverlayUtil,
ZoomBrushOverlayUtil,
SnapIndicatorOverlayUtil,
]
import { defaultShapeUtils } from '../lib/defaultShapeUtils'
import { registerDefaultSideEffects } from '../lib/defaultSideEffects'
import { defaultTools } from '../lib/defaultTools'
import { defaultAddFontsFromNode, tipTapDefaultExtensions } from '../lib/utils/text/richText'
import { shapesFromJsx } from './test-jsx'
declare module 'vitest' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface Matchers {
toCloselyMatchObject(expected: any, roundToNearest?: number): void
}
}
vi.useFakeTimers()
Object.assign(navigator, {
clipboard: {
write: () => {
//noop
},
},
})
// @ts-expect-error
window.ClipboardItem = class {}
/** @
* TestEditor is a subclass of Editor that is used to test the editor.
* @param options - The options for the editor.
* @param storeOptions - The options for the store.
* @returns A new TestEditor instance.
* internal */
export class TestEditor extends Editor {
controller: Driver
constructor(
options: Partial> = {},
storeOptions: Partial = {}
) {
const elm = document.createElement('div')
const bounds = {
x: 0,
y: 0,
top: 0,
left: 0,
width: 1080,
height: 720,
bottom: 720,
right: 1080,
}
// make the app full screen for the sake of the insets property
vi.spyOn(document.body, 'scrollWidth', 'get').mockImplementation(() => bounds.width)
vi.spyOn(document.body, 'scrollHeight', 'get').mockImplementation(() => bounds.height)
elm.tabIndex = 0
elm.getBoundingClientRect = () => bounds as DOMRect
const shapeUtilsWithDefaults = [
...defaultShapeUtils.filter((s) => !options.shapeUtils?.some((su) => su.type === s.type)),
...(options.shapeUtils ?? []),
]
const bindingUtilsWithDefaults = [
...defaultBindingUtils.filter((b) => !options.bindingUtils?.some((bu) => bu.type === b.type)),
...(options.bindingUtils ?? []),
]
super({
...options,
shapeUtils: shapeUtilsWithDefaults,
bindingUtils: bindingUtilsWithDefaults,
tools: [...defaultTools, ...defaultShapeTools, ...(options.tools ?? [])],
store: createTLStore({
shapeUtils: shapeUtilsWithDefaults,
bindingUtils: bindingUtilsWithDefaults,
...storeOptions,
}),
getContainer: () => elm,
initialState: 'select',
options: {
...options.options,
text: {
addFontsFromNode: defaultAddFontsFromNode,
tipTapConfig: {
extensions: tipTapDefaultExtensions,
},
...options.options?.text,
},
},
})
this.elm = elm
this.bounds = bounds
this.controller = new Driver(this)
// Pretty hacky way to mock the screen bounds
document.body.appendChild(this.elm)
this.textMeasure.measureText = (
textToMeasure: string,
opts: TLMeasureTextOpts
): BoxModel & { scrollWidth: number } => {
const breaks = textToMeasure.split('\n')
const longest = breaks.reduce((acc, curr) => {
return curr.length > acc.length ? curr : acc
}, '')
const w = longest.length * (opts.fontSize / 2)
return {
x: 0,
y: 0,
w: opts.maxWidth === null ? w : Math.max(w, opts.maxWidth),
h:
(opts.maxWidth === null ? breaks.length : Math.ceil(w / opts.maxWidth) + breaks.length) *
opts.fontSize,
scrollWidth: opts.measureScrollWidth
? opts.maxWidth === null
? w
: Math.max(w, opts.maxWidth)
: 0,
}
}
this.textMeasure.measureHtml = (
html: string,
opts: TLMeasureTextOpts
): BoxModel & { scrollWidth: number } => {
const textToMeasure = html
.split('
')
.join('\n')
.replace(/<[^>]+>/g, '')
return this.textMeasure.measureText(textToMeasure, opts)
}
this.textMeasure.measureTextSpans = (textToMeasure, opts) => {
const box = this.textMeasure.measureText(textToMeasure, {
...opts,
maxWidth: opts.width,
padding: `${opts.padding}px`,
})
return [{ box, text: textToMeasure }]
}
// Turn off edge scrolling for tests. Tests that require this can turn it back on.
this.user.updateUserPreferences({ edgeScrollSpeed: 0 })
// Wow! we'd forgotten these for a long time
registerDefaultSideEffects(this)
}
getHistory() {
return this.history
}
elm: HTMLElement
readonly bounds: {
x: number
y: number
top: number
left: number
width: number
height: number
bottom: number
right: number
}
setScreenBounds(bounds: BoxModel, center = false) {
this.bounds.x = bounds.x
this.bounds.y = bounds.y
this.bounds.top = bounds.y
this.bounds.left = bounds.x
this.bounds.width = bounds.w
this.bounds.height = bounds.h
this.bounds.right = bounds.x + bounds.w
this.bounds.bottom = bounds.y + bounds.h
this.updateViewportScreenBounds(Box.From(bounds), center)
return this
}
/**
* If you need to trigger a double click, you can either mock the implementation of one of these
* methods, or call mockRestore() to restore the actual implementation (e.g.
* _transformPointerDownSpy.mockRestore())
*/
_transformPointerDownSpy = vi
.spyOn(this._clickManager, 'handlePointerEvent')
.mockImplementation((info) => {
return info
})
_transformPointerUpSpy = vi
.spyOn(this._clickManager, 'handlePointerEvent')
.mockImplementation((info) => {
return info
})
/* ---- Delegated to Driver ---- */
getClipboard() {
return this.controller.clipboard
}
setClipboard(value: TLContent | null) {
this.controller.clipboard = value
}
getLastCreatedShapes(...args: Parameters) {
return this.controller.getLastCreatedShapes(...args)
}
getLastCreatedShape() {
return this.controller.getLastCreatedShape()
}
testShapeID(...args: Parameters) {
return this.controller.createShapeID(...args)
}
testPageID(...args: Parameters) {
return this.controller.createPageID(...args)
}
copy(...args: Parameters) {
this.controller.copy(...args)
return this
}
cut(...args: Parameters) {
this.controller.cut(...args)
return this
}
paste(...args: Parameters) {
this.controller.paste(...args)
return this
}
getViewportPageCenter() {
return this.controller.getViewportPageCenter()
}
getSelectionPageCenter() {
return this.controller.getSelectionPageCenter()
}
getPageCenter(...args: Parameters) {
return this.controller.getPageCenter(...args)
}
getPageRotationById(...args: Parameters) {
return this.controller.getPageRotationById(...args)
}
getPageRotation(...args: Parameters) {
return this.controller.getPageRotation(...args)
}
getArrowsBoundTo(...args: Parameters) {
return this.controller.getArrowsBoundTo(...args)
}
forceTick(...args: Parameters) {
this.controller.forceTick(...args)
return this
}
pointerMove(...args: Parameters) {
this.controller.pointerMove(...args)
return this
}
pointerDown(...args: Parameters) {
this.controller.pointerDown(...args)
return this
}
pointerUp(...args: Parameters) {
this.controller.pointerUp(...args)
return this
}
click(...args: Parameters) {
this.controller.click(...args)
return this
}
rightClick(...args: Parameters) {
this.controller.rightClick(...args)
return this
}
doubleClick(...args: Parameters) {
this.controller.doubleClick(...args)
return this
}
keyPress(...args: Parameters) {
this.controller.keyPress(...args)
return this
}
keyDown(...args: Parameters) {
this.controller.keyDown(...args)
return this
}
keyRepeat(...args: Parameters) {
this.controller.keyRepeat(...args)
return this
}
keyUp(...args: Parameters) {
this.controller.keyUp(...args)
return this
}
wheel(...args: Parameters) {
this.controller.wheel(...args)
return this
}
pan(...args: Parameters) {
this.controller.pan(...args)
return this
}
pinchStart(...args: Parameters) {
this.controller.pinchStart(...args)
return this
}
pinchTo(...args: Parameters) {
this.controller.pinchTo(...args)
return this
}
pinchEnd(...args: Parameters) {
this.controller.pinchEnd(...args)
return this
}
rotateSelection(...args: Parameters) {
this.controller.rotateSelection(...args)
return this
}
translateSelection(...args: Parameters) {
this.controller.translateSelection(...args)
return this
}
resizeSelection(...args: Parameters) {
this.controller.resizeSelection(...args)
return this
}
createShapesFromJsx(shapesJsx: React.JSX.Element | React.JSX.Element[]) {
const { shapes, assets, ids, bindings } = shapesFromJsx(shapesJsx)
this.createAssets(assets)
this.createShapes(shapes)
this.createBindings(bindings)
return ids
}
/**
* Move to a named selection handle and pointerDown there. The chained equivalent of
* `pointerDown(x, y, { target: 'selection', handle })` but using a real canvas event
* that exercises the overlay hit-test path. Requires `defaultHandleOverlays`.
*/
pointerDownOnHandle(
handle: string,
modifiers?: Partial<{ ctrlKey: boolean; shiftKey: boolean; altKey: boolean }>
): this {
const p = this.getSelectionHandlePagePoint(handle)
this.pointerMove(p.x, p.y)
this.pointerDown(p.x, p.y, undefined, modifiers)
return this
}
/**
* Move the pointer by the given delta from its current page position.
*/
pointerMoveBy(
dx: number,
dy: number,
modifiers?: Partial<{ ctrlKey: boolean; shiftKey: boolean; altKey: boolean }>
): this {
const current = this.inputs.getCurrentPagePoint()
this.pointerMove(current.x + dx, current.y + dy, modifiers)
return this
}
/**
* Get the page point of a named selection handle (resize, rotate, crop, etc.)
* by querying the SelectionForegroundOverlayUtil. Returns a point that hit-tests
* to the requested overlay first (some handles overlap, e.g. rotate handles can
* extend into the resize square area for small selections). Requires
* `defaultHandleOverlays`.
*/
getSelectionHandlePagePoint(handle: string): { x: number; y: number } {
const util =
this.overlays.getOverlayUtil('selection_foreground')
const id = `selection_fg:${handle}`
const overlay = util.getOverlays().find((o) => o.id === id)
if (!overlay) {
throw new Error(`No selection_foreground overlay found for handle "${handle}"`)
}
const geom = util.getGeometry(overlay)
if (!geom) throw new Error(`Overlay "${id}" has no geometry`)
const c = geom.center
// First try the geometric center
const initialHit = this.overlays.getOverlayAtPoint({ x: c.x, y: c.y })
if (initialHit && initialHit.id === id) return { x: c.x, y: c.y }
// Walk in a direction that escapes overlapping handles. For rotate handles,
// walk away from the selection center (rotate is visually outside the box);
// for resize handles, walk toward the selection center.
const isRotate = handle.endsWith('_rotate') || handle === 'mobile_rotate'
const selBounds = this.getSelectionPageBounds()
if (!selBounds) return { x: c.x, y: c.y }
const selCenter = selBounds.center
const dx = isRotate ? c.x - selCenter.x : selCenter.x - c.x
const dy = isRotate ? c.y - selCenter.y : selCenter.y - c.y
const len = Math.sqrt(dx * dx + dy * dy) || 1
const ux = dx / len
const uy = dy / len
for (let step = 1; step <= 20; step++) {
const p = { x: c.x + ux * step, y: c.y + uy * step }
const hit = this.overlays.getOverlayAtPoint(p)
if (hit && hit.id === id) return p
}
return { x: c.x, y: c.y }
}
/* ---- Test assertions ---- */
expectToBeIn(path: string) {
expect(this.getPath()).toBe(path)
return this
}
expectCameraToBe(x: number, y: number, z: number) {
const camera = this.getCamera()
expect({
x: +camera.x.toFixed(2),
y: +camera.y.toFixed(2),
z: +camera.z.toFixed(2),
}).toCloselyMatchObject({ x, y, z })
return this
}
expectShapeToMatch(
...model: RequiredKeys>, 'id'>[]
) {
model.forEach((model) => {
const shape = this.getShape(model.id!)!
const next = { ...shape, ...model }
expect(shape).toCloselyMatchObject(next)
})
return this
}
expectPageBoundsToBe(id: IdOf, bounds: Partial) {
const observedBounds = this.getShapePageBounds(id)!
expect(observedBounds).toCloselyMatchObject(bounds)
return this
}
expectScreenBoundsToBe(id: IdOf, bounds: Partial) {
const pageBounds = this.getShapePageBounds(id)!
const screenPoint = this.pageToScreen(pageBounds.point)
const observedBounds = pageBounds.clone()
observedBounds.x = screenPoint.x
observedBounds.y = screenPoint.y
expect(observedBounds).toCloselyMatchObject(bounds)
return this
}
}
export const defaultShapesIds = {
box1: createShapeId('box1'),
box2: createShapeId('box2'),
ellipse1: createShapeId('ellipse1'),
}
export const createDefaultShapes = (): TLShapePartial[] => [
{
id: defaultShapesIds.box1,
type: 'geo',
x: 100,
y: 100,
props: {
w: 100,
h: 100,
geo: 'rectangle',
},
},
{
id: defaultShapesIds.box2,
type: 'geo',
x: 200,
y: 200,
rotation: HALF_PI / 2,
props: {
w: 100,
h: 100,
color: 'black',
fill: 'none',
dash: 'draw',
size: 'm',
geo: 'rectangle',
},
},
{
id: defaultShapesIds.ellipse1,
type: 'geo',
parentId: defaultShapesIds.box2,
x: 200,
y: 200,
props: {
w: 50,
h: 50,
color: 'black',
fill: 'none',
dash: 'draw',
size: 'm',
geo: 'ellipse',
},
},
]