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:
- Inline on the node — set
attrs.srcto a URL or data URI. Node views render this immediately. - Sidecar file descriptors — add entries to the snapshot
filesarray ({ id, mimeType, url, previewSrc? }) keyed by the figure’sid. The figure plugin seeds its state from this list and will usepreviewSrc(orurl) whenattrs.srcis 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.
Recommended Flow¶
- User selects or drops an image in your UI.
- You upload the file to your storage service (S3, GCS, internal API, …).
- 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:
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 aFile. 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.previewSrcpopulates the figure’ssrc;urlis stored in the snapshotfilesarray (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)altcaptioncreditid(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.