Excalidraw Onboarding LabChapter V — Getting a scene in and out: data/ import and export0/6Contents
Chapter V

Getting a scene in and out: data/ import and export

This module follows a dropped file — a .excalidraw JSON, or a PNG/SVG carrying an embedded scene — through data/blob.ts and data/json.ts back into live elements and appState, and then traces the mirror-image export path in data/index.ts and data/json.ts that serializes the Scene back out. The throughline is that persistence code is not a special App-level channel: it decodes/encodes bytes, then hands the result to the same Scene.replaceAllElements / updateScene commit point and the same Actions dispatch you've already seen used for drawing.


Figure I · pipelineFrom a browser drop event to elements in the Scene
4 stages
Press play — or step through the pipeline stage by stage.
walkthrough

Inside loadSceneOrLibraryFromBlob

packages/excalidraw/data/blob.ts · lines 138195
lines 138–144

The signature carries the two pieces of context an import needs beyond the file itself: localAppState/localElements (the current Scene, used to repair bindings and preserve scroll position) and an optional native FileSystemFileHandle so later saves can write back to the same file.

line 146

It hands the raw blob to parseFileContents first, so by this point any PNG/SVG-embedded payload has already been unwrapped into a plain JSON string - this function never has to know it was looking at an image.

lines 148–159

JSON.parse turns the text into an object; if that fails on a file whose type is a recognized image, the failure is reclassified as ImageSceneDataError rather than a generic parse error - that's the signal App later uses to fall back to inserting the file as a plain picture instead of showing a corrupt-file message.

lines 160–181

This is the branch that matters for a .excalidraw drop: elements go through restoreElements (repairing bindings, dropping invisible elements) and appState through restoreAppState, seeded with the local theme/fileHandle and merged against whatever appState the file carried. The output is exactly the { elements, appState, files } triple that the Scene-commit step already knows how to consume.

lines 182–186

The same dropped-file plumbing also recognizes an .excalidrawlib payload by its content shape (not its file extension) and routes it to the library system instead of the Scene.

lines 187–195

Anything that's neither a valid scene nor a valid library collapses to one generic "invalid file" error, while an ImageSceneDataError is deliberately rethrown unchanged so the caller can distinguish "this image just has no embedded scene" from "this file is garbage".

export const loadSceneOrLibraryFromBlob = async (  blob: Blob | File,  /** @see restore.localAppState */  localAppState: AppState | null,  localElements: readonly ExcalidrawElement[] | null,  /** FileSystemFileHandle. Defaults to `blob.handle` if defined, otherwise null. */  fileHandle?: FileSystemFileHandle | null,) => {  const contents = await parseFileContents(blob);  let data;  try {    try {      data = JSON.parse(contents);    } catch (error: any) {      if (isSupportedImageFile(blob)) {        throw new ImageSceneDataError(          "Image doesn't contain scene",          "IMAGE_NOT_CONTAINS_SCENE_DATA",        );      }      throw error;    }    if (isValidExcalidrawData(data)) {      return {        type: MIME_TYPES.excalidraw,        data: {          elements: restoreElements(data.elements, localElements, {            repairBindings: true,            deleteInvisibleElements: true,          }),          appState: restoreAppState(            {              theme: localAppState?.theme,              fileHandle: fileHandle || blob.handle || null,              ...cleanAppStateForExport(data.appState || {}),              ...(localAppState                ? getScrollToContentState(data.elements || [], localAppState)                : {}),            },            localAppState,          ),          files: data.files || {},        },      };    } else if (isValidLibrary(data)) {      return {        type: MIME_TYPES.excalidrawlib,        data,      };    }    throw new Error("Error: invalid file");  } catch (error: any) {    if (error instanceof ImageSceneDataError) {      throw error;    }    throw new Error("Error: invalid file");  }};
step 1 of 6
const parseFileContents = async (blob: Blob | File): Promise<string> => {  let contents: string;  if (blob.type === MIME_TYPES.png) {    try {      return await (await import("./image")).decodePngMetadata(blob);    } catch (error: any) {      if (error.message === "INVALID") {        throw new ImageSceneDataError(          "Image doesn't contain scene",          "IMAGE_NOT_CONTAINS_SCENE_DATA",        );      } else {        throw new ImageSceneDataError("Error: cannot restore image");      }    }  } else {    if ("text" in Blob) {      contents = await blob.text();    } else {      contents = await new Promise((resolve) => {        const reader = new FileReader();        reader.readAsText(blob, "utf8");        reader.onloadend = () => {          if (reader.readyState === FileReader.DONE) {            resolve(reader.result as string);          }        };      });    }    if (blob.type === MIME_TYPES.svg) {      try {        return decodeSvgBase64Payload({          svg: contents,        });      } catch (error: any) {        if (error.message === "INVALID") {          throw new ImageSceneDataError(            "Image doesn't contain scene",            "IMAGE_NOT_CONTAINS_SCENE_DATA",          );        } else {          throw new ImageSceneDataError("Error: cannot restore image");        }      }    }  }  return contents;};
packages/excalidraw/data/blob.ts · lines 3280
Figure II · A PNG exported with "embed scene" checked isn't just pixels: encodePngMetadata slips a compressed copy of the .excalidraw JSON into a PNG tEXt metadata chunk, a spot ordinary image viewers ignore. Think of it as a shipping container with the original packing invoice zip-tied to the inside wall - a photo viewer only ever unpacks the crate (the pixels), but parseFileContents always checks for that zip-tied invoice first via decodePngMetadata (and the SVG branch does the same via a hidden XML comment and decodeSvgBase64Payload). That's why dragging an exported PNG/SVG back onto the canvas can restore the original editable drawing instead of inserting a picture.
export const serializeAsJSON = (  elements: readonly ExcalidrawElement[],  appState: Partial<AppState>,  files: BinaryFiles,  type: "local" | "database",): string => {  const data: ExportedDataState = {    type: EXPORT_DATA_TYPES.excalidraw,    version: VERSIONS.excalidraw,    source: getExportSource(),    elements,    appState:      type === "local"        ? cleanAppStateForExport(appState)        : clearAppStateForDatabase(appState),    files:      type === "local"        ? filterOutDeletedFiles(elements, files)        : // will be stripped from JSON          undefined,  };  return JSON.stringify(data, null, 2);};export const saveAsJSON = async ({  data,  filename,  fileHandle,}: {  data: MaybePromise<JSONExportData>;  filename: string;  fileHandle: AppState["fileHandle"];}) => {  const blob = Promise.resolve(data).then(({ elements, appState, files }) => {    const serialized = serializeAsJSON(elements, appState, files, "local");    return new Blob([serialized], {      type: MIME_TYPES.excalidraw,    });  });  const savedFileHandle = await fileSave(blob, {    name: filename,    extension: "excalidraw",    description: "Excalidraw file",    fileHandle: isImageFileHandle(fileHandle) ? null : fileHandle,  });  return { fileHandle: savedFileHandle };};
packages/excalidraw/data/json.ts · lines 52100
Figure III · serializeAsJSON builds the exact envelope - type: "excalidraw", version, source, elements, appState, files - that isValidExcalidrawData checks for on the way in, and that loadSceneOrLibraryFromBlob then reads into restoreElements/restoreAppState. saveAsJSON does nothing more than call serializeAsJSON, wrap the string in a Blob, and hand it to fileSave: import and export are reading and writing the same on-disk shape from opposite ends.
checkpoint

Checkpoint

Answered in place — nothing is graded, everything is explained. 0 / 2 passed

You export a drawing to PNG with "embed scene" enabled, then later drag that PNG back onto the canvas. Why do you get the original editable elements back instead of just an inserted picture?

After loadSceneOrLibraryFromBlob returns a parsed { elements, appState, files } object, what actually makes those elements the live scene content?