Skip to content

Custom File API for Figures

Figure nodes store a URL (src) plus optional metadata (alt, caption, credit, etc.). The editor does not upload files on its own—you supply the storage workflow and call insertFigure once the asset is available.

You can supply image data two ways, and they can be mixed:

  1. Inline on the node — set attrs.src to a URL or data URI. Node views render this immediately.
  2. Sidecar file descriptors — add entries to the snapshot files array ({ id, mimeType, url, previewSrc? }) keyed by the figure’s id. The figure plugin seeds its state from this list and will use previewSrc (or url) when attrs.src is empty. This is how the demo seeds base64 previews for the placeholder figures.

In both cases the full-resolution URL should live in the snapshot files array so downstream exports don’t depend on inline src values.

Demo helper

The demo registers a mock image endpoint at /mock/images. POST a FormData with a file field and it will return { id, filename, url }. GET and PUT requests to /mock/images/:filename are also handled entirely in the browser, so you can test the upload flow without any additional servers.

Toolbar & figure node view

The bundled format bar now opens a file picker by default when you insert a figure. Clicking an existing figure image also prompts for a replacement file. If you cancel the picker, you can still fall back to providing a manual URL.

  1. User selects or drops an image in your UI.
  2. You upload the file to your storage service (S3, GCS, internal API, …).
  3. After the upload returns a public URL, you call commands.insertFigure({ src, alt, caption }) – or simply trigger the interactive command (commands.insertFigureInteractive?.()), which now opens the configured picker/upload flow for you.
async function handleImage(files) {
  const [file] = files;
  if (!file) return;

  const url = await uploadToStorage(file); // your API or the demo mock

  const insertFigure = editor.commands?.commands?.insertFigureInteractive;
  if (typeof insertFigure === 'function') {
    insertFigure(); // handled by the feature (file picker + upload)
  } else {
    editor.commands?.commands?.insertFigure?.({
      src: url,
      caption: `Figure: ${file.name}`,
    });
  }
}

Commands are synchronous

Perform uploads before calling insertFigure. The command expects a final URL and will not await Promises.

Using the Demo Mock API

async function uploadToStorage(file) {
  const formData = new FormData();
  formData.append('file', file);

  const response = await fetch('/mock/images', {
    method: 'POST',
    body: formData,
  });

  if (!response.ok) {
    throw new Error('Mock upload failed');
  }

  const payload = await response.json();
  return payload.url;
}

Update existing images with:

await fetch(`/mock/images/${filename}`, { method: 'PUT', body: formData });

Because the mock lives in the browser, no extra setup is required when you run the demo locally.

Bringing Your Own Upload Handlers

When you configure features, call createFigureFeature instead of using the default export and provide custom handlers:

import { createFigureFeature } from '@sciflow/editor-core/features/figure';
import type { UploadedFigureFile } from '@sciflow/editor-core/features/figure';

type UploadHandler = (file: File) => Promise<UploadedFigureFile>;
type RequestHandler = () => Promise<File | null>;

const requestFile: RequestHandler = async () => pickFromMyDialog(); // optional
const uploadFile: UploadHandler = async (file) => {
  // return { id, mimeType, url?, previewSrc? }
  return myUploader(file);
};

const figureFeature = createFigureFeature({
  imageUpload: {
    requestFile,
    uploadFile,
  },
});

await editor.configureFeatures([figureFeature, citationFeature, headingFeature]);

This lets you wire corporate storage APIs, signed URLs, or any other flow without touching the built-in mock implementation.

Handlers at a glance

  • requestFile?: () => Promise<File | null> — (optional) show a picker/asset browser and return a File. If omitted, the feature uses the browser file picker by default.
  • uploadFile: (file: File | null) => Promise<{ id: string; mimeType: string; url?: string; previewSrc?: string; }> — (required) receive the selected/dropped file and return the file descriptor. previewSrc populates the figure’s src; url is stored in the snapshot files array (and used as a fallback if no preview is provided). You decide how to upload (S3, signed URL, internal API, etc.). The default handler also extracts dimensions and emits a preview data URL capped at 200×300 and ~5KB.

Example: Wrapping an External File API

If your platform already exposes upload/resolve helpers (e.g., PKP’s OJS API that returns { id, filename }), adapt them like so:

type UploadedAsset = { id: string; filename: string };
type UploadFile = (file: File) => Promise<UploadedAsset>;
type ResolveAsset = (asset: UploadedAsset) => string;
type ResolvePreview = (asset: UploadedAsset) => string;

const uploadFile: UploadFile = async (file) => {
  const formData = new FormData();
  formData.append('file', file);
  const response = await fetch('/api/files', { method: 'POST', body: formData });
  return response.json();
};

const resolveAsset: ResolveAsset = (asset) =>
  `https://files.example.com/${asset.id}/${asset.filename}`;
const resolvePreview: ResolvePreview = (asset) =>
  `https://images.example.com/previews/${asset.id}.jpg`;

const figureFeature = createFigureFeature({
  imageUpload: {
    // Only override requestFile if you want a custom picker UI
    requestFile: () => pickFromMyDialog(), // optional
    async uploadFile(file) {
      const asset = await uploadFile(file);
      return {
        id: asset.id,
        mimeType: file.type || 'application/octet-stream',
        url: resolveAsset(asset), // full-resolution URL stored in snapshot files
        previewSrc: resolvePreview(asset), // optional preview shown in the doc
      };
    },
  },
});

await editor.configureFeatures([figureFeature]);

The editor still receives { src, alt } after the flow completes; you simply map your backend’s response into the final URL stored in the figure node.

Hooking Into Drag/Drop

editor.addEventListener('drop', async (event) => {
  const files = event.dataTransfer?.files;
  if (!files?.length) return;
  event.preventDefault();
  await handleImage(files);
});

Passing Extra Metadata

The InsertFigureOptions interface (see packages/editor/core/src/lib/features/figure/types.ts) accepts any figure node attribute:

  • src (required)
  • alt
  • caption
  • credit
  • id (e.g., fig-3)
insertFigure({
  src: url,
  alt: description,
  caption: userInput.caption,
  credit: userInput.credit,
  id: slugify(description),
});

Validating Images Before Upload

function guardFiles(files) {
  const allowed = Array.from(files).filter((file) => file.type.startsWith('image/'));
  if (!allowed.length) {
    throw new Error('Only images are supported');
  }
  return allowed;
}

Call guardFiles before uploading to give users immediate feedback.

Large uploads

If uploads take a long time, show progress in your UI. The editor only knows about the final insertFigure call; it cannot display intermediate states for you.