Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
296 lines
10 KiB
TypeScript
296 lines
10 KiB
TypeScript
import {
|
||
Box,
|
||
commentSchemaRecords,
|
||
createCommentThread,
|
||
createShapeId,
|
||
createTLSchema,
|
||
createTLStore,
|
||
defaultBindingUtils,
|
||
defaultShapeUtils,
|
||
defaultTools,
|
||
Editor,
|
||
PageRecordType,
|
||
TLShapeId,
|
||
} from 'tldraw'
|
||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||
import { commentsSidebarOpen } from './state'
|
||
import {
|
||
anchorPagePoint,
|
||
commentCenterScreenOffset,
|
||
commentTargetShapeAt,
|
||
focusThread,
|
||
impreciseShapePinInset,
|
||
IMPRECISE_PIN_INSET_PX,
|
||
isBoxInInflatedViewport,
|
||
isInInflatedViewport,
|
||
shapeAnchorAt,
|
||
} from './thread-state'
|
||
|
||
/**
|
||
* A real editor: the behavior under test is hit-testing against real shape geometry, which is
|
||
* exactly what a stub can't model.
|
||
*/
|
||
let editor: Editor
|
||
|
||
beforeEach(() => {
|
||
editor = new Editor({
|
||
store: createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }),
|
||
shapeUtils: defaultShapeUtils,
|
||
bindingUtils: defaultBindingUtils,
|
||
tools: defaultTools,
|
||
getContainer: () => document.body,
|
||
})
|
||
editor.updateViewportScreenBounds(new Box(0, 0, 1000, 1000))
|
||
})
|
||
|
||
describe('commentTargetShapeAt', () => {
|
||
it('hits an arrow pointed at near its line', () => {
|
||
const id = createShapeId()
|
||
editor.createShape({
|
||
id,
|
||
type: 'arrow',
|
||
x: 100,
|
||
y: 100,
|
||
props: { start: { x: 0, y: 0 }, end: { x: 200, y: 0 } },
|
||
})
|
||
|
||
// Dead on the line, and a couple of pixels off it — both are a hit. Without a hit-test
|
||
// margin only the pixel-perfect click would land, which made arrows uncommentable.
|
||
expect(commentTargetShapeAt(editor, { x: 200, y: 100 })?.id).toBe(id)
|
||
expect(commentTargetShapeAt(editor, { x: 200, y: 102 })?.id).toBe(id)
|
||
})
|
||
|
||
it('hits a line shape near its segment', () => {
|
||
const id = createShapeId()
|
||
editor.createShape({ id, type: 'line', x: 100, y: 100 })
|
||
|
||
const bounds = editor.getShapePageBounds(id)!
|
||
expect(commentTargetShapeAt(editor, { x: bounds.center.x, y: bounds.center.y })?.id).toBe(id)
|
||
})
|
||
|
||
it('hits inside a hollow shape', () => {
|
||
const id = createShapeId()
|
||
editor.createShape({
|
||
id,
|
||
type: 'geo',
|
||
x: 100,
|
||
y: 100,
|
||
props: { w: 100, h: 100, fill: 'none' },
|
||
})
|
||
|
||
expect(commentTargetShapeAt(editor, { x: 150, y: 150 })?.id).toBe(id)
|
||
})
|
||
|
||
it('returns undefined on empty canvas', () => {
|
||
editor.createShape({
|
||
id: createShapeId(),
|
||
type: 'arrow',
|
||
x: 100,
|
||
y: 100,
|
||
props: { start: { x: 0, y: 0 }, end: { x: 200, y: 0 } },
|
||
})
|
||
|
||
expect(commentTargetShapeAt(editor, { x: 600, y: 600 })).toBeUndefined()
|
||
})
|
||
})
|
||
|
||
/** A 100×100 square whose page bounds are (100,100)–(200,200), unrotated. */
|
||
function createSquare(): TLShapeId {
|
||
const id = createShapeId()
|
||
editor.createShape({ id, type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } })
|
||
return id
|
||
}
|
||
|
||
describe('shape anchors under a shape transform', () => {
|
||
it('carries a precise pin around when the shape rotates', () => {
|
||
const id = createSquare()
|
||
// Top-right corner of the square.
|
||
const anchor = shapeAnchorAt(editor, id, { x: 200, y: 100 }, true)
|
||
expect(anchor).toMatchObject({ type: 'shape', shapeId: id, x: 1, y: 0, isPrecise: true })
|
||
expect(anchorPagePoint(editor, anchor)).toMatchObject({ x: 200, y: 100 })
|
||
|
||
// A quarter turn about the square's centre takes that corner to the bottom-right. The
|
||
// square's page bounds don't change, so a pin normalized against them would sit still.
|
||
editor.rotateShapesBy([id], Math.PI / 2)
|
||
const rotated = anchorPagePoint(editor, anchor)!
|
||
expect(rotated.x).toBeCloseTo(200)
|
||
expect(rotated.y).toBeCloseTo(200)
|
||
})
|
||
|
||
it('records the spot a pin was dropped on a rotated shape', () => {
|
||
const id = createSquare()
|
||
editor.rotateShapesBy([id], Math.PI / 2)
|
||
|
||
// After the quarter turn the square's local top-right corner sits at the page's bottom-right.
|
||
const anchor = shapeAnchorAt(editor, id, { x: 200, y: 200 }, true)
|
||
expect(anchor.type).toBe('shape')
|
||
if (anchor.type !== 'shape') return
|
||
expect(anchor.x).toBeCloseTo(1)
|
||
expect(anchor.y).toBeCloseTo(0)
|
||
|
||
const point = anchorPagePoint(editor, anchor)!
|
||
expect(point.x).toBeCloseTo(200)
|
||
expect(point.y).toBeCloseTo(200)
|
||
})
|
||
|
||
it('still tracks moves and resizes', () => {
|
||
const id = createSquare()
|
||
const anchor = shapeAnchorAt(editor, id, { x: 200, y: 100 }, true)
|
||
|
||
editor.updateShape({ id, type: 'geo', x: 300, props: { w: 200 } })
|
||
expect(anchorPagePoint(editor, anchor)).toMatchObject({ x: 500, y: 100 })
|
||
})
|
||
|
||
it('turns an imprecise pin inset with the shape so it keeps stepping inward', () => {
|
||
const id = createSquare()
|
||
const anchor = shapeAnchorAt(editor, id, { x: 200, y: 100 }, false)
|
||
// The default top-right spot: step left and down, into the square.
|
||
expect(impreciseShapePinInset(editor, anchor)).toMatchObject({
|
||
x: -IMPRECISE_PIN_INSET_PX,
|
||
y: IMPRECISE_PIN_INSET_PX,
|
||
})
|
||
|
||
editor.rotateShapesBy([id], Math.PI / 2)
|
||
// The corner is now bottom-right, so stepping inward means left and up.
|
||
const inset = impreciseShapePinInset(editor, anchor)!
|
||
expect(inset.x).toBeCloseTo(-IMPRECISE_PIN_INSET_PX)
|
||
expect(inset.y).toBeCloseTo(-IMPRECISE_PIN_INSET_PX)
|
||
})
|
||
|
||
it('hides the pin for a shape that no longer exists', () => {
|
||
const id = createSquare()
|
||
const anchor = shapeAnchorAt(editor, id, { x: 200, y: 100 }, true)
|
||
editor.deleteShape(id)
|
||
expect(anchorPagePoint(editor, anchor)).toBe(null)
|
||
})
|
||
})
|
||
|
||
describe('commentCenterScreenOffset', () => {
|
||
/** A sidebar element whose left edge sits at `left` client px, in the editor's container. */
|
||
function mountSidebar(left: number) {
|
||
const el = document.createElement('div')
|
||
el.className = 'tlui-cmt-canvas-sidebar'
|
||
el.getBoundingClientRect = () => ({ left }) as DOMRect
|
||
document.body.appendChild(el)
|
||
return el
|
||
}
|
||
|
||
afterEach(() => {
|
||
document.querySelector('.tlui-cmt-canvas-sidebar')?.remove()
|
||
})
|
||
|
||
it('is zero while the sidebar is closed', () => {
|
||
mountSidebar(712)
|
||
expect(commentCenterScreenOffset(editor)).toBe(0)
|
||
})
|
||
|
||
it('is half the covered width, so the pin centers in the uncovered area', () => {
|
||
commentsSidebarOpen.set(editor, true)
|
||
// Viewport is 1000px wide; the sidebar covers the rightmost 288px. The centered pin (at
|
||
// 356px) leaves the thread UI clear of the sidebar, so no nudge is needed.
|
||
mountSidebar(712)
|
||
expect(commentCenterScreenOffset(editor)).toBe(144)
|
||
})
|
||
|
||
it('nudges the pin further left when the thread UI would reach the sidebar', () => {
|
||
commentsSidebarOpen.set(editor, true)
|
||
// Centered in the uncovered area the pin sits at 300px, and the thread UI (334px wide,
|
||
// plus the 8px gap) would cross the sidebar's edge — so the pin backs off to 258px.
|
||
mountSidebar(600)
|
||
expect(commentCenterScreenOffset(editor)).toBe(500 - 258)
|
||
})
|
||
|
||
it('never nudges the pin past the left edge', () => {
|
||
commentsSidebarOpen.set(editor, true)
|
||
// Clearing the sidebar would need a negative pin position; the left inset wins.
|
||
mountSidebar(300)
|
||
expect(commentCenterScreenOffset(editor)).toBe(500 - 8)
|
||
})
|
||
|
||
it('is zero while the sidebar element is not mounted', () => {
|
||
commentsSidebarOpen.set(editor, true)
|
||
expect(commentCenterScreenOffset(editor)).toBe(0)
|
||
})
|
||
})
|
||
|
||
describe('isInInflatedViewport', () => {
|
||
// The viewport is 1000x1000 (see beforeEach) and the margin is 120 screen px.
|
||
it('accepts a point on screen', () => {
|
||
expect(isInInflatedViewport(editor, { x: 500, y: 500 })).toBe(true)
|
||
})
|
||
|
||
it('accepts a point just off screen, so a pan finds it already mounted', () => {
|
||
expect(isInInflatedViewport(editor, { x: -119, y: 500 })).toBe(true)
|
||
expect(isInInflatedViewport(editor, { x: 1119, y: 500 })).toBe(true)
|
||
expect(isInInflatedViewport(editor, { x: 500, y: -119 })).toBe(true)
|
||
expect(isInInflatedViewport(editor, { x: 500, y: 1119 })).toBe(true)
|
||
})
|
||
|
||
it('rejects a point past the margin on any side', () => {
|
||
expect(isInInflatedViewport(editor, { x: -121, y: 500 })).toBe(false)
|
||
expect(isInInflatedViewport(editor, { x: 1121, y: 500 })).toBe(false)
|
||
expect(isInInflatedViewport(editor, { x: 500, y: -121 })).toBe(false)
|
||
expect(isInInflatedViewport(editor, { x: 500, y: 1121 })).toBe(false)
|
||
})
|
||
})
|
||
|
||
describe('isBoxInInflatedViewport', () => {
|
||
it('accepts a box wholly on screen', () => {
|
||
expect(isBoxInInflatedViewport(editor, { x: 100, y: 100, w: 200, h: 200 })).toBe(true)
|
||
})
|
||
|
||
it('accepts a box straddling the edge, whose pin corner alone would be culled', () => {
|
||
// This is the case the point test gets wrong: the box's visible edge is on screen while
|
||
// its far corner — where a region thread's pin can sit — is thousands of px away.
|
||
expect(isBoxInInflatedViewport(editor, { x: -5000, y: -5000, w: 5100, h: 5100 })).toBe(true)
|
||
})
|
||
|
||
it('accepts a box larger than the viewport on every side', () => {
|
||
expect(isBoxInInflatedViewport(editor, { x: -5000, y: -5000, w: 10000, h: 10000 })).toBe(true)
|
||
})
|
||
|
||
it('rejects a box wholly past the margin', () => {
|
||
expect(isBoxInInflatedViewport(editor, { x: 1200, y: 100, w: 100, h: 100 })).toBe(false)
|
||
expect(isBoxInInflatedViewport(editor, { x: 100, y: -400, w: 100, h: 100 })).toBe(false)
|
||
})
|
||
|
||
it('follows the camera rather than page coordinates', () => {
|
||
const box = { x: 2000, y: 0, w: 100, h: 100 }
|
||
expect(isBoxInInflatedViewport(editor, box)).toBe(false)
|
||
editor.setCamera({ x: -2000, y: 0, z: 1 })
|
||
expect(isBoxInInflatedViewport(editor, box)).toBe(true)
|
||
})
|
||
})
|
||
|
||
describe('focusThread', () => {
|
||
it('records a cross-page jump as its own undo step', () => {
|
||
const page1Id = editor.getCurrentPageId()
|
||
const page2Id = PageRecordType.createId()
|
||
const boxId = createShapeId()
|
||
|
||
// Set up the second page outside of history so the only undoable steps are the ones the
|
||
// test creates: one edit on page 1, then the jump to the thread on page 2.
|
||
editor.run(() => editor.createPage({ id: page2Id, name: 'Page 2' }), { history: 'ignore' })
|
||
editor.clearHistory()
|
||
|
||
editor.markHistoryStoppingPoint('create box')
|
||
editor.createShape({ id: boxId, type: 'geo', x: 0, y: 0, props: { w: 100, h: 100 } })
|
||
|
||
const thread = createCommentThread({
|
||
pageId: page2Id,
|
||
anchor: { type: 'point', x: 50, y: 50 },
|
||
createdBy: 'me',
|
||
})
|
||
focusThread(editor, thread)
|
||
expect(editor.getCurrentPageId()).toBe(page2Id)
|
||
|
||
// The first undo only takes us back to page 1; the edit made there is still intact.
|
||
editor.undo()
|
||
expect(editor.getCurrentPageId()).toBe(page1Id)
|
||
expect(editor.getShape(boxId)).toBeDefined()
|
||
|
||
editor.undo()
|
||
expect(editor.getShape(boxId)).toBeUndefined()
|
||
})
|
||
})
|