import { Editor, FileHelpers, MigrationFailureReason, MigrationResult, RecordId, Result, SerializedSchema, SerializedSchemaV1, SerializedSchemaV2, SerializedStore, T, TLAsset, TLAssetId, TLRecord, TLSchema, TLStore, UnknownRecord, createTLStore, exhaustiveSwitchError, partition, transact, } from '@tldraw/editor' import { TLUiToastsContextType } from '../../ui/context/toasts' import { TLUiTranslationKey } from '../../ui/hooks/useTranslation/TLUiTranslationKey' import { buildFromV1Document } from '../tldr/buildFromV1Document' /** @public */ export const TLDRAW_FILE_MIMETYPE = 'application/vnd.tldraw+json' as const /** @public */ export const TLDRAW_FILE_EXTENSION = '.tldr' as const // When incrementing this, you'll need to update parseTldrawJsonFile to handle // both your new changes and the old file format const LATEST_TLDRAW_FILE_FORMAT_VERSION = 1 /** @public */ export interface TldrawFile { tldrawFileFormatVersion: number schema: SerializedSchema records: UnknownRecord[] } const schemaV1 = T.object({ schemaVersion: T.literal(1), storeVersion: T.positiveInteger, recordVersions: T.dict( T.string, T.object({ version: T.positiveInteger, subTypeVersions: T.dict(T.string, T.positiveInteger).optional(), subTypeKey: T.string.optional(), }) ), }) const schemaV2 = T.object({ schemaVersion: T.literal(2), sequences: T.dict(T.string, T.positiveInteger), }) const tldrawFileValidator: T.Validator = T.object({ tldrawFileFormatVersion: T.nonZeroInteger, schema: T.union('schemaVersion', { 1: schemaV1, 2: schemaV2, }), records: T.arrayOf( T.object({ id: T.string as any as T.Validator>, typeName: T.string, }).allowUnknownProperties() ), }) /** @public */ export function isV1File(data: any) { try { if (data.document?.version) { return true } return false } catch (e) { return false } } /** @public */ export type TldrawFileParseError = | { type: 'v1File'; data: any } | { type: 'notATldrawFile'; cause: unknown } | { type: 'fileFormatVersionTooNew'; version: number } | { type: 'migrationFailed'; reason: MigrationFailureReason } | { type: 'invalidRecords'; cause: unknown } /** @public */ export function parseTldrawJsonFile({ json, schema, }: { schema: TLSchema json: string }): Result { // first off, we parse .json file and check it matches the general shape of // a tldraw file let data try { data = tldrawFileValidator.validate(JSON.parse(json)) } catch (e) { // could be a v1 file! try { data = JSON.parse(json) if (isV1File(data)) { return Result.err({ type: 'v1File', data }) } } catch (e) { // noop } return Result.err({ type: 'notATldrawFile', cause: e }) } // if the file format version isn't supported, we can't open it - it's // probably from a newer version of tldraw if (data.tldrawFileFormatVersion > LATEST_TLDRAW_FILE_FORMAT_VERSION) { return Result.err({ type: 'fileFormatVersionTooNew', version: data.tldrawFileFormatVersion, }) } // even if the file version is up to date, it might contain old-format // records. lets create a store with the records and migrate it to the // latest version let migrationResult: MigrationResult> try { const storeSnapshot = Object.fromEntries(data.records.map((r) => [r.id, r as TLRecord])) migrationResult = schema.migrateStoreSnapshot({ store: storeSnapshot, schema: data.schema }) } catch (e) { // junk data in the migration return Result.err({ type: 'invalidRecords', cause: e }) } // if the migration failed, we can't open the file if (migrationResult.type === 'error') { return Result.err({ type: 'migrationFailed', reason: migrationResult.reason }) } // at this stage, the store should have records at the latest versions, so // we should be able to validate them. if any of the records at this stage // are invalid, we don't open the file try { return Result.ok( createTLStore({ initialData: migrationResult.value, schema, }) ) } catch (e) { // junk data in the records (they're not validated yet!) could cause the // migrations to crash. We treat any throw from a migration as an // invalid record return Result.err({ type: 'invalidRecords', cause: e }) } } /** @public */ export async function serializeTldrawJson(store: TLStore): Promise { const records: TLRecord[] = [] const usedAssets = new Set() const assets: TLAsset[] = [] for (const record of store.allRecords()) { switch (record.typeName) { case 'asset': if ( record.type !== 'bookmark' && record.props.src && !record.props.src.startsWith('data:') ) { let assetSrcToSave try { // try to save the asset as a base64 string assetSrcToSave = await FileHelpers.blobToDataUrl( await (await fetch(record.props.src)).blob() ) } catch { // if that fails, just save the original src assetSrcToSave = record.props.src } assets.push({ ...record, props: { ...record.props, src: assetSrcToSave, }, }) } else { assets.push(record) } break case 'shape': if ('assetId' in record.props) { usedAssets.add(record.props.assetId) } records.push(record) break default: records.push(record) break } } const recordsToSave = records.concat(assets.filter((a) => usedAssets.has(a.id))) return JSON.stringify({ tldrawFileFormatVersion: LATEST_TLDRAW_FILE_FORMAT_VERSION, schema: store.schema.serialize(), records: recordsToSave, }) } /** @public */ export async function serializeTldrawJsonBlob(store: TLStore): Promise { return new Blob([await serializeTldrawJson(store)], { type: TLDRAW_FILE_MIMETYPE }) } /** @internal */ export async function parseAndLoadDocument( editor: Editor, document: string, msg: (id: TLUiTranslationKey | Exclude) => string, addToast: TLUiToastsContextType['addToast'], onV1FileLoad?: () => void, forceDarkMode?: boolean ) { const parseFileResult = parseTldrawJsonFile({ schema: editor.store.schema, json: document, }) if (!parseFileResult.ok) { let description switch (parseFileResult.error.type) { case 'notATldrawFile': editor.annotateError(parseFileResult.error.cause, { origin: 'file-system.open.parse', willCrashApp: false, tags: { parseErrorType: parseFileResult.error.type }, }) reportError(parseFileResult.error.cause) description = msg('file-system.file-open-error.not-a-tldraw-file') break case 'fileFormatVersionTooNew': description = msg('file-system.file-open-error.file-format-version-too-new') break case 'migrationFailed': if (parseFileResult.error.reason === MigrationFailureReason.TargetVersionTooNew) { description = msg('file-system.file-open-error.file-format-version-too-new') } else { description = msg('file-system.file-open-error.generic-corrupted-file') } break case 'invalidRecords': editor.annotateError(parseFileResult.error.cause, { origin: 'file-system.open.parse', willCrashApp: false, tags: { parseErrorType: parseFileResult.error.type }, }) reportError(parseFileResult.error.cause) description = msg('file-system.file-open-error.generic-corrupted-file') break case 'v1File': { buildFromV1Document(editor, parseFileResult.error.data.document) onV1FileLoad?.() return } default: exhaustiveSwitchError(parseFileResult.error, 'type') } addToast({ title: msg('file-system.file-open-error.title'), description, severity: 'error', }) return } // tldraw file contain the full state of the app, // including ephemeral data. it up to the opener to // decide what to restore and what to retain. Here, we // just restore everything, so if the user has opened // this file before they'll get their camera etc. // restored. we could change this in the future. transact(() => { const initialBounds = editor.getViewportScreenBounds().clone() const isFocused = editor.getInstanceState().isFocused editor.store.clear() const [shapes, nonShapes] = partition( parseFileResult.value.allRecords(), (record) => record.typeName === 'shape' ) editor.store.put(nonShapes, 'initialize') editor.store.ensureStoreIsUsable() editor.store.put(shapes, 'initialize') editor.history.clear() // Put the old bounds back in place editor.updateViewportScreenBounds(initialBounds) editor.updateRenderingBounds() const bounds = editor.getCurrentPageBounds() if (bounds) { editor.zoomToBounds(bounds, { targetZoom: 1 }) } editor.updateInstanceState({ isFocused }) }) if (forceDarkMode) editor.user.updateUserPreferences({ isDarkMode: true }) }