Files
upstream-tldraw/packages/commenting/src/canvas/thread-state.test.ts
upstream-mirror a774ac673a
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
snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
2026-09-23 16:12:52 +05:00

296 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
})
})