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:
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user