Fabric.js Document Engine

Search documentation

Find a page or section

中文

Large documents

View as Markdown

Thousands of objects, a progress bar, a cancel button and clear image errors.

loadFromJSON creates every object in one go, so a floor plan or a long design with thousands of objects freezes the tab until it is done. Fabric.js performance on load matters most for these large documents, because the user sees nothing and cannot cancel. The engine loads in chunks, reports progress and stops when asked.

Loading the live demo. Its code is below.

large-document-demo.tsx
"use client";
 
import {
  type FabricDocument,
  isDocumentEngineError,
  type LoadProgress,
  type SerializedFabricObject,
} from "fabricjs-document-engine";
import { useDocumentEngine } from "fabricjs-document-engine/react";
import { CircleStopIcon, FolderOpenIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRef, useState } from "react";
 
import {
  COLORS,
  DemoCanvas,
  DemoFrame,
  Status,
  Toolbar,
  ToolButton,
} from "./demo-ui";
import { PAGE, useFabricCanvas } from "./use-fabric-canvas";
 
const COUNT = 20_000;
 
/** A document with thousands of small shapes, like a busy floor plan. */
function bigDocument(): FabricDocument {
  const objects: SerializedFabricObject[] = Array.from(
    { length: COUNT },
    (_, index) => ({
      type: index % 3 === 0 ? "Circle" : "Rect",
      originX: "left",
      originY: "top",
      left: Math.random() * (PAGE.width - 8),
      top: Math.random() * (PAGE.height - 8),
      width: 6,
      height: 6,
      radius: 3,
      fill: COLORS[index % COLORS.length],
      objectCaching: false,
    })
  );
  return {
    schemaVersion: 1,
    id: `floor-plan-${Date.now()}`,
    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString(),
    canvas: { width: PAGE.width, height: PAGE.height, background: "#ffffff" },
    objects,
    metadata: {},
  };
}
 
export default function LargeDocumentDemo() {
  const t = useTranslations("demos");
  const { elementRef, canvas } = useFabricCanvas();
  const engine = useDocumentEngine(canvas);
  const controller = useRef<AbortController | null>(null);
  const [progress, setProgress] = useState<LoadProgress | null>(null);
  const [message, setMessage] = useState("");
  const loading =
    controller.current !== null &&
    progress !== null &&
    progress.stage !== "done";
 
  async function load() {
    if (!engine) return;
    const current = new AbortController();
    controller.current = current;
    const started = performance.now();
    setMessage("");
    try {
      await engine.loadDocument(bigDocument(), {
        signal: current.signal,
        onProgress: setProgress,
        discardUnsavedChanges: true,
      });
      setMessage(
        t("large.done", {
          count: COUNT,
          ms: Math.round(performance.now() - started),
        })
      );
    } catch (error) {
      setMessage(
        isDocumentEngineError(error) && error.code === "LOAD_ABORTED"
          ? t("large.cancelled")
          : t("large.failed", { message: String(error) })
      );
    } finally {
      controller.current = null;
      setProgress(null);
    }
  }
 
  const share =
    progress && progress.total > 0 ? progress.done / progress.total : 0;
 
  return (
    <DemoFrame>
      <DemoCanvas elementRef={elementRef} label={t("canvas")} />
 
      <div
        role="progressbar"
        aria-label={t("large.progress")}
        aria-valuemin={0}
        aria-valuemax={100}
        aria-valuenow={Math.round(share * 100)}
        className="h-1.5 overflow-hidden rounded-full bg-muted"
      >
        <div
          className="h-full bg-foreground transition-[width]"
          style={{ width: `${share * 100}%` }}
        />
      </div>
 
      <Toolbar>
        <ToolButton
          icon={FolderOpenIcon}
          label={t("large.load", { count: COUNT.toLocaleString() })}
          primary
          disabled={!engine || loading}
          onClick={() => void load()}
        />
        <ToolButton
          icon={CircleStopIcon}
          label={t("large.cancel")}
          disabled={!loading}
          onClick={() => controller.current?.abort()}
        />
      </Toolbar>
 
      <Status tone={loading ? "busy" : message ? "ok" : "idle"}>
        {progress
          ? t("large.stage", {
              stage: t(`large.stages.${progress.stage}`),
              done: progress.done,
              total: progress.total,
            })
          : message || t("large.hint")}
      </Status>
    </DemoFrame>
  );
}

Show load progress and cancel

const controller = new AbortController();
cancelButton.onclick = () => controller.abort();
 
await engine.load("floor-plan", {
  signal: controller.signal,
  onProgress: ({ stage, done, total }) => {
    progressBar.value = total > 0 ? done / total : 0;
    progressLabel.textContent = stage;
  },
});
  • stage is prepare (validation and migration), images (images checked), objects (objects created) and done.
  • Objects are created 100 at a time, and the page gets a turn between chunks, so clicks and scrolling keep working.
  • Cancelling the AbortSignal rejects with LOAD_ABORTED, and the canvas keeps showing what it showed before. A failed load does the same, because every object is created before the canvas is cleared.
  • The load:progress event carries the same values for code that is not the caller.

signal and onProgress work for load, loadDocument, importFabricJson and restoreRecovery.

Why an image failed

A load stops with MISSING_ASSETS when images are missing, and each entry now says why:

try {
  await engine.load("floor-plan");
} catch (error) {
  if (isDocumentEngineError(error) && error.code === "MISSING_ASSETS") {
    for (const { url, failure } of error.missingAssets) {
      console.warn(url, failure?.reason, failure?.status);
    }
  }
}
reasonMeaning
NOT_FOUNDThe server answered 404 or 410
HTTP_ERRORThe server answered with another error status
CORSAnother site does not allow your page to read the image
NETWORKThe address could not be reached
TIMEOUTThe image took longer than assets.imageTimeout
DECODEThe file arrived, but it is not a picture the browser can read
ABORTEDThe load was cancelled

Browsers hide some details on purpose. When an image from another site fails without CORS headers, the reason is CORS if the image asked for crossOrigin, and NETWORK otherwise.

Tune image loading

createDocumentEngine({
  canvas,
  assets: {
    imageTimeout: 15_000,     // default 30 seconds
    maxConcurrentImages: 4,   // default 6
  },
});

Fewer images at once helps when many large images compete on a slow connection. More Fabric.js performance figures are on the compatibility page, and missing images are covered in images and fonts. To render many documents rather than open one large one, see rendering many documents.