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:
11
templates/agent/shared/format/BlurryShape.ts
Normal file
11
templates/agent/shared/format/BlurryShape.ts
Normal 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
|
||||
}
|
||||
36
templates/agent/shared/format/FocusedColor.ts
Normal file
36
templates/agent/shared/format/FocusedColor.ts
Normal 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'
|
||||
}
|
||||
31
templates/agent/shared/format/FocusedFill.ts
Normal file
31
templates/agent/shared/format/FocusedFill.ts
Normal 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]
|
||||
}
|
||||
86
templates/agent/shared/format/FocusedFontSize.ts
Normal file
86
templates/agent/shared/format/FocusedFontSize.ts
Normal 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
|
||||
}
|
||||
26
templates/agent/shared/format/FocusedGeoShapeType.ts
Normal file
26
templates/agent/shared/format/FocusedGeoShapeType.ts
Normal 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>
|
||||
179
templates/agent/shared/format/FocusedShape.ts
Normal file
179
templates/agent/shared/format/FocusedShape.ts
Normal 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
|
||||
}
|
||||
6
templates/agent/shared/format/PeripheralShapesCluster.ts
Normal file
6
templates/agent/shared/format/PeripheralShapesCluster.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { BoxModel } from 'tldraw'
|
||||
|
||||
export interface PeripheralShapeCluster {
|
||||
bounds: BoxModel
|
||||
numberOfShapes: number
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user