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

This commit is contained in:
upstream-mirror
2026-09-23 16:12:52 +05:00
commit a774ac673a
4541 changed files with 770499 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
import { FocusedShape } from './FocusedShape'
export interface BlurryShape {
shapeId: string
text?: string
type: FocusedShape['_type']
x: number
y: number
w: number
h: number
}

View File

@@ -0,0 +1,36 @@
import z from 'zod'
export const FocusedColor = z.enum([
'red',
'light-red',
'green',
'light-green',
'blue',
'light-blue',
'orange',
'yellow',
'black',
'violet',
'light-violet',
'grey',
'white',
])
export type IFocusedColor = z.infer<typeof FocusedColor>
export function asColor(color: string): IFocusedColor {
if (FocusedColor.safeParse(color).success) {
return color as IFocusedColor
}
switch (color) {
case 'pink': {
return 'light-violet'
}
case 'light-pink': {
return 'light-violet'
}
}
return 'black'
}

View File

@@ -0,0 +1,31 @@
import { TLDefaultFillStyle } from 'tldraw'
import z from 'zod'
export const FocusedFillSchema = z.enum(['none', 'tint', 'background', 'solid', 'pattern'])
export type FocusedFill = z.infer<typeof FocusedFillSchema>
const FOCUSED_TO_SHAPE_FILLS: Record<FocusedFill, TLDefaultFillStyle> = {
none: 'none',
solid: 'lined-fill',
background: 'semi',
tint: 'solid',
pattern: 'pattern',
}
const SHAPE_TO_FOCUSED_FILLS: Record<TLDefaultFillStyle, FocusedFill> = {
none: 'none',
fill: 'solid',
'lined-fill': 'solid',
semi: 'background',
solid: 'tint',
pattern: 'pattern',
}
export function convertFocusedFillToTldrawFill(fill: FocusedFill): TLDefaultFillStyle {
return FOCUSED_TO_SHAPE_FILLS[fill]
}
export function convertTldrawFillToFocusedFill(fill: TLDefaultFillStyle): FocusedFill {
return SHAPE_TO_FOCUSED_FILLS[fill]
}

View File

@@ -0,0 +1,86 @@
import {
createShapeId,
Editor,
getDisplayValues,
IndexKey,
TLDefaultSizeStyle,
TLTextShape,
TextShapeUtil,
TextShapeUtilDisplayValues,
} from 'tldraw'
import { z } from 'zod'
const TEXT_SIZE_STYLES = ['s', 'm', 'l', 'xl'] as const satisfies readonly TLDefaultSizeStyle[]
export const FocusedFontSize = z.number()
/**
* Calculates the closest predefined font size and scale combination to achieve a target font size
* @param editor - The tldraw editor instance
* @param targetFontSize - The desired font size in pixels
* @param textProps - The text shape props to use when resolving display values
* @returns An object containing the closest predefined font size key and the scale factor
*/
export function convertFocusedFontSizeToTldrawFontSizeAndScale(
editor: Editor,
targetFontSize: number,
textProps?: Partial<TLTextShape['props']>
) {
const fontSizeEntries = TEXT_SIZE_STYLES.map(
(size) => [size, getTextShapeDisplayFontSize(editor, { ...textProps, size })] as const
)
let closestSize = fontSizeEntries[0]
let closestPixelSize = closestSize[1]
let minDifference = Math.abs(targetFontSize - closestPixelSize)
for (const [size, pixelSize] of fontSizeEntries) {
const difference = Math.abs(targetFontSize - pixelSize)
if (difference < minDifference) {
minDifference = difference
closestSize = [size, pixelSize]
closestPixelSize = pixelSize
}
}
const textSize = closestSize[0] as TLDefaultSizeStyle
const scale = targetFontSize / closestPixelSize
return { textSize, scale }
}
/**
* Converts a tldraw font size and scale to a focused font size
* @param editor - The tldraw editor instance
* @param shape - The text shape to convert
* @returns The focused font size
*/
export function convertTldrawFontSizeAndScaleToFocusedFontSize(editor: Editor, shape: TLTextShape) {
const util = editor.getShapeUtil<TextShapeUtil>('text')
const displayValues = getDisplayValues<TLTextShape, TextShapeUtilDisplayValues>(util, shape)
return Math.round(displayValues.fontSize * shape.props.scale)
}
function getTextShapeDisplayFontSize(
editor: Editor,
textProps?: Partial<TLTextShape['props']>
): number {
const util = editor.getShapeUtil<TextShapeUtil>('text')
const shape: TLTextShape = {
id: createShapeId('agent-font-size'),
typeName: 'shape',
type: 'text',
x: 0,
y: 0,
rotation: 0,
index: 'a1' as IndexKey,
parentId: editor.getCurrentPageId(),
isLocked: false,
opacity: 1,
props: {
...util.getDefaultProps(),
...textProps,
},
meta: {},
}
return getDisplayValues<TLTextShape, TextShapeUtilDisplayValues>(util, shape).fontSize
}

View File

@@ -0,0 +1,26 @@
import { z } from 'zod'
export const FocusedGeoShapeTypeSchema = z.enum([
'rectangle',
'ellipse',
'triangle',
'diamond',
'hexagon',
'pill',
'cloud',
'x-box',
'check-box',
'heart',
'pentagon',
'octagon',
'star',
'parallelogram-right',
'parallelogram-left',
'trapezoid',
'fat-arrow-right',
'fat-arrow-left',
'fat-arrow-up',
'fat-arrow-down',
])
export type FocusedGeoShapeType = z.infer<typeof FocusedGeoShapeTypeSchema>

View File

@@ -0,0 +1,179 @@
import { z } from 'zod'
import { SimpleShapeIdSchema } from '../types/ids-schema'
import { FocusedColor } from './FocusedColor'
import { FocusedFillSchema } from './FocusedFill'
import { FocusedFontSize } from './FocusedFontSize'
import { FocusedGeoShapeTypeSchema } from './FocusedGeoShapeType'
const FocusedLabel = z.string()
export const FocusedTextAnchorSchema = z.enum([
'bottom-center',
'bottom-left',
'bottom-right',
'center-left',
'center-right',
'center',
'top-center',
'top-left',
'top-right',
])
export type FocusedTextAnchor = z.infer<typeof FocusedTextAnchorSchema>
export const FocusedGeoShape = z.object({
_type: FocusedGeoShapeTypeSchema,
color: FocusedColor,
fill: FocusedFillSchema,
h: z.number(),
note: z.string(),
shapeId: SimpleShapeIdSchema,
text: FocusedLabel.optional(),
textAlign: z.enum(['start', 'middle', 'end']).optional(),
w: z.number(),
x: z.number(),
y: z.number(),
})
export type FocusedGeoShape = z.infer<typeof FocusedGeoShape>
export const FocusedGeoShapePartialSchema = FocusedGeoShape.partial()
export type FocusedGeoShapePartial = z.infer<typeof FocusedGeoShapePartialSchema>
const FocusedLineShape = z.object({
_type: z.literal('line'),
color: FocusedColor,
note: z.string(),
shapeId: SimpleShapeIdSchema,
x1: z.number(),
x2: z.number(),
y1: z.number(),
y2: z.number(),
})
export type FocusedLineShape = z.infer<typeof FocusedLineShape>
const FocusedNoteShape = z.object({
_type: z.literal('note'),
color: FocusedColor,
note: z.string(),
shapeId: SimpleShapeIdSchema,
text: FocusedLabel.optional(),
x: z.number(),
y: z.number(),
})
export type FocusedNoteShape = z.infer<typeof FocusedNoteShape>
const FocusedTextShape = z
.object({
_type: z.literal('text'),
anchor: FocusedTextAnchorSchema,
color: FocusedColor,
fontSize: FocusedFontSize.optional(),
maxWidth: z.number().nullable(),
note: z.string(),
shapeId: SimpleShapeIdSchema,
text: FocusedLabel,
x: z.number(),
y: z.number(),
})
.meta({
title: 'Text Shape',
description:
'A text shape is a shape that contains text. The `anchor` property indicates how the text shape is positioned and aligned. For example, the "top-left" anchor means the text shape\'s x and y coordinates are the top left corner of the text shape, and the text gets left aligned. A shape with the "bottom-center" anchor means the text shape\'s x and y coordinates are the bottom center of the text shape, and the text gets center aligned on the horizontal axis. By default, text shapes auto-size to fit their content. If you provide a `maxWidth`, the text will automatically wrap to the next line if it exceeds that width (there is no need to add manual line breaks for word wrapping).',
})
export type FocusedTextShape = z.infer<typeof FocusedTextShape>
export const FocusedTextShapePartialSchema = FocusedTextShape.partial()
export type FocusedTextShapePartial = z.infer<typeof FocusedTextShapePartialSchema>
const FocusedArrowShape = z.object({
_type: z.literal('arrow'),
color: FocusedColor,
fromId: SimpleShapeIdSchema.nullable(),
note: z.string(),
shapeId: SimpleShapeIdSchema,
text: z.string().optional(),
toId: SimpleShapeIdSchema.nullable(),
x1: z.number(),
x2: z.number(),
y1: z.number(),
y2: z.number(),
bend: z.number().optional(),
})
export type FocusedArrowShape = z.infer<typeof FocusedArrowShape>
const FocusedDrawShape = z
.object({
_type: z.literal('draw'),
color: FocusedColor,
fill: FocusedFillSchema.optional(),
note: z.string(),
shapeId: SimpleShapeIdSchema,
})
.meta({
title: 'Draw Shape',
description:
'A draw shape is a freeform shape that was drawn by the pen tool. IMPORTANT: Do not create draw shapes with the "create" action. To create new draw shapes, the AI must use the pen event because it gives more control.',
})
export type FocusedDrawShape = z.infer<typeof FocusedDrawShape>
const FocusedUnknownShape = z
.object({
_type: z.literal('unknown'),
note: z.string(),
shapeId: SimpleShapeIdSchema,
subType: z.string(),
x: z.number(),
y: z.number(),
})
.meta({
title: 'Unknown Shape',
description:
"A special shape that is not represented by one of the canvas's core shape types. The AI cannot create these shapes, but it *can* interact with them. eg: The AI can move these shapes. The `subType` property contains the internal name of the shape's type.",
})
export type FocusedUnknownShape = z.infer<typeof FocusedUnknownShape>
const FOCUSED_SHAPES = [
FocusedDrawShape,
FocusedGeoShape,
FocusedLineShape,
FocusedTextShape,
FocusedArrowShape,
FocusedNoteShape,
FocusedUnknownShape,
] as const
export const FocusedShapeSchema = z.union(FOCUSED_SHAPES)
export type FocusedShape = z.infer<typeof FocusedShapeSchema>
export type FocusedShapePartial = Partial<FocusedShape>
/**
* Extract all shape type names from the schema
*/
export function getFocusedShapeSchemaNames() {
const typeNames: FocusedShape['_type'][] = []
for (const shapeSchema of FOCUSED_SHAPES) {
const typeField = shapeSchema.shape._type
if (typeField) {
// Handle ZodLiterals (like FocusedDrawShape)
if ('value' in typeField && typeof typeField.value === 'string') {
typeNames.push(typeField.value)
}
// Handle ZodEnums (like FocusedGeoShape)
else if ('options' in typeField && Array.isArray(typeField.options)) {
typeNames.push(...typeField.options)
}
}
}
return typeNames
}

View File

@@ -0,0 +1,6 @@
import { BoxModel } from 'tldraw'
export interface PeripheralShapeCluster {
bounds: BoxModel
numberOfShapes: number
}

View File

@@ -0,0 +1,817 @@
import {
Box,
createShapeId,
Editor,
IndexKey,
reverseRecordsDiff,
TLArrowShape,
TLBindingCreate,
TLDefaultShape,
TLDefaultSizeStyle,
TLDrawShape,
TLGeoShape,
TLGeoShapeGeoStyle,
TLLineShape,
TLNoteShape,
TLShape,
TLShapeId,
TLTextShape,
toRichText,
Vec,
VecLike,
} from 'tldraw'
import { asColor } from './FocusedColor'
import { convertFocusedFillToTldrawFill } from './FocusedFill'
import { convertFocusedFontSizeToTldrawFontSizeAndScale } from './FocusedFontSize'
import { FocusedGeoShapeType } from './FocusedGeoShapeType'
import {
FocusedArrowShape,
FocusedDrawShape,
FocusedGeoShape,
FocusedGeoShapePartial,
FocusedLineShape,
FocusedNoteShape,
FocusedShape,
FocusedShapePartial,
FocusedTextShape,
FocusedTextShapePartial,
FocusedUnknownShape,
} from './FocusedShape'
/**
* Convert a FocusedShape to a shape object to a tldraw shape using defaultShape for fallback values
* @param editor - The tldraw editor instance
* @param focusedShape - The focused shape to convert
* @param defaultShape - The default shape to use for fallback values
* @returns The converted shape and bindings
*/
export function convertFocusedShapeToTldrawShape(
editor: Editor,
focusedShape: FocusedShape,
{ defaultShape }: { defaultShape: Partial<TLShape> }
): { shape: TLShape; bindings?: TLBindingCreate[] } {
switch (focusedShape._type) {
case 'text': {
return convertTextShapeToTldrawShape(editor, focusedShape, { defaultShape })
}
case 'line': {
return convertLineShapeToTldrawShape(editor, focusedShape, { defaultShape })
}
case 'arrow': {
return convertArrowShapeToTldrawShape(editor, focusedShape, { defaultShape })
}
case 'cloud':
case 'rectangle':
case 'triangle':
case 'diamond':
case 'hexagon':
case 'pill':
case 'x-box':
case 'pentagon':
case 'octagon':
case 'star':
case 'parallelogram-right':
case 'parallelogram-left':
case 'trapezoid':
case 'fat-arrow-right':
case 'fat-arrow-left':
case 'fat-arrow-up':
case 'fat-arrow-down':
case 'check-box':
case 'heart':
case 'ellipse': {
return convertGeoShapeToTldrawShape(editor, focusedShape, { defaultShape })
}
case 'note': {
return convertNoteShapeToTldrawShape(editor, focusedShape, { defaultShape })
}
case 'draw': {
return convertDrawShapeToTldrawShape(editor, focusedShape, { defaultShape })
}
case 'unknown': {
return convertUnknownShapeToTldrawShape(editor, focusedShape, { defaultShape })
}
}
}
export function convertSimpleIdToTldrawId(id: string): TLShapeId {
return ('shape:' + id) as TLShapeId
}
export function convertFocusedTypeToTldrawType(
type: FocusedShape['_type']
): TLGeoShapeGeoStyle | TLDefaultShape['type'] | 'unknown' {
if (type in FOCUSED_TO_GEO_TYPES) {
return convertFocusedGeoTypeToTldrawGeoGeoType(
type as FocusedGeoShapeType
) as TLGeoShapeGeoStyle
}
return type as TLDefaultShape['type'] | 'unknown'
}
export function convertFocusedGeoTypeToTldrawGeoGeoType(
type: FocusedGeoShapeType
): TLGeoShapeGeoStyle {
return FOCUSED_TO_GEO_TYPES[type]
}
export const FOCUSED_TO_GEO_TYPES: Record<FocusedGeoShapeType, TLGeoShapeGeoStyle> = {
rectangle: 'rectangle',
ellipse: 'ellipse',
triangle: 'triangle',
diamond: 'diamond',
hexagon: 'hexagon',
pill: 'oval',
cloud: 'cloud',
'x-box': 'x-box',
'check-box': 'check-box',
heart: 'heart',
pentagon: 'pentagon',
octagon: 'octagon',
star: 'star',
'parallelogram-right': 'rhombus',
'parallelogram-left': 'rhombus-2',
trapezoid: 'trapezoid',
'fat-arrow-right': 'arrow-right',
'fat-arrow-left': 'arrow-left',
'fat-arrow-up': 'arrow-up',
'fat-arrow-down': 'arrow-down',
} as const
function convertTextShapeToTldrawShape(
editor: Editor,
focusedShape: FocusedTextShape,
{ defaultShape }: { defaultShape: Partial<TLShape> }
): { shape: TLTextShape } {
const shapeId = convertSimpleIdToTldrawId(focusedShape.shapeId)
const defaultTextShape = defaultShape as TLTextShape
// Determine the base font size and scale - focusedShape takes priority
let textSize: TLDefaultSizeStyle = 's'
let scale = 1
const font = defaultTextShape.props?.font ?? 'draw'
if (focusedShape.fontSize) {
const { textSize: calculatedTextSize, scale: calculatedScale } =
convertFocusedFontSizeToTldrawFontSizeAndScale(editor, focusedShape.fontSize, {
...defaultTextShape.props,
font,
})
textSize = calculatedTextSize
scale = calculatedScale
} else if (defaultTextShape.props?.size) {
textSize = defaultTextShape.props.size
scale = defaultTextShape.props.scale ?? 1
}
// If maxWidth is provided as a number, enable wrapping (autoSize = false)
// Otherwise (undefined or null), preserve existing autoSize behavior
// Check for undefined first to distinguish "not provided" from "explicitly null"
const autoSize =
focusedShape.maxWidth !== undefined && focusedShape.maxWidth !== null
? false
: (defaultTextShape.props?.autoSize ?? true)
let richText
if (focusedShape.text !== undefined) {
richText = toRichText(focusedShape.text)
} else if (defaultTextShape.props?.richText) {
richText = defaultTextShape.props.richText
} else {
richText = toRichText('')
}
let textAlign: TLTextShape['props']['textAlign'] = defaultTextShape.props?.textAlign ?? 'start'
switch (focusedShape.anchor) {
case 'top-left':
case 'bottom-left':
case 'center-left':
textAlign = 'start'
break
case 'top-center':
case 'bottom-center':
case 'center':
textAlign = 'middle'
break
case 'top-right':
case 'bottom-right':
case 'center-right':
textAlign = 'end'
break
}
const unpositionedShape: TLTextShape = {
id: shapeId,
type: 'text',
typeName: 'shape',
x: 0,
y: 0,
rotation: defaultTextShape.rotation ?? 0,
index: defaultTextShape.index ?? editor.getHighestIndexForParent(editor.getCurrentPageId()),
parentId: defaultTextShape.parentId ?? editor.getCurrentPageId(),
isLocked: defaultTextShape.isLocked ?? false,
opacity: defaultTextShape.opacity ?? 1,
props: {
size: textSize,
scale,
richText,
color: asColor(focusedShape.color ?? defaultTextShape.props?.color ?? 'black'),
textAlign,
autoSize,
w:
focusedShape.maxWidth !== undefined && focusedShape.maxWidth !== null
? focusedShape.maxWidth
: (defaultTextShape.props?.w ?? 100),
font,
},
meta: {
note: focusedShape.note ?? defaultTextShape.meta?.note ?? '',
},
}
const unpositionedBounds = getDummyBounds(editor, unpositionedShape)
const position = new Vec(defaultTextShape.x ?? 0, defaultTextShape.y ?? 0)
const x = focusedShape.x ?? defaultTextShape.x ?? 0
const y = focusedShape.y ?? defaultTextShape.y ?? 0
switch (focusedShape.anchor) {
case 'top-left': {
position.x = x
position.y = y
break
}
case 'top-center': {
position.x = x - unpositionedBounds.w / 2
position.y = y
break
}
case 'top-right': {
position.x = x - unpositionedBounds.w
position.y = y
break
}
case 'bottom-left': {
position.x = x
position.y = y - unpositionedBounds.h
break
}
case 'bottom-center': {
position.x = x - unpositionedBounds.w / 2
position.y = y - unpositionedBounds.h
break
}
case 'bottom-right': {
position.x = x - unpositionedBounds.w
position.y = y - unpositionedBounds.h
break
}
case 'center-left': {
position.x = x
position.y = y - unpositionedBounds.h / 2
break
}
case 'center-right': {
position.x = x - unpositionedBounds.w
position.y = y - unpositionedBounds.h / 2
break
}
case 'center': {
position.x = focusedShape.x - unpositionedBounds.w / 2
position.y = focusedShape.y - unpositionedBounds.h / 2
break
}
}
const positionedShape: TLTextShape = {
...unpositionedShape,
x: position.x,
y: position.y,
}
return {
shape: positionedShape,
}
}
function convertLineShapeToTldrawShape(
editor: Editor,
focusedShape: FocusedLineShape,
{ defaultShape }: { defaultShape: Partial<TLShape> }
): { shape: TLShape } {
const shapeId = convertSimpleIdToTldrawId(focusedShape.shapeId)
const defaultLineShape = defaultShape as TLLineShape
const x1 = focusedShape.x1 ?? 0
const y1 = focusedShape.y1 ?? 0
const x2 = focusedShape.x2 ?? 0
const y2 = focusedShape.y2 ?? 0
const minX = Math.min(x1, x2)
const minY = Math.min(y1, y2)
return {
shape: {
id: shapeId,
type: 'line',
typeName: 'shape',
x: minX,
y: minY,
rotation: defaultLineShape.rotation ?? 0,
index: defaultLineShape.index ?? editor.getHighestIndexForParent(editor.getCurrentPageId()),
parentId: defaultLineShape.parentId ?? editor.getCurrentPageId(),
isLocked: defaultLineShape.isLocked ?? false,
opacity: defaultLineShape.opacity ?? 1,
props: {
size: defaultLineShape.props?.size ?? 's',
points: {
a1: {
id: 'a1',
index: 'a1' as IndexKey,
x: x1 - minX,
y: y1 - minY,
},
a2: {
id: 'a2',
index: 'a2' as IndexKey,
x: x2 - minX,
y: y2 - minY,
},
},
color: asColor(focusedShape.color ?? defaultLineShape.props?.color ?? 'black'),
dash: defaultLineShape.props?.dash ?? 'draw',
scale: defaultLineShape.props?.scale ?? 1,
spline: defaultLineShape.props?.spline ?? 'line',
},
meta: {
note: focusedShape.note ?? defaultLineShape.meta?.note ?? '',
},
},
}
}
function convertArrowShapeToTldrawShape(
editor: Editor,
focusedShape: FocusedArrowShape,
{ defaultShape }: { defaultShape: Partial<TLShape> }
): { shape: TLShape; bindings?: TLBindingCreate[] } {
const shapeId = convertSimpleIdToTldrawId(focusedShape.shapeId)
const defaultArrowShape = defaultShape as TLArrowShape
const x1 = focusedShape.x1 ?? defaultArrowShape.props?.start?.x ?? 0
const y1 = focusedShape.y1 ?? defaultArrowShape.props?.start?.y ?? 0
const x2 = focusedShape.x2 ?? defaultArrowShape.props?.end?.x ?? 0
const y2 = focusedShape.y2 ?? defaultArrowShape.props?.end?.y ?? 0
const minX = Math.min(x1, x2)
const minY = Math.min(y1, y2)
// Handle richText properly - focusedShape takes priority
let richText
if (focusedShape.text !== undefined) {
richText = toRichText(focusedShape.text)
} else if (defaultArrowShape.props?.richText) {
richText = defaultArrowShape.props.richText
} else {
richText = toRichText('')
}
const shape = {
id: shapeId,
type: 'arrow' as const,
typeName: 'shape' as const,
x: minX,
y: minY,
rotation: defaultArrowShape.rotation ?? 0,
index: defaultArrowShape.index ?? editor.getHighestIndexForParent(editor.getCurrentPageId()),
parentId: defaultArrowShape.parentId ?? editor.getCurrentPageId(),
isLocked: defaultArrowShape.isLocked ?? false,
opacity: defaultArrowShape.opacity ?? 1,
props: {
arrowheadEnd: defaultArrowShape.props?.arrowheadEnd ?? 'arrow',
arrowheadStart: defaultArrowShape.props?.arrowheadStart ?? 'none',
bend: (focusedShape.bend ?? (defaultArrowShape.props?.bend ?? 0) * -1) * -1,
color: asColor(focusedShape.color ?? defaultArrowShape.props?.color ?? 'black'),
dash: defaultArrowShape.props?.dash ?? 'draw',
elbowMidPoint: defaultArrowShape.props?.elbowMidPoint ?? 0.5,
end: { x: x2 - minX, y: y2 - minY },
fill: defaultArrowShape.props?.fill ?? 'none',
font: defaultArrowShape.props?.font ?? 'draw',
kind: defaultArrowShape.props?.kind ?? 'arc',
labelColor: defaultArrowShape.props?.labelColor ?? 'black',
labelPosition: defaultArrowShape.props?.labelPosition ?? 0.5,
richText,
scale: defaultArrowShape.props?.scale ?? 1,
size: defaultArrowShape.props?.size ?? 's',
start: { x: x1 - minX, y: y1 - minY },
},
meta: {
note: focusedShape.note ?? defaultArrowShape.meta?.note ?? '',
},
}
// Handle arrow bindings if fromId or toId are provided
const bindings: TLBindingCreate[] = []
if (focusedShape.fromId) {
const fromId = convertSimpleIdToTldrawId(focusedShape.fromId)
const startShape = editor.getShape(fromId)
if (startShape) {
const targetPoint = { x: x1, y: y1 }
const finalNormalizedAnchor = calculateArrowBindingAnchor(editor, startShape, targetPoint)
bindings.push({
type: 'arrow',
typeName: 'binding',
fromId: shapeId,
toId: startShape.id,
props: {
normalizedAnchor: finalNormalizedAnchor,
isExact: false,
isPrecise: true,
terminal: 'start',
},
meta: {},
})
}
}
if (focusedShape.toId) {
const toId = convertSimpleIdToTldrawId(focusedShape.toId)
const endShape = editor.getShape(toId)
if (endShape) {
const targetPoint = { x: x2, y: y2 }
const finalNormalizedAnchor = calculateArrowBindingAnchor(editor, endShape, targetPoint)
bindings.push({
type: 'arrow',
typeName: 'binding',
fromId: shapeId,
toId: endShape.id,
props: {
normalizedAnchor: finalNormalizedAnchor,
isExact: false,
isPrecise: true,
terminal: 'end',
},
meta: {},
})
}
}
return {
shape,
bindings: bindings.length > 0 ? bindings : undefined,
}
}
function convertGeoShapeToTldrawShape(
editor: Editor,
focusedShape: FocusedGeoShape,
{ defaultShape }: { defaultShape: Partial<TLShape> }
): { shape: TLShape } {
const shapeId = convertSimpleIdToTldrawId(focusedShape.shapeId)
const shapeType = convertFocusedGeoTypeToTldrawGeoGeoType(focusedShape._type)
const defaultGeoShape = defaultShape as TLGeoShape
// Handle richText properly - focusedShape takes priority
let richText
if (focusedShape.text !== undefined) {
richText = toRichText(focusedShape.text)
} else if (defaultGeoShape.props?.richText) {
richText = defaultGeoShape.props.richText
} else {
richText = toRichText('')
}
// Handle fill properly - focusedShape takes priority
let fill
if (focusedShape.fill !== undefined) {
fill = convertFocusedFillToTldrawFill(focusedShape.fill) ?? 'none'
} else if (defaultGeoShape.props?.fill) {
fill = defaultGeoShape.props.fill
} else {
fill = convertFocusedFillToTldrawFill('none')
}
return {
shape: {
id: shapeId,
type: 'geo',
typeName: 'shape',
x: focusedShape.x ?? defaultGeoShape.x ?? 0,
y: focusedShape.y ?? defaultGeoShape.y ?? 0,
rotation: defaultGeoShape.rotation ?? 0,
index: defaultGeoShape.index ?? editor.getHighestIndexForParent(editor.getCurrentPageId()),
parentId: defaultGeoShape.parentId ?? editor.getCurrentPageId(),
isLocked: defaultGeoShape.isLocked ?? false,
opacity: defaultGeoShape.opacity ?? 1,
props: {
align: focusedShape.textAlign ?? defaultGeoShape.props?.align ?? 'middle',
color: asColor(focusedShape.color ?? defaultGeoShape.props?.color ?? 'black'),
dash: defaultGeoShape.props?.dash ?? 'draw',
fill,
font: defaultGeoShape.props?.font ?? 'draw',
geo: shapeType,
growY: defaultGeoShape.props?.growY ?? 0,
h: focusedShape.h ?? defaultGeoShape.props?.h ?? 100,
labelColor: defaultGeoShape.props?.labelColor ?? 'black',
richText,
scale: defaultGeoShape.props?.scale ?? 1,
size: defaultGeoShape.props?.size ?? 's',
url: defaultGeoShape.props?.url ?? '',
verticalAlign: defaultGeoShape.props?.verticalAlign ?? 'middle',
w: focusedShape.w ?? defaultGeoShape.props?.w ?? 100,
flipX: defaultGeoShape.props?.flipX ?? false,
flipY: defaultGeoShape.props?.flipY ?? false,
},
meta: {
note: focusedShape.note ?? defaultGeoShape.meta?.note ?? '',
},
},
}
}
function convertNoteShapeToTldrawShape(
editor: Editor,
focusedShape: FocusedNoteShape,
{ defaultShape }: { defaultShape: Partial<TLShape> }
): { shape: TLShape } {
const shapeId = convertSimpleIdToTldrawId(focusedShape.shapeId)
const defaultNoteShape = defaultShape as TLNoteShape
// Handle richText properly - focusedShape takes priority
let richText
if (focusedShape.text !== undefined) {
richText = toRichText(focusedShape.text)
} else if (defaultNoteShape.props?.richText) {
richText = defaultNoteShape.props.richText
} else {
richText = toRichText('')
}
return {
shape: {
id: shapeId,
type: 'note',
typeName: 'shape',
x: focusedShape.x ?? defaultNoteShape.x ?? 0,
y: focusedShape.y ?? defaultNoteShape.y ?? 0,
rotation: defaultNoteShape.rotation ?? 0,
index: defaultNoteShape.index ?? editor.getHighestIndexForParent(editor.getCurrentPageId()),
parentId: defaultNoteShape.parentId ?? editor.getCurrentPageId(),
isLocked: defaultNoteShape.isLocked ?? false,
opacity: defaultNoteShape.opacity ?? 1,
props: {
color: asColor(focusedShape.color ?? defaultNoteShape.props?.color ?? 'black'),
richText,
size: defaultNoteShape.props?.size ?? 's',
align: defaultNoteShape.props?.align ?? 'middle',
font: defaultNoteShape.props?.font ?? 'draw',
fontSizeAdjustment: defaultNoteShape.props?.fontSizeAdjustment ?? 1,
growY: defaultNoteShape.props?.growY ?? 0,
labelColor: defaultNoteShape.props?.labelColor ?? 'black',
scale: defaultNoteShape.props?.scale ?? 1,
url: defaultNoteShape.props?.url ?? '',
verticalAlign: defaultNoteShape.props?.verticalAlign ?? 'middle',
textLastEditedBy: defaultNoteShape.props?.textLastEditedBy ?? null,
},
meta: {
note: focusedShape.note ?? defaultNoteShape.meta?.note ?? '',
},
},
}
}
function convertDrawShapeToTldrawShape(
editor: Editor,
focusedShape: FocusedDrawShape,
{ defaultShape }: { defaultShape: Partial<TLShape> }
): { shape: TLShape } {
const shapeId = convertSimpleIdToTldrawId(focusedShape.shapeId)
const defaultDrawShape = defaultShape as TLDrawShape
// Handle fill properly - focusedShape takes priority
let fill
if (focusedShape.fill !== undefined) {
fill = convertFocusedFillToTldrawFill(focusedShape.fill)
} else if (defaultDrawShape.props?.fill) {
fill = defaultDrawShape.props.fill
} else {
fill = convertFocusedFillToTldrawFill('none')
}
return {
shape: {
id: shapeId,
type: 'draw',
typeName: 'shape',
x: defaultDrawShape.x ?? 0,
y: defaultDrawShape.y ?? 0,
rotation: defaultDrawShape.rotation ?? 0,
index: defaultDrawShape.index ?? editor.getHighestIndexForParent(editor.getCurrentPageId()),
parentId: defaultDrawShape.parentId ?? editor.getCurrentPageId(),
isLocked: defaultDrawShape.isLocked ?? false,
opacity: defaultDrawShape.opacity ?? 1,
props: {
...editor.getShapeUtil('draw').getDefaultProps(),
color: asColor(focusedShape.color ?? defaultDrawShape.props?.color ?? 'black'),
fill,
},
meta: {
note: focusedShape.note ?? defaultDrawShape.meta?.note ?? '',
},
},
}
}
function convertUnknownShapeToTldrawShape(
editor: Editor,
focusedShape: FocusedUnknownShape,
{ defaultShape }: { defaultShape: Partial<TLShape> }
): { shape: TLShape } {
const shapeId = convertSimpleIdToTldrawId(focusedShape.shapeId)
return {
shape: {
id: shapeId,
type: defaultShape.type ?? 'geo',
typeName: 'shape',
x: focusedShape.x ?? defaultShape.x ?? 0,
y: focusedShape.y ?? defaultShape.y ?? 0,
rotation: defaultShape.rotation ?? 0,
index: defaultShape.index ?? editor.getHighestIndexForParent(editor.getCurrentPageId()),
parentId: defaultShape.parentId ?? editor.getCurrentPageId(),
isLocked: defaultShape.isLocked ?? false,
opacity: defaultShape.opacity ?? 1,
props: defaultShape.props ?? ({} as any),
meta: {
note: focusedShape.note ?? defaultShape.meta?.note ?? '',
},
},
}
}
/**
* Helper function to calculate the best normalized anchor point for arrow binding
* @param editor - The tldraw editor instance
* @param targetShape - The shape to bind to
* @param targetPoint - The desired point in page space
* @returns The normalized anchor point (0-1 range within shape bounds)
*/
function calculateArrowBindingAnchor(
editor: Editor,
targetShape: TLShape,
targetPoint: VecLike
): VecLike {
const targetShapePageBounds = editor.getShapePageBounds(targetShape)
const targetShapeGeometry = editor.getShapeGeometry(targetShape)
if (!targetShapePageBounds || !targetShapeGeometry) {
return { x: 0.5, y: 0.5 } // Fall back to center
}
// transforms the target shape's geometry to page space for calculations
const pageTransform = editor.getShapePageTransform(targetShape)
const targetShapeGeometryInPageSpace = targetShapeGeometry.transform(pageTransform)
// Step 1: Find the best anchor point on the shape
// If the target point is inside the shape, use it; otherwise use the nearest point on the shape
const anchorPoint = targetShapeGeometryInPageSpace.hitTestPoint(targetPoint, 0, true)
? targetPoint
: targetShapeGeometryInPageSpace.nearestPoint(targetPoint)
// Step 2: Convert anchor point to normalized coordinates (0-1 range within shape bounds)
const normalizedAnchor = {
x: (anchorPoint.x - targetShapePageBounds.x) / targetShapePageBounds.w,
y: (anchorPoint.y - targetShapePageBounds.y) / targetShapePageBounds.h,
}
// Step 3: Clamp normalized coordinates to valid range [0, 1]
const clampedNormalizedAnchor = {
x: Math.max(0.1, Math.min(0.9, normalizedAnchor.x)),
y: Math.max(0.1, Math.min(0.9, normalizedAnchor.y)),
}
// Step 4: Validate that the clamped anchor point is still within the shape geometry. This is necessary because the above logic sometimes fails for some reason?
const clampedAnchorInPageSpace = {
x: targetShapePageBounds.x + clampedNormalizedAnchor.x * targetShapePageBounds.w,
y: targetShapePageBounds.y + clampedNormalizedAnchor.y * targetShapePageBounds.h,
}
const finalNormalizedAnchor = targetShapeGeometryInPageSpace.hitTestPoint(
clampedAnchorInPageSpace,
0,
true
)
? clampedNormalizedAnchor
: { x: 0.5, y: 0.5 } // Fall back to center
return finalNormalizedAnchor
}
/**
* Get the bounds of a shape by temporarily creating it in the editor.
* This is useful when we need to know the bounds before the shape is actually created.
*/
function getDummyBounds(editor: Editor, shape: TLShape): Box {
const bounds = editor.getShapePageBounds(shape)
if (bounds) {
return bounds
}
// Create a dummy shape and get the bounds, then reverse the creation of the dummy shape
let dummyBounds: Box | undefined
const diff = editor.store.extractingChanges(() => {
editor.run(
() => {
const dummyId = createShapeId()
editor.createShape({
...shape,
id: dummyId,
})
dummyBounds = editor.getShapePageBounds(dummyId)
},
{ ignoreShapeLock: false, history: 'ignore' }
)
})
const reverseDiff = reverseRecordsDiff(diff)
editor.store.applyDiff(reverseDiff)
if (!dummyBounds) {
throw new Error('Failed to get bounds for shape')
}
return dummyBounds
}
/**
* Convert a partial FocusedShape to a tldraw shape.
* This is used for streaming shapes that are still being generated.
*
* @param editor - The tldraw editor instance
* @param focusedShape - The partial focused shape to convert
* @param defaultShape - The default shape to use for fallback values
* @param complete - Whether the shape is complete
* @returns The converted shape (or null if essential fields are missing), bindings, and position
*/
export function convertPartialFocusedShapeToTldrawShape(
editor: Editor,
focusedShape: FocusedShapePartial,
{ defaultShape, complete }: { defaultShape: Partial<TLShape>; complete: boolean }
): { shape: TLShape | null; bindings: TLBindingCreate[] | null; position: VecLike | null } {
// For text shapes, require: x, y, text
if (focusedShape._type === 'text') {
const partial = focusedShape as FocusedTextShapePartial
if (partial.x === undefined || partial.y === undefined || partial.text === undefined) {
return { shape: null, bindings: null, position: null }
}
// Fill in minimal required fields, let converter handle the rest with defaults
const fullShape: FocusedTextShape = {
...partial,
_type: 'text',
shapeId: partial.shapeId ?? ('streaming-shape' as any),
note: partial.note ?? '',
anchor: partial.anchor ?? 'top-left',
color: partial.color ?? 'black',
maxWidth: partial.maxWidth ?? null,
} as FocusedTextShape
const result = convertTextShapeToTldrawShape(editor, fullShape, { defaultShape })
return { shape: result.shape, bindings: null, position: { x: partial.x, y: partial.y } }
}
// For geo shapes, require: _type, x, y, w, h
if (focusedShape._type && focusedShape._type in FOCUSED_TO_GEO_TYPES) {
const partial = focusedShape as FocusedGeoShapePartial
if (
partial.x === undefined ||
partial.y === undefined ||
partial.w === undefined ||
partial.h === undefined
) {
return { shape: null, bindings: null, position: null }
}
// Fill in minimal required fields, let converter handle the rest with defaults
const fullShape: FocusedGeoShape = {
...partial,
_type: focusedShape._type as FocusedGeoShape['_type'],
shapeId: partial.shapeId ?? ('streaming-shape' as any),
note: partial.note ?? '',
color: partial.color ?? 'black',
textAlign: partial.textAlign || 'middle',
} as FocusedGeoShape
const result = convertGeoShapeToTldrawShape(editor, fullShape, { defaultShape })
return { shape: result.shape, bindings: null, position: { x: partial.x, y: partial.y } }
}
// For all other shapes, require complete: true
if (!complete) {
return { shape: null, bindings: null, position: null }
}
// If complete, use the full converter
const result = convertFocusedShapeToTldrawShape(editor, focusedShape as FocusedShape, {
defaultShape,
})
const position =
'x' in focusedShape && 'y' in focusedShape
? { x: focusedShape.x as number, y: focusedShape.y as number }
: 'x1' in focusedShape && 'y1' in focusedShape
? { x: focusedShape.x1 as number, y: focusedShape.y1 as number }
: null
return { shape: result.shape, bindings: result.bindings ?? null, position }
}

View File

@@ -0,0 +1,33 @@
import { Editor, TLShape } from 'tldraw'
import { BlurryShape } from './BlurryShape'
import {
convertTldrawIdToSimpleId,
convertTldrawShapeToFocusedType,
} from './convertTldrawShapeToFocusedShape'
import { FocusedShape } from './FocusedShape'
/**
* Convert a tldraw shape to the blurry shape format
*/
export function convertTldrawShapeToBlurryShape(
editor: Editor,
shape: TLShape
): BlurryShape | null {
const bounds = editor.getShapeMaskedPageBounds(shape)
if (!bounds) return null
const util = editor.getShapeUtil(shape)
const text = util.getText(shape)
const shapeType = convertTldrawShapeToFocusedType(shape)
return {
x: Math.round(bounds.x),
y: Math.round(bounds.y),
w: Math.round(bounds.w),
h: Math.round(bounds.h),
type: shapeType as FocusedShape['_type'],
shapeId: convertTldrawIdToSimpleId(shape.id),
text,
}
}

View File

@@ -0,0 +1,315 @@
import {
Box,
createShapeId,
Editor,
isPageId,
reverseRecordsDiff,
TLArrowBinding,
TLArrowShape,
TLDrawShape,
TLGeoShape,
TLGeoShapeGeoStyle,
TLLineShape,
TLNoteShape,
TLShape,
TLShapeId,
TLTextShape,
Vec,
} from 'tldraw'
import { SimpleShapeId } from '../types/ids-schema'
import { convertTldrawFillToFocusedFill } from './FocusedFill'
import { convertTldrawFontSizeAndScaleToFocusedFontSize } from './FocusedFontSize'
import { FocusedGeoShapeType } from './FocusedGeoShapeType'
import {
FocusedArrowShape,
FocusedDrawShape,
FocusedGeoShape,
FocusedLineShape,
FocusedNoteShape,
FocusedShape,
FocusedTextAnchor,
FocusedTextShape,
FocusedUnknownShape,
} from './FocusedShape'
/**
* Convert a tldraw shape to a focused shape
*/
export function convertTldrawShapeToFocusedShape(editor: Editor, shape: TLShape): FocusedShape {
switch (shape.type) {
case 'text':
return convertTextShapeToFocused(editor, shape as TLTextShape)
case 'geo':
return convertGeoShapeToFocused(editor, shape as TLGeoShape)
case 'line':
return convertLineShapeToFocused(editor, shape as TLLineShape)
case 'arrow':
return convertArrowShapeToFocused(editor, shape as TLArrowShape)
case 'note':
return convertNoteShapeToFocused(editor, shape as TLNoteShape)
case 'draw':
return convertDrawShapeToFocused(editor, shape as TLDrawShape)
default:
return convertUnknownShapeToFocused(editor, shape)
}
}
export function convertTldrawShapeToFocusedType(shape: TLShape): FocusedShape['_type'] {
switch (shape.type) {
case 'geo': {
const geoShape = shape as TLGeoShape
return GEO_TO_FOCUSED_TYPES[geoShape.props.geo]
}
case 'text':
case 'line':
case 'arrow':
case 'note':
case 'draw':
return shape.type
default:
return 'unknown'
}
}
const GEO_TO_FOCUSED_TYPES: Record<TLGeoShapeGeoStyle, FocusedGeoShapeType> = {
rectangle: 'rectangle',
ellipse: 'ellipse',
triangle: 'triangle',
diamond: 'diamond',
hexagon: 'hexagon',
oval: 'pill',
cloud: 'cloud',
'x-box': 'x-box',
'check-box': 'check-box',
heart: 'heart',
pentagon: 'pentagon',
octagon: 'octagon',
star: 'star',
rhombus: 'parallelogram-right',
'rhombus-2': 'parallelogram-left',
trapezoid: 'trapezoid',
'arrow-right': 'fat-arrow-right',
'arrow-left': 'fat-arrow-left',
'arrow-up': 'fat-arrow-up',
'arrow-down': 'fat-arrow-down',
} as const
export function convertTldrawIdToSimpleId(id: TLShapeId): SimpleShapeId {
return id.slice(6) as SimpleShapeId
}
function convertDrawShapeToFocused(editor: Editor, shape: TLDrawShape): FocusedDrawShape {
return {
_type: 'draw',
color: shape.props.color,
fill: convertTldrawFillToFocusedFill(shape.props.fill),
note: (shape.meta.note as string) ?? '',
shapeId: convertTldrawIdToSimpleId(shape.id),
}
}
function convertTextShapeToFocused(editor: Editor, shape: TLTextShape): FocusedTextShape {
const util = editor.getShapeUtil(shape)
const text = util.getText(shape) ?? ''
const bounds = getSimpleBounds(editor, shape)
const position = new Vec()
let anchor: FocusedTextAnchor = 'top-left'
switch (shape.props.textAlign) {
case 'middle': {
anchor = 'top-center'
position.x = bounds.center.x
position.y = bounds.top
break
}
case 'end': {
anchor = 'top-right'
position.x = bounds.right
position.y = bounds.top
break
}
case 'start': {
anchor = 'top-left'
position.x = bounds.left
position.y = bounds.top
break
}
}
return {
_type: 'text',
anchor,
color: shape.props.color,
fontSize: convertTldrawFontSizeAndScaleToFocusedFontSize(editor, shape),
maxWidth: shape.props.autoSize ? null : shape.props.w,
note: (shape.meta.note as string) ?? '',
shapeId: convertTldrawIdToSimpleId(shape.id),
text: text,
x: position.x,
y: position.y,
}
}
function convertGeoShapeToFocused(editor: Editor, shape: TLGeoShape): FocusedGeoShape {
const util = editor.getShapeUtil(shape)
const text = util.getText(shape)
const bounds = getSimpleBounds(editor, shape)
const shapeTextAlign = shape.props.align
let newTextAlign: FocusedGeoShape['textAlign']
switch (shapeTextAlign) {
case 'start-legacy':
newTextAlign = 'start'
break
case 'middle-legacy':
newTextAlign = 'middle'
break
case 'end-legacy':
newTextAlign = 'end'
break
default:
newTextAlign = shapeTextAlign
break
}
return {
_type: GEO_TO_FOCUSED_TYPES[shape.props.geo],
color: shape.props.color,
fill: convertTldrawFillToFocusedFill(shape.props.fill),
h: shape.props.h,
note: (shape.meta.note as string) ?? '',
shapeId: convertTldrawIdToSimpleId(shape.id),
text: text ?? '',
textAlign: newTextAlign,
w: shape.props.w,
x: bounds.x,
y: bounds.y,
}
}
function convertLineShapeToFocused(editor: Editor, shape: TLLineShape): FocusedLineShape {
const bounds = getSimpleBounds(editor, shape)
const points = Object.values(shape.props.points).sort((a, b) => a.index.localeCompare(b.index))
return {
_type: 'line',
color: shape.props.color,
note: (shape.meta.note as string) ?? '',
shapeId: convertTldrawIdToSimpleId(shape.id),
x1: points[0].x + bounds.x,
x2: points[1].x + bounds.x,
y1: points[0].y + bounds.y,
y2: points[1].y + bounds.y,
}
}
function convertArrowShapeToFocused(editor: Editor, shape: TLArrowShape): FocusedArrowShape {
const bounds = getSimpleBounds(editor, shape)
const bindings = editor.store.query.records('binding').get()
const arrowBindings = bindings.filter(
(b) => b.type === 'arrow' && b.fromId === shape.id
) as TLArrowBinding[]
const startBinding = arrowBindings.find((b) => b.props.terminal === 'start')
const endBinding = arrowBindings.find((b) => b.props.terminal === 'end')
return {
_type: 'arrow',
bend: shape.props.bend * -1,
color: shape.props.color,
fromId: startBinding ? convertTldrawIdToSimpleId(startBinding.toId) : null,
note: (shape.meta.note as string) ?? '',
shapeId: convertTldrawIdToSimpleId(shape.id),
text: (shape.meta.text as string) ?? '',
toId: endBinding ? convertTldrawIdToSimpleId(endBinding.toId) : null,
x1: shape.props.start.x + bounds.x,
x2: shape.props.end.x + bounds.x,
y1: shape.props.start.y + bounds.y,
y2: shape.props.end.y + bounds.y,
}
}
function convertNoteShapeToFocused(editor: Editor, shape: TLNoteShape): FocusedNoteShape {
const util = editor.getShapeUtil(shape)
const text = util.getText(shape)
const bounds = getSimpleBounds(editor, shape)
return {
_type: 'note',
color: shape.props.color,
note: (shape.meta.note as string) ?? '',
shapeId: convertTldrawIdToSimpleId(shape.id),
text: text ?? '',
x: bounds.x,
y: bounds.y,
}
}
function convertUnknownShapeToFocused(editor: Editor, shape: TLShape): FocusedUnknownShape {
const bounds = getSimpleBounds(editor, shape)
return {
_type: 'unknown',
note: (shape.meta.note as string) ?? '',
shapeId: convertTldrawIdToSimpleId(shape.id),
subType: shape.type,
x: bounds.x,
y: bounds.y,
}
}
function getSimpleBounds(editor: Editor, shape: TLShape): Box {
// Compute page position from the shape record's own x/y, not the editor's cached bounds.
// This is critical for diffing historical shape records where the editor's
// current state differs from the shape record being converted.
const pagePoint = getShapePagePoint(editor, shape)
// Try to get dimensions from shape props first
const props = shape.props as { w?: number; h?: number }
if (props.w !== undefined && props.h !== undefined) {
return new Box(pagePoint.x, pagePoint.y, props.w, props.h)
}
// Fall back to editor bounds for dimensions only (position comes from shape record)
const bounds = editor.getShapePageBounds(shape)
if (bounds) {
return new Box(pagePoint.x, pagePoint.y, bounds.w, bounds.h)
}
// Create a mock shape and get the bounds, then reverse the creation of the mock shape
let mockBounds: Box | undefined
const diff = editor.store.extractingChanges(() => {
editor.run(
() => {
const mockId = createShapeId()
editor.createShape({
...shape,
id: mockId,
})
mockBounds = editor.getShapePageBounds(mockId)
},
{ ignoreShapeLock: false, history: 'ignore' }
)
})
const reverseDiff = reverseRecordsDiff(diff)
editor.store.applyDiff(reverseDiff)
if (!mockBounds) {
throw new Error('Failed to get bounds for shape')
}
return new Box(pagePoint.x, pagePoint.y, mockBounds.w, mockBounds.h)
}
/**
* Get the page-space position of a shape from its record's x/y values.
* For shapes at the root level, this is just shape.x/y.
* For shapes inside frames/groups, we transform through the parent's page transform.
*/
function getShapePagePoint(editor: Editor, shape: TLShape): Vec {
// If the shape is at the root level (parent is a page), use x/y directly
if (isPageId(shape.parentId)) {
return new Vec(shape.x, shape.y)
}
// For shapes inside parents, get the parent's page transform and apply it
// Note: We use the editor's current parent transform, which is correct as long
// as the parent itself hasn't moved in the same diff (uncommon case)
const parentTransform = editor.getShapePageTransform(shape.parentId)
return parentTransform.applyToPoint(new Vec(shape.x, shape.y))
}

View File

@@ -0,0 +1,54 @@
import { Box, Editor, TLShape } from 'tldraw'
import { PeripheralShapeCluster } from './PeripheralShapesCluster'
export function convertTldrawShapesToPeripheralShapes(
editor: Editor,
shapes: TLShape[],
{ padding = 0 }: { padding?: number } = {}
): PeripheralShapeCluster[] {
if (shapes.length === 0) return []
const groups: { shapes: TLShape[]; bounds: Box; numberOfShapes: number }[] = []
const expandedBounds = shapes.map((shape) => {
return {
shape,
bounds: editor.getShapeMaskedPageBounds(shape)!.clone().expandBy(padding),
}
})
for (let i = 0; i < expandedBounds.length; i++) {
const item = expandedBounds[i]
if (i === 0) {
groups[0] = {
shapes: [item.shape],
bounds: item.bounds,
numberOfShapes: 1,
}
continue
}
let didLand = false
for (const group of groups) {
if (group.bounds.includes(item.bounds)) {
group.shapes.push(item.shape)
group.bounds.expand(item.bounds)
group.numberOfShapes++
didLand = true
break
}
}
if (!didLand) {
groups.push({
shapes: [item.shape],
bounds: item.bounds,
numberOfShapes: 1,
})
}
}
return groups.map((group) => {
const shrunkBounds = group.bounds.clone().expandBy(-padding)
return {
bounds: shrunkBounds,
numberOfShapes: group.numberOfShapes,
}
})
}