Tldraw/packages/file-format/src/lib/buildFromV1Document.ts

1170 wiersze
29 KiB
TypeScript

import {
App,
AssetRecordType,
MAX_SHAPES_PER_PAGE,
PageRecordType,
TLAlignType,
TLArrowShape,
TLArrowTerminal,
TLArrowUtil,
TLArrowheadType,
TLAsset,
TLAssetId,
TLColorType,
TLDashType,
TLDrawShape,
TLFontType,
TLGeoShape,
TLImageShape,
TLNoteShape,
TLPageId,
TLShapeId,
TLShapePartial,
TLSizeType,
TLTextShape,
TLVideoShape,
Vec2dModel,
} from '@tldraw/editor'
import { Vec2d, clamp } from '@tldraw/primitives'
const TLDRAW_V1_VERSION = 15.5
/** @internal */
export function buildFromV1Document(app: App, document: LegacyTldrawDocument) {
app.batch(() => {
document = migrate(document, TLDRAW_V1_VERSION)
// Cancel any interactions / states
app.cancel().cancel().cancel().cancel()
const firstPageId = app.pages[0].id
// Set the current page to the first page
app.setCurrentPageId(firstPageId)
// Delete all pages except first page
for (const page of app.pages.slice(1)) {
app.deletePage(page.id)
}
// Delete all of the shapes on the current page
app.selectAll()
app.deleteShapes()
// Create assets
const v1AssetIdsToV2AssetIds = new Map<string, TLAssetId>()
Object.values(document.assets ?? {}).forEach((v1Asset) => {
switch (v1Asset.type) {
case TDAssetType.Image: {
const assetId: TLAssetId = AssetRecordType.createId()
v1AssetIdsToV2AssetIds.set(v1Asset.id, assetId)
const placeholderAsset: TLAsset = {
id: assetId,
typeName: 'asset',
type: 'image',
props: {
w: coerceDimension(v1Asset.size[0]),
h: coerceDimension(v1Asset.size[1]),
name: v1Asset.fileName ?? 'Untitled',
isAnimated: false,
mimeType: null,
src: v1Asset.src,
},
}
app.createAssets([placeholderAsset])
tryMigrateAsset(app, placeholderAsset)
break
}
case TDAssetType.Video:
{
const assetId: TLAssetId = AssetRecordType.createId()
v1AssetIdsToV2AssetIds.set(v1Asset.id, assetId)
app.createAssets([
{
id: assetId,
typeName: 'asset',
type: 'video',
props: {
w: coerceDimension(v1Asset.size[0]),
h: coerceDimension(v1Asset.size[1]),
name: v1Asset.fileName ?? 'Untitled',
isAnimated: true,
mimeType: null,
src: v1Asset.src,
},
},
])
}
break
}
})
// Create pages
const v1PageIdsToV2PageIds = new Map<string, TLPageId>()
Object.values(document.pages ?? {})
.sort((a, b) => ((a.childIndex ?? 1) < (b.childIndex ?? 1) ? -1 : 1))
.forEach((v1Page, i) => {
if (i === 0) {
v1PageIdsToV2PageIds.set(v1Page.id, app.currentPageId)
} else {
const pageId = PageRecordType.createId()
v1PageIdsToV2PageIds.set(v1Page.id, pageId)
app.createPage(v1Page.name ?? 'Page', pageId)
}
})
Object.values(document.pages ?? {})
.sort((a, b) => ((a.childIndex ?? 1) < (b.childIndex ?? 1) ? -1 : 1))
.forEach((v1Page) => {
// Set the current page id to the current page
app.setCurrentPageId(v1PageIdsToV2PageIds.get(v1Page.id)!)
const v1ShapeIdsToV2ShapeIds = new Map<string, TLShapeId>()
const v1GroupShapeIdsToV1ChildIds = new Map<string, string[]>()
const v1Shapes = Object.values(v1Page.shapes ?? {})
.sort((a, b) => (a.childIndex < b.childIndex ? -1 : 1))
.slice(0, MAX_SHAPES_PER_PAGE)
// Groups only
v1Shapes.forEach((v1Shape) => {
if (v1Shape.type !== TDShapeType.Group) return
const shapeId = app.createShapeId()
v1ShapeIdsToV2ShapeIds.set(v1Shape.id, shapeId)
v1GroupShapeIdsToV1ChildIds.set(v1Shape.id, [])
})
function decideNotToCreateShape(v1Shape: TDShape) {
v1ShapeIdsToV2ShapeIds.delete(v1Shape.id)
const v1GroupParent = v1GroupShapeIdsToV1ChildIds.has(v1Shape.parentId)
if (v1GroupParent) {
const ids = v1GroupShapeIdsToV1ChildIds
.get(v1Shape.parentId)!
.filter((id) => id !== v1Shape.id)
v1GroupShapeIdsToV1ChildIds.set(v1Shape.parentId, ids)
}
}
// Non-groups only
v1Shapes.forEach((v1Shape) => {
// Skip groups for now, we'll create groups via the app's API
if (v1Shape.type === TDShapeType.Group) {
return
}
const shapeId = app.createShapeId()
v1ShapeIdsToV2ShapeIds.set(v1Shape.id, shapeId)
if (v1Shape.parentId !== v1Page.id) {
// If the parent is a group, then add the shape to the group's children
if (v1GroupShapeIdsToV1ChildIds.has(v1Shape.parentId)) {
v1GroupShapeIdsToV1ChildIds.get(v1Shape.parentId)!.push(v1Shape.id)
} else {
console.warn('parent does not exist', v1Shape)
}
}
// First, try to find the shape's parent among the existing groups
const parentId = v1PageIdsToV2PageIds.get(v1Page.id)!
const inCommon = {
id: shapeId,
parentId,
x: coerceNumber(v1Shape.point[0]),
y: coerceNumber(v1Shape.point[1]),
rotation: 0,
isLocked: !!v1Shape.isLocked,
}
switch (v1Shape.type) {
case TDShapeType.Sticky: {
const partial: TLShapePartial<TLNoteShape> = {
...inCommon,
type: 'note',
props: {
text: v1Shape.text ?? '',
color: getV2Color(v1Shape.style.color),
size: getV2Size(v1Shape.style.size),
font: getV2Font(v1Shape.style.font),
align: getV2Align(v1Shape.style.textAlign),
},
}
app.createShapes([partial])
break
}
case TDShapeType.Rectangle: {
const partial: TLShapePartial<TLGeoShape> = {
...inCommon,
type: 'geo',
props: {
geo: 'rectangle',
w: coerceDimension(v1Shape.size[0]),
h: coerceDimension(v1Shape.size[1]),
text: v1Shape.label ?? '',
fill: getV2Fill(v1Shape.style.isFilled, v1Shape.style.color),
labelColor: getV2Color(v1Shape.style.color),
color: getV2Color(v1Shape.style.color),
size: getV2Size(v1Shape.style.size),
font: getV2Font(v1Shape.style.font),
dash: getV2Dash(v1Shape.style.dash),
align: 'middle',
},
}
app.createShapes([partial])
const pageBoundsBeforeLabel = app.getPageBoundsById(inCommon.id)!
app.updateShapes([
{
id: inCommon.id,
type: 'geo',
props: {
text: v1Shape.label ?? '',
},
},
])
if (pageBoundsBeforeLabel.width === pageBoundsBeforeLabel.height) {
const shape = app.getShapeById<TLGeoShape>(inCommon.id)!
const { growY } = shape.props
const w = coerceDimension(shape.props.w)
const h = coerceDimension(shape.props.h)
const newW = w + growY / 2
const newH = h + growY / 2
app.updateShapes([
{
id: inCommon.id,
type: 'geo',
x: coerceNumber(shape.x) - (newW - w) / 2,
y: coerceNumber(shape.y) - (newH - h) / 2,
props: {
w: newW,
h: newH,
},
},
])
}
break
}
case TDShapeType.Triangle: {
const partial: TLShapePartial<TLGeoShape> = {
...inCommon,
type: 'geo',
props: {
geo: 'triangle',
w: coerceDimension(v1Shape.size[0]),
h: coerceDimension(v1Shape.size[1]),
fill: getV2Fill(v1Shape.style.isFilled, v1Shape.style.color),
labelColor: getV2Color(v1Shape.style.color),
color: getV2Color(v1Shape.style.color),
size: getV2Size(v1Shape.style.size),
font: getV2Font(v1Shape.style.font),
dash: getV2Dash(v1Shape.style.dash),
align: 'middle',
},
}
app.createShapes([partial])
const pageBoundsBeforeLabel = app.getPageBoundsById(inCommon.id)!
app.updateShapes([
{
id: inCommon.id,
type: 'geo',
props: {
text: v1Shape.label ?? '',
},
},
])
if (pageBoundsBeforeLabel.width === pageBoundsBeforeLabel.height) {
const shape = app.getShapeById<TLGeoShape>(inCommon.id)!
const { growY } = shape.props
const w = coerceDimension(shape.props.w)
const h = coerceDimension(shape.props.h)
const newW = w + growY / 2
const newH = h + growY / 2
app.updateShapes([
{
id: inCommon.id,
type: 'geo',
x: coerceNumber(shape.x) - (newW - w) / 2,
y: coerceNumber(shape.y) - (newH - h) / 2,
props: {
w: newW,
h: newH,
},
},
])
}
break
}
case TDShapeType.Ellipse: {
const partial: TLShapePartial<TLGeoShape> = {
...inCommon,
type: 'geo',
props: {
geo: 'ellipse',
w: coerceDimension(v1Shape.radius[0]) * 2,
h: coerceDimension(v1Shape.radius[1]) * 2,
fill: getV2Fill(v1Shape.style.isFilled, v1Shape.style.color),
labelColor: getV2Color(v1Shape.style.color),
color: getV2Color(v1Shape.style.color),
size: getV2Size(v1Shape.style.size),
font: getV2Font(v1Shape.style.font),
dash: getV2Dash(v1Shape.style.dash),
align: 'middle',
},
}
app.createShapes([partial])
const pageBoundsBeforeLabel = app.getPageBoundsById(inCommon.id)!
app.updateShapes([
{
id: inCommon.id,
type: 'geo',
props: {
text: v1Shape.label ?? '',
},
},
])
if (pageBoundsBeforeLabel.width === pageBoundsBeforeLabel.height) {
const shape = app.getShapeById<TLGeoShape>(inCommon.id)!
const { growY } = shape.props
const w = coerceDimension(shape.props.w)
const h = coerceDimension(shape.props.h)
const newW = w + growY / 2
const newH = h + growY / 2
app.updateShapes([
{
id: inCommon.id,
type: 'geo',
x: coerceNumber(shape.x) - (newW - w) / 2,
y: coerceNumber(shape.y) - (newH - h) / 2,
props: {
w: newW,
h: newH,
},
},
])
}
break
}
case TDShapeType.Draw: {
if (v1Shape.points.length === 0) {
decideNotToCreateShape(v1Shape)
break
}
const partial: TLShapePartial<TLDrawShape> = {
...inCommon,
type: 'draw',
props: {
fill: getV2Fill(v1Shape.style.isFilled, v1Shape.style.color),
color: getV2Color(v1Shape.style.color),
size: getV2Size(v1Shape.style.size),
dash: getV2Dash(v1Shape.style.dash),
isPen: false,
isComplete: v1Shape.isComplete,
segments: [{ type: 'free', points: v1Shape.points.map(getV2Point) }],
},
}
app.createShapes([partial])
break
}
case TDShapeType.Arrow: {
const v1Bend = coerceNumber(v1Shape.bend)
const v1Start = getV2Point(v1Shape.handles.start.point)
const v1End = getV2Point(v1Shape.handles.end.point)
const dist = Vec2d.Dist(v1Start, v1End)
const v2Bend = (dist * -v1Bend) / 2
// Could also be a line... but we'll use it as an arrow anyway
const partial: TLShapePartial<TLArrowShape> = {
...inCommon,
type: 'arrow',
props: {
text: v1Shape.label ?? '',
color: getV2Color(v1Shape.style.color),
labelColor: getV2Color(v1Shape.style.color),
size: getV2Size(v1Shape.style.size),
font: getV2Font(v1Shape.style.font),
dash: getV2Dash(v1Shape.style.dash),
arrowheadStart: getV2Arrowhead(v1Shape.decorations?.start),
arrowheadEnd: getV2Arrowhead(v1Shape.decorations?.end),
start: {
type: 'point',
x: coerceNumber(v1Shape.handles.start.point[0]),
y: coerceNumber(v1Shape.handles.start.point[1]),
},
end: {
type: 'point',
x: coerceNumber(v1Shape.handles.end.point[0]),
y: coerceNumber(v1Shape.handles.end.point[1]),
},
bend: v2Bend,
},
}
app.createShapes([partial])
break
}
case TDShapeType.Text: {
const partial: TLShapePartial<TLTextShape> = {
...inCommon,
type: 'text',
props: {
text: v1Shape.text ?? ' ',
color: getV2Color(v1Shape.style.color),
size: getV2TextSize(v1Shape.style.size),
font: getV2Font(v1Shape.style.font),
align: getV2Align(v1Shape.style.textAlign),
scale: v1Shape.style.scale ?? 1,
},
}
app.createShapes([partial])
break
}
case TDShapeType.Image: {
const assetId = v1AssetIdsToV2AssetIds.get(v1Shape.assetId)
if (!assetId) {
console.warn('Could not find asset id', v1Shape.assetId)
return
}
const partial: TLShapePartial<TLImageShape> = {
...inCommon,
type: 'image',
props: {
w: coerceDimension(v1Shape.size[0]),
h: coerceDimension(v1Shape.size[1]),
assetId,
},
}
app.createShapes([partial])
break
}
case TDShapeType.Video: {
const assetId = v1AssetIdsToV2AssetIds.get(v1Shape.assetId)
if (!assetId) {
console.warn('Could not find asset id', v1Shape.assetId)
return
}
const partial: TLShapePartial<TLVideoShape> = {
...inCommon,
type: 'video',
props: {
w: coerceDimension(v1Shape.size[0]),
h: coerceDimension(v1Shape.size[1]),
assetId,
},
}
app.createShapes([partial])
break
}
}
const rotation = coerceNumber(v1Shape.rotation)
if (rotation !== 0) {
app.select(shapeId)
app.rotateShapesBy([shapeId], rotation)
}
})
// Create groups
v1GroupShapeIdsToV1ChildIds.forEach((v1ChildIds, v1GroupId) => {
const v2ChildShapeIds = v1ChildIds.map((id) => v1ShapeIdsToV2ShapeIds.get(id)!)
const v2GroupId = v1ShapeIdsToV2ShapeIds.get(v1GroupId)!
app.groupShapes(v2ChildShapeIds, v2GroupId)
const v1Group = v1Page.shapes[v1GroupId]
const rotation = coerceNumber(v1Group.rotation)
if (rotation !== 0) {
app.select(v2GroupId)
app.rotateShapesBy([v2GroupId], rotation)
}
})
// Bind arrows to shapes
v1Shapes.forEach((v1Shape) => {
if (v1Shape.type !== TDShapeType.Arrow) {
return
}
const v2ShapeId = v1ShapeIdsToV2ShapeIds.get(v1Shape.id)!
const util = app.getShapeUtil(TLArrowUtil)
// dumb but necessary
app.inputs.ctrlKey = false
for (const handleId of ['start', 'end'] as const) {
const bindingId = v1Shape.handles[handleId].bindingId
if (bindingId) {
const binding = v1Page.bindings[bindingId]
if (!binding) {
// arrow has a reference to a binding that no longer exists
continue
}
const targetId = v1ShapeIdsToV2ShapeIds.get(binding.toId)!
const targetShape = app.getShapeById(targetId)!
// (unexpected) We didn't create the target shape
if (!targetShape) continue
if (targetId) {
const bounds = app.getPageBoundsById(targetId)!
const v2ShapeFresh = app.getShapeById<TLArrowShape>(v2ShapeId)!
const nx = clamp((coerceNumber(binding.point[0]) + 0.5) / 2, 0.2, 0.8)
const ny = clamp((coerceNumber(binding.point[1]) + 0.5) / 2, 0.2, 0.8)
const point = app.getPointInShapeSpace(v2ShapeFresh, {
x: bounds.minX + bounds.width * nx,
y: bounds.minY + bounds.height * ny,
})
const handles = util.handles(v2ShapeFresh)
const change = util.onHandleChange!(v2ShapeFresh, {
handle: {
...handles.find((h) => h.id === handleId)!,
x: point.x,
y: point.y,
},
isPrecise: point.x !== 0.5 || point.y !== 0.5,
})
if (change) {
if (change.props?.[handleId]) {
const terminal = change.props?.[handleId] as TLArrowTerminal
if (terminal.type === 'binding') {
terminal.isExact = binding.distance === 0
if (terminal.boundShapeId !== targetId) {
console.warn('Hit the wrong shape!')
terminal.boundShapeId = targetId
terminal.normalizedAnchor = { x: 0.5, y: 0.5 }
}
}
}
app.updateShapes([change])
}
}
}
}
})
})
// Set the current page to the first page again
app.setCurrentPageId(firstPageId)
app.history.clear()
app.selectNone()
app.updateViewportScreenBounds()
const bounds = app.allShapesCommonBounds
if (bounds) {
app.zoomToBounds(bounds.minX, bounds.minY, bounds.width, bounds.height, 1)
}
})
}
function coerceNumber(n: unknown): number {
if (typeof n !== 'number') return 0
if (Number.isNaN(n)) return 0
if (!Number.isFinite(n)) return 0
return n
}
function coerceDimension(d: unknown): number {
const n = coerceNumber(d)
if (n <= 0) return 1
return n
}
/**
* We want to move assets over to our new S3 bucket & extract any relevant metadata. That process is
* async though, where the rest of our migration is synchronous.
*
* We'll write placeholder assets to the app using the old asset URLs, then kick off a process async
* to try and download the real assets, extract the metadata, and upload them to our new bucket.
* It's not a big deal if this fails though.
*/
async function tryMigrateAsset(app: App, placeholderAsset: TLAsset) {
try {
if (placeholderAsset.type === 'bookmark' || !placeholderAsset.props.src) return
const response = await fetch(placeholderAsset.props.src)
if (!response.ok) return
const file = new File([await response.blob()], placeholderAsset.props.name, {
type: response.headers.get('content-type') ?? placeholderAsset.props.mimeType ?? undefined,
})
const newAsset = await app.onCreateAssetFromFile(file)
if (newAsset.type === 'bookmark') return
app.updateAssets([
{
id: placeholderAsset.id,
type: placeholderAsset.type,
props: {
...newAsset.props,
name: placeholderAsset.props.name,
},
},
])
} catch (err) {
// not a big deal, we'll just keep the placeholder asset
}
}
function migrate(document: LegacyTldrawDocument, newVersion: number): LegacyTldrawDocument {
const { version = 0 } = document
if (!document.assets) {
document.assets = {}
}
// Remove unused assets when loading a document
const assetIdsInUse = new Set<string>()
Object.values(document.pages).forEach((page) =>
Object.values(page.shapes).forEach((shape) => {
const { parentId, children, assetId } = shape
if (assetId) {
assetIdsInUse.add(assetId)
}
// Fix missing parent bug
if (parentId !== page.id && !page.shapes[parentId]) {
console.warn('Encountered a shape with a missing parent!')
shape.parentId = page.id
}
if (shape.type === TDShapeType.Group && children) {
children.forEach((childId) => {
if (!page.shapes[childId]) {
console.warn('Encountered a parent with a missing child!', shape.id, childId)
children?.splice(children.indexOf(childId), 1)
}
})
// TODO: Remove the shape if it has no children
}
})
)
Object.keys(document.assets).forEach((assetId) => {
if (!assetIdsInUse.has(assetId)) {
delete document.assets[assetId]
}
})
if (version !== newVersion) {
if (version < 14) {
Object.values(document.pages).forEach((page) => {
Object.values(page.shapes)
.filter((shape) => shape.type === TDShapeType.Text)
.forEach((shape) => {
if ((shape as TextShape).style.font === undefined) {
;(shape as TextShape).style.font === FontStyle.Script
}
})
})
}
// Lowercase styles, move binding meta to binding
if (version <= 13) {
Object.values(document.pages).forEach((page) => {
Object.values(page.bindings).forEach((binding) => {
Object.assign(binding, (binding as any).meta)
})
Object.values(page.shapes).forEach((shape) => {
Object.entries(shape.style).forEach(([id, style]) => {
if (typeof style === 'string') {
// @ts-ignore
shape.style[id] = style.toLowerCase()
}
})
if (shape.type === TDShapeType.Arrow) {
if (shape.decorations) {
Object.entries(shape.decorations).forEach(([id, decoration]) => {
if ((decoration as unknown) === 'Arrow') {
shape.decorations = {
...shape.decorations,
[id]: Decoration.Arrow,
}
}
})
}
}
})
})
}
// Add document name and file system handle
if (version <= 13.1 && document.name == null) {
document.name = 'New Document'
}
if (version < 15 && document.assets == null) {
document.assets = {}
}
Object.values(document.pages).forEach((page) => {
Object.values(page.shapes).forEach((shape) => {
if (version < 15.2) {
if (
(shape.type === TDShapeType.Image || shape.type === TDShapeType.Video) &&
shape.style.isFilled == null
) {
shape.style.isFilled = true
}
}
if (version < 15.3) {
if (
shape.type === TDShapeType.Rectangle ||
shape.type === TDShapeType.Triangle ||
shape.type === TDShapeType.Ellipse ||
shape.type === TDShapeType.Arrow
) {
if ('text' in shape && typeof shape.text === 'string') {
shape.label = shape.text
}
if (!shape.label) {
shape.label = ''
}
if (!shape.labelPoint) {
shape.labelPoint = [0.5, 0.5]
}
}
}
})
})
}
// Cleanup
Object.values(document.pageStates).forEach((pageState) => {
pageState.selectedIds = pageState.selectedIds.filter((id) => {
return document.pages[pageState.id].shapes[id] !== undefined
})
pageState.bindingId = undefined
pageState.editingId = undefined
pageState.hoveredId = undefined
pageState.pointedId = undefined
})
document.version = newVersion
return document
}
/* -------------------- TLV1 Types -------------------- */
interface TLV1Handle {
id: string
index: number
point: number[]
}
interface TLV1Binding {
id: string
toId: string
fromId: string
}
interface TLV1Shape {
id: string
type: string
parentId: string
childIndex: number
name: string
point: number[]
assetId?: string
rotation?: number
children?: string[]
handles?: Record<string, TLV1Handle>
isGhost?: boolean
isHidden?: boolean
isLocked?: boolean
isGenerated?: boolean
isAspectRatioLocked?: boolean
}
enum TDShapeType {
Sticky = 'sticky',
Ellipse = 'ellipse',
Rectangle = 'rectangle',
Triangle = 'triangle',
Draw = 'draw',
Arrow = 'arrow',
Text = 'text',
Group = 'group',
Image = 'image',
Video = 'video',
}
enum ColorStyle {
White = 'white',
LightGray = 'lightGray',
Gray = 'gray',
Black = 'black',
Green = 'green',
Cyan = 'cyan',
Blue = 'blue',
Indigo = 'indigo',
Violet = 'violet',
Red = 'red',
Orange = 'orange',
Yellow = 'yellow',
}
enum SizeStyle {
Small = 'small',
Medium = 'medium',
Large = 'large',
}
enum DashStyle {
Draw = 'draw',
Solid = 'solid',
Dashed = 'dashed',
Dotted = 'dotted',
}
enum AlignStyle {
Start = 'start',
Middle = 'middle',
End = 'end',
Justify = 'justify',
}
enum FontStyle {
Script = 'script',
Sans = 'sans',
Serif = 'serif',
Mono = 'mono',
}
type ShapeStyles = {
color: ColorStyle
size: SizeStyle
dash: DashStyle
font?: FontStyle
textAlign?: AlignStyle
isFilled?: boolean
scale?: number
}
interface TDBaseShape extends TLV1Shape {
style: ShapeStyles
type: TDShapeType
label?: string
handles?: Record<string, TDHandle>
}
interface DrawShape extends TDBaseShape {
type: TDShapeType.Draw
points: number[][]
isComplete: boolean
}
// The extended handle (used for arrows)
interface TDHandle extends TLV1Handle {
canBind?: boolean
bindingId?: string
}
interface RectangleShape extends TDBaseShape {
type: TDShapeType.Rectangle
size: number[]
label?: string
labelPoint?: number[]
}
interface EllipseShape extends TDBaseShape {
type: TDShapeType.Ellipse
radius: number[]
label?: string
labelPoint?: number[]
}
interface TriangleShape extends TDBaseShape {
type: TDShapeType.Triangle
size: number[]
label?: string
labelPoint?: number[]
}
enum Decoration {
Arrow = 'arrow',
}
// The shape created with the arrow tool
interface ArrowShape extends TDBaseShape {
type: TDShapeType.Arrow
bend: number
handles: {
start: TDHandle
bend: TDHandle
end: TDHandle
}
decorations?: {
start?: Decoration
end?: Decoration
middle?: Decoration
}
label?: string
labelPoint?: number[]
}
interface ArrowBinding extends TLV1Binding {
handleId: keyof ArrowShape['handles']
distance: number
point: number[]
}
type TDBinding = ArrowBinding
interface ImageShape extends TDBaseShape {
type: TDShapeType.Image
size: number[]
assetId: string
}
interface VideoShape extends TDBaseShape {
type: TDShapeType.Video
size: number[]
assetId: string
isPlaying: boolean
currentTime: number
}
// The shape created by the text tool
interface TextShape extends TDBaseShape {
type: TDShapeType.Text
text: string
}
// The shape created by the sticky tool
interface StickyShape extends TDBaseShape {
type: TDShapeType.Sticky
size: number[]
text: string
}
// The shape created when multiple shapes are grouped
interface GroupShape extends TDBaseShape {
type: TDShapeType.Group
size: number[]
children: string[]
}
type TDShape =
| RectangleShape
| EllipseShape
| TriangleShape
| DrawShape
| ArrowShape
| TextShape
| GroupShape
| StickyShape
| ImageShape
| VideoShape
type TDPage = {
id: string
name?: string
childIndex?: number
shapes: Record<string, TDShape>
bindings: Record<string, TDBinding>
}
interface TLV1Bounds {
minX: number
minY: number
maxX: number
maxY: number
width: number
height: number
rotation?: number
}
interface TLV1PageState {
id: string
selectedIds: string[]
camera: {
point: number[]
zoom: number
}
brush?: TLV1Bounds | null
pointedId?: string | null
hoveredId?: string | null
editingId?: string | null
bindingId?: string | null
}
enum TDAssetType {
Image = 'image',
Video = 'video',
}
interface TDImageAsset extends TLV1Asset {
type: TDAssetType.Image
fileName: string
src: string
size: number[]
}
interface TDVideoAsset extends TLV1Asset {
type: TDAssetType.Video
fileName: string
src: string
size: number[]
}
interface TLV1Asset {
id: string
type: string
}
type TDAsset = TDImageAsset | TDVideoAsset
type TDAssets = Record<string, TDAsset>
/** @internal */
export interface LegacyTldrawDocument {
id: string
name: string
version: number
pages: Record<string, TDPage>
pageStates: Record<string, TLV1PageState>
assets: TDAssets
}
/* ------------------ Translations ------------------ */
const v1ColorsToV2Colors: Record<ColorStyle, TLColorType> = {
[ColorStyle.White]: 'black',
[ColorStyle.Black]: 'black',
[ColorStyle.LightGray]: 'grey',
[ColorStyle.Gray]: 'grey',
[ColorStyle.Green]: 'light-green',
[ColorStyle.Cyan]: 'green',
[ColorStyle.Blue]: 'light-blue',
[ColorStyle.Indigo]: 'blue',
[ColorStyle.Orange]: 'orange',
[ColorStyle.Yellow]: 'yellow',
[ColorStyle.Red]: 'red',
[ColorStyle.Violet]: 'light-violet',
}
const v1FontsToV2Fonts: Record<FontStyle, TLFontType> = {
[FontStyle.Mono]: 'mono',
[FontStyle.Sans]: 'sans',
[FontStyle.Script]: 'draw',
[FontStyle.Serif]: 'serif',
}
const v1AlignsToV2Aligns: Record<AlignStyle, TLAlignType> = {
[AlignStyle.Start]: 'start',
[AlignStyle.Middle]: 'middle',
[AlignStyle.End]: 'end',
[AlignStyle.Justify]: 'start',
}
const v1TextSizesToV2TextSizes: Record<SizeStyle, TLSizeType> = {
[SizeStyle.Small]: 's',
[SizeStyle.Medium]: 'l',
[SizeStyle.Large]: 'xl',
}
const v1SizesToV2Sizes: Record<SizeStyle, TLSizeType> = {
[SizeStyle.Small]: 'm',
[SizeStyle.Medium]: 'l',
[SizeStyle.Large]: 'xl',
}
const v1DashesToV2Dashes: Record<DashStyle, TLDashType> = {
[DashStyle.Solid]: 'solid',
[DashStyle.Dashed]: 'dashed',
[DashStyle.Dotted]: 'dotted',
[DashStyle.Draw]: 'draw',
}
function getV2Color(color: ColorStyle | undefined): TLColorType {
return color ? v1ColorsToV2Colors[color] ?? 'black' : 'black'
}
function getV2Font(font: FontStyle | undefined): TLFontType {
return font ? v1FontsToV2Fonts[font] ?? 'draw' : 'draw'
}
function getV2Align(align: AlignStyle | undefined): TLAlignType {
return align ? v1AlignsToV2Aligns[align] ?? 'middle' : 'middle'
}
function getV2TextSize(size: SizeStyle | undefined): TLSizeType {
return size ? v1TextSizesToV2TextSizes[size] ?? 'm' : 'm'
}
function getV2Size(size: SizeStyle | undefined): TLSizeType {
return size ? v1SizesToV2Sizes[size] ?? 'l' : 'l'
}
function getV2Dash(dash: DashStyle | undefined): TLDashType {
return dash ? v1DashesToV2Dashes[dash] ?? 'draw' : 'draw'
}
function getV2Point(point: number[]): Vec2dModel {
return {
x: coerceNumber(point[0]),
y: coerceNumber(point[1]),
z: point[2] == null ? 0.5 : coerceNumber(point[2]),
}
}
function getV2Arrowhead(decoration: Decoration | undefined): TLArrowheadType {
return decoration === Decoration.Arrow ? 'arrow' : 'none'
}
function getV2Fill(isFilled: boolean | undefined, color: ColorStyle) {
return isFilled
? color === ColorStyle.Black || color === ColorStyle.White
? 'semi'
: 'solid'
: 'none'
}