snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
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
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
This commit is contained in:
418
packages/tldraw/src/lib/shapes/geo/GeoShapeUtil.test.tsx
Normal file
418
packages/tldraw/src/lib/shapes/geo/GeoShapeUtil.test.tsx
Normal file
@@ -0,0 +1,418 @@
|
||||
import {
|
||||
GeoShapeGeoStyle,
|
||||
Group2d,
|
||||
IndexKey,
|
||||
TLGeoShape,
|
||||
TLShapeId,
|
||||
createShapeId,
|
||||
toRichText,
|
||||
} from '@tldraw/editor'
|
||||
import { vi } from 'vitest'
|
||||
import { TestEditor } from '../../../test/TestEditor'
|
||||
import { PathBuilder } from '../shared/PathBuilder'
|
||||
import { GeoShapeUtil } from './GeoShapeUtil'
|
||||
|
||||
let editor: TestEditor
|
||||
let ids: Record<string, TLShapeId>
|
||||
|
||||
beforeEach(() => {
|
||||
editor = new TestEditor()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
editor?.dispose()
|
||||
})
|
||||
|
||||
describe('Handle snapping', () => {
|
||||
beforeEach(() => {
|
||||
ids = {
|
||||
geo: createShapeId('geo'),
|
||||
line: createShapeId('line'),
|
||||
}
|
||||
editor.createShapes([
|
||||
{ id: ids.geo, type: 'geo', x: 0, y: 0, props: { geo: 'rectangle', w: 100, h: 100 } },
|
||||
{
|
||||
id: ids.line,
|
||||
type: 'line',
|
||||
x: 0,
|
||||
y: 0,
|
||||
props: {
|
||||
points: {
|
||||
a1: { id: 'a1', index: 'a1' as IndexKey, x: 200, y: 0 },
|
||||
a2: { id: 'a2', index: 'a2' as IndexKey, x: 200, y: 100 },
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
const geoShape = () => {
|
||||
const shape = editor.getShape(ids.geo)!
|
||||
assert(editor.isShapeOfType(shape, 'geo'))
|
||||
return shape
|
||||
}
|
||||
const lineShape = () => {
|
||||
const shape = editor.getShape(ids.line)!
|
||||
assert(editor.isShapeOfType(shape, 'line'))
|
||||
return shape
|
||||
}
|
||||
const lineHandles = () => editor.getShapeUtil('line').getHandles!(lineShape())!
|
||||
|
||||
function startDraggingHandle() {
|
||||
editor
|
||||
.select(ids.line)
|
||||
.pointerDown(200, 0, { target: 'handle', shape: lineShape(), handle: lineHandles()[0] })
|
||||
}
|
||||
|
||||
test('handles snap to the edges of the shape', () => {
|
||||
startDraggingHandle()
|
||||
editor.pointerMove(50, 5, undefined, { ctrlKey: true })
|
||||
expect(editor.snaps.getIndicators()).toHaveLength(1)
|
||||
expect(lineHandles()[0]).toMatchObject({ x: 50, y: 0 })
|
||||
})
|
||||
|
||||
test('handles snap to the corner of the shape', () => {
|
||||
startDraggingHandle()
|
||||
editor.pointerMove(0, 5, undefined, { ctrlKey: true })
|
||||
expect(editor.snaps.getIndicators()).toHaveLength(1)
|
||||
expect(lineHandles()[0]).toMatchObject({ x: 0, y: 0 })
|
||||
})
|
||||
|
||||
test('handles snap to the center of the shape', () => {
|
||||
startDraggingHandle()
|
||||
editor.pointerMove(51, 45, undefined, { ctrlKey: true })
|
||||
expect(editor.snaps.getIndicators()).toHaveLength(1)
|
||||
expect(lineHandles()[0]).toMatchObject({ x: 50, y: 50 })
|
||||
})
|
||||
|
||||
test('does not snap to the label of the shape', () => {
|
||||
startDraggingHandle()
|
||||
const geometry = editor.getShapeUtil('geo').getGeometry(geoShape()) as Group2d
|
||||
const label = geometry.children.find((c) => c.isLabel)!
|
||||
const labelVertex = label.vertices[0]
|
||||
editor.pointerMove(labelVertex.x + 2, labelVertex.y + 2, undefined, { ctrlKey: true })
|
||||
expect(editor.snaps.getIndicators()).toHaveLength(0)
|
||||
expect(lineHandles()[0]).toMatchObject({ x: labelVertex.x + 2, y: labelVertex.y + 2 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Resizing geo shapes with labels', () => {
|
||||
const geoId = createShapeId('geo')
|
||||
|
||||
function getGeo() {
|
||||
return editor.getShape<TLGeoShape>(geoId)!
|
||||
}
|
||||
|
||||
test('can be resized narrower than the initial label width', () => {
|
||||
// Create a wide shape with a short label
|
||||
editor.createShapes([
|
||||
{
|
||||
id: geoId,
|
||||
type: 'geo',
|
||||
props: { w: 300, h: 100, richText: toRichText('Hi'), geo: 'rectangle' },
|
||||
},
|
||||
])
|
||||
const initialW = getGeo().props.w
|
||||
|
||||
// Resize to half width
|
||||
editor.resizeShape(geoId, { x: 0.5, y: 1 })
|
||||
|
||||
// Should be narrower than initial — not locked to the initial label measurement width
|
||||
const afterW = getGeo().props.w
|
||||
expect(afterW).toBeLessThan(initialW)
|
||||
})
|
||||
|
||||
test('height grows to accommodate wrapped text when resized narrower', () => {
|
||||
// Create a shape where the label fits without wrapping.
|
||||
// "Hello World" (11 chars) at medium size has raw text width = 121,
|
||||
// so min label width = 153. At initial w=300 the text is one line.
|
||||
editor.createShapes([
|
||||
{
|
||||
id: geoId,
|
||||
type: 'geo',
|
||||
props: { w: 300, h: 100, richText: toRichText('Hello World'), geo: 'rectangle' },
|
||||
},
|
||||
])
|
||||
|
||||
// Resize to a narrow width AND short height — text must wrap at the narrower width,
|
||||
// and the height should grow to fit the taller wrapped text.
|
||||
editor.resizeShape(geoId, { x: 0.2, y: 0.3 })
|
||||
|
||||
const geo = getGeo()
|
||||
// The height should be greater than the naive 100 * 0.3 = 30 target,
|
||||
// because the text wraps at the narrower width and needs more vertical space.
|
||||
expect(geo.props.h).toBeGreaterThan(30)
|
||||
})
|
||||
|
||||
test('width is constrained to at least the minimum label width', () => {
|
||||
// "Hello World" has a minimum label width of 153 (text raw width 121 + padding 32)
|
||||
editor.createShapes([
|
||||
{
|
||||
id: geoId,
|
||||
type: 'geo',
|
||||
props: { w: 300, h: 100, richText: toRichText('Hello World'), geo: 'rectangle' },
|
||||
},
|
||||
])
|
||||
|
||||
// Try to resize to something very small
|
||||
editor.resizeShape(geoId, { x: 0.1, y: 1 })
|
||||
|
||||
const geo = getGeo()
|
||||
// The width should be at least MIN_SIZE_WITH_LABEL (51) — the text's minimum width
|
||||
// is actually larger (153), so the shape should be constrained to the label width
|
||||
expect(geo.props.w).toBeGreaterThanOrEqual(51)
|
||||
// And specifically, it should be at least the label's minimum width
|
||||
expect(geo.props.w).toBeGreaterThan(100)
|
||||
})
|
||||
|
||||
test('shape without label can be resized freely', () => {
|
||||
editor.createShapes([
|
||||
{
|
||||
id: geoId,
|
||||
type: 'geo',
|
||||
props: { w: 300, h: 200, geo: 'rectangle' },
|
||||
},
|
||||
])
|
||||
|
||||
editor.resizeShape(geoId, { x: 0.1, y: 0.1 })
|
||||
|
||||
const geo = getGeo()
|
||||
expect(geo.props.w).toBeLessThan(50)
|
||||
expect(geo.props.h).toBeLessThan(50)
|
||||
})
|
||||
|
||||
describe.each([1, 2, 0.5])('when the label stops the shrink at scale %s', (scale) => {
|
||||
const w = 300 * scale
|
||||
const h = 100 * scale
|
||||
|
||||
beforeEach(() => {
|
||||
editor.createShapes([
|
||||
{
|
||||
id: geoId,
|
||||
type: 'geo',
|
||||
props: { w, h, scale, richText: toRichText('Hello World'), geo: 'rectangle' },
|
||||
},
|
||||
])
|
||||
editor.select(geoId)
|
||||
})
|
||||
|
||||
function getEdges() {
|
||||
const { minX, minY, maxX, maxY } = editor.getShapePageBounds(geoId)!
|
||||
return { minX, minY, maxX, maxY }
|
||||
}
|
||||
|
||||
test('dragging the top-left handle keeps the bottom-right corner fixed', () => {
|
||||
const before = getEdges()
|
||||
|
||||
editor
|
||||
.pointerDown(0, 0, { target: 'selection', handle: 'top_left' })
|
||||
.pointerMove(w - 10 * scale, h - 2 * scale)
|
||||
.pointerUp()
|
||||
|
||||
const after = getEdges()
|
||||
// the label must have stopped the shrink, or the corner check proves nothing
|
||||
expect(after.maxX - after.minX).toBeGreaterThan(10 * scale)
|
||||
expect(after.maxY - after.minY).toBeGreaterThan(2 * scale)
|
||||
expect({ maxX: after.maxX, maxY: after.maxY }).toEqual({
|
||||
maxX: before.maxX,
|
||||
maxY: before.maxY,
|
||||
})
|
||||
})
|
||||
|
||||
test('flipping over the right edge with the left handle keeps that edge fixed', () => {
|
||||
const before = getEdges()
|
||||
|
||||
editor
|
||||
.pointerDown(0, h / 2, { target: 'selection', handle: 'left' })
|
||||
.pointerMove(w + 10 * scale, h / 2)
|
||||
.pointerUp()
|
||||
|
||||
expect(getGeo().props.flipX).toBe(true)
|
||||
expect(getEdges().minX).toBe(before.maxX)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Geo shapes with programmatically-authored empty rich text', () => {
|
||||
const geoId = createShapeId('geo')
|
||||
|
||||
function getGeo() {
|
||||
return editor.getShape<TLGeoShape>(geoId)!
|
||||
}
|
||||
|
||||
// An empty paragraph can be encoded two equally-valid ways. The interactive
|
||||
// editor emits the first (no `content` key); programmatic authoring (snapshot
|
||||
// load, agents emitting tldraw JSON) commonly emits the second (`content: []`).
|
||||
// Both mean "no label", so neither should grow the shape to fit a phantom line.
|
||||
const emptyForms = [
|
||||
{
|
||||
label: 'paragraph with no content key (toRichText output)',
|
||||
richText: toRichText(''),
|
||||
},
|
||||
{
|
||||
label: 'paragraph with empty content array',
|
||||
richText: {
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph', attrs: { dir: 'auto' }, content: [] }],
|
||||
} as TLGeoShape['props']['richText'],
|
||||
},
|
||||
{
|
||||
label: 'doc with empty content array',
|
||||
richText: { type: 'doc', content: [] } as TLGeoShape['props']['richText'],
|
||||
},
|
||||
]
|
||||
|
||||
for (const { label, richText } of emptyForms) {
|
||||
test(`does not grow height for an empty label: ${label}`, () => {
|
||||
editor.createShapes([
|
||||
{
|
||||
id: geoId,
|
||||
type: 'geo',
|
||||
props: { w: 110, h: 24, geo: 'rectangle', size: 's', richText },
|
||||
},
|
||||
])
|
||||
|
||||
const geo = getGeo()
|
||||
expect(geo.props.h).toBe(24)
|
||||
expect(geo.props.growY).toBe(0)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('Flipping geo shapes', () => {
|
||||
const geoId = createShapeId('geo')
|
||||
|
||||
function getGeo() {
|
||||
return editor.getShape<TLGeoShape>(geoId)!
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
editor.createShapes([
|
||||
{ id: geoId, type: 'geo', x: 0, y: 0, props: { geo: 'arrow-left', w: 100, h: 80 } },
|
||||
])
|
||||
})
|
||||
|
||||
test('flip command toggles flipX / flipY like the image shape', () => {
|
||||
expect(getGeo().props.flipX).toBe(false)
|
||||
|
||||
editor.flipShapes([geoId], 'horizontal')
|
||||
expect(getGeo().props).toMatchObject({ flipX: true, flipY: false })
|
||||
|
||||
editor.flipShapes([geoId], 'vertical')
|
||||
expect(getGeo().props).toMatchObject({ flipX: true, flipY: true })
|
||||
|
||||
// flipping again on the same axis restores the original state
|
||||
editor.flipShapes([geoId], 'horizontal')
|
||||
expect(getGeo().props).toMatchObject({ flipX: false, flipY: true })
|
||||
})
|
||||
|
||||
test('flipping a lone geo shape keeps its position and size', () => {
|
||||
const before = getGeo()
|
||||
editor.flipShapes([geoId], 'horizontal')
|
||||
const after = getGeo()
|
||||
expect(after).toMatchObject({
|
||||
x: before.x,
|
||||
y: before.y,
|
||||
props: { w: before.props.w, h: before.props.h, flipX: true },
|
||||
})
|
||||
})
|
||||
|
||||
test('mirrors the geometry when flipped, keeping the bounds unchanged', () => {
|
||||
const util = editor.getShapeUtil('geo')
|
||||
const outlineBefore = (util.getGeometry(getGeo()) as Group2d).children[0]
|
||||
const vertsBefore = outlineBefore.vertices.map((v) => ({ x: v.x, y: v.y }))
|
||||
|
||||
editor.updateShape({ id: geoId, type: 'geo', props: { flipX: true } })
|
||||
|
||||
const outlineAfter = (util.getGeometry(getGeo()) as Group2d).children[0]
|
||||
|
||||
// bounds are unchanged: we mirror geometry within the shape's box, not its w/h
|
||||
expect(outlineAfter.bounds.width).toBeCloseTo(outlineBefore.bounds.width)
|
||||
expect(outlineAfter.bounds.height).toBeCloseTo(outlineBefore.bounds.height)
|
||||
|
||||
// every vertex is mirrored around the horizontal center (w = 100)
|
||||
const vertsAfter = outlineAfter.vertices
|
||||
expect(vertsAfter).toHaveLength(vertsBefore.length)
|
||||
for (const v of vertsBefore) {
|
||||
const mirrored = { x: 100 - v.x, y: v.y }
|
||||
expect(
|
||||
vertsAfter.some(
|
||||
(a) => Math.abs(a.x - mirrored.x) < 0.01 && Math.abs(a.y - mirrored.y) < 0.01
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('GeoShapeUtil.configure with customGeoTypes', () => {
|
||||
// Snapshot the built-in geo values so we can clean up any custom keys added
|
||||
// during these tests. `GeoShapeUtil.configure({ customGeoTypes })` mutates
|
||||
// `GeoShapeGeoStyle.values` globally via `addValues`, so without cleanup the
|
||||
// state would leak between tests in this describe block.
|
||||
const builtinGeoValues = [...GeoShapeGeoStyle.values]
|
||||
afterEach(() => {
|
||||
const toRemove = GeoShapeGeoStyle.values.filter((v) => !builtinGeoValues.includes(v))
|
||||
if (toRemove.length > 0) {
|
||||
GeoShapeGeoStyle.removeValues(...toRemove)
|
||||
}
|
||||
})
|
||||
|
||||
const validDef = {
|
||||
getPath: (w: number, h: number) =>
|
||||
new PathBuilder()
|
||||
.moveTo(0, 0, { geometry: { isFilled: true } })
|
||||
.lineTo(w, 0)
|
||||
.lineTo(w, h)
|
||||
.lineTo(0, h)
|
||||
.close(),
|
||||
snapType: 'polygon' as const,
|
||||
icon: 'geo-rectangle',
|
||||
defaultSize: { w: 999, h: 999 },
|
||||
}
|
||||
|
||||
function getConfiguredOptions(customGeoTypes: Record<string, typeof validDef>) {
|
||||
const Configured = GeoShapeUtil.configure({ customGeoTypes })
|
||||
const localEditor = new TestEditor({ shapeUtils: [Configured] })
|
||||
try {
|
||||
const util = localEditor.getShapeUtil('geo') as GeoShapeUtil
|
||||
return util.options.customGeoTypes
|
||||
} finally {
|
||||
localEditor.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
test('keeps non-colliding entries in options.customGeoTypes', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
try {
|
||||
expect(getConfiguredOptions({ 'my-shape': validDef })).toEqual({ 'my-shape': validDef })
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test('strips colliding keys from options so runtime lookups do not see them', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
try {
|
||||
expect(getConfiguredOptions({ rectangle: validDef, 'my-shape': validDef })).toEqual({
|
||||
'my-shape': validDef,
|
||||
})
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringMatching(/customGeoTypes key "rectangle"/))
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test('reusing the same custom key across configure() calls still keeps the entry', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
try {
|
||||
expect(getConfiguredOptions({ 'my-shape': validDef })).toEqual({ 'my-shape': validDef })
|
||||
// A second configure() call with the same key should not treat it as a
|
||||
// collision with built-ins, so the entry is preserved.
|
||||
expect(getConfiguredOptions({ 'my-shape': validDef })).toEqual({ 'my-shape': validDef })
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user