@scopmx/markora-client
v1.5.0
Published
Fabric.js markup and drawing editor for React — shapes, text, PDF and image backgrounds, comments, version history, PDF/image/HTML export, and multi-user saving to @scopmx/markora-server.
Readme
@scopmx/markora-client
Fabric.js markup and drawing editor for React — shapes, text, PDF and image backgrounds, comments, version history, PDF/image/HTML export, and multi-user saving to @scopmx/markora-server.
This package succeeds @scopmx/markup-editor, whose code and full history it now holds; that package is no longer maintained.
Install
npm install @scopmx/markora-client fabric pdfjs-distUsage
import {
DrawingEditor,
UserProvider,
ToastProvider,
ToastContainer,
SwitchUserModal,
} from "@scopmx/markora-client";
import "@scopmx/markora-client/styles.css";
export function EditorPage() {
return (
<UserProvider>
<ToastProvider>
<DrawingEditor
drawingId={null}
loadToken={0}
onDrawingCreated={() => {}}
onDirtyChange={() => {}}
onMetaUpdated={() => {}}
onCommentsChange={() => {}}
onSelectionChange={() => {}}
rightTab="style"
onRightTabChange={() => {}}
commentsPanelProps={{/* ... */}}
historyPanelProps={{/* ... */}}
/>
<SwitchUserModal />
<ToastContainer />
</ToastProvider>
</UserProvider>
);
}Exports
- Components:
DrawingEditor,EditorHeader,ConfirmDialog,SwitchUserModal,ToastContainer - Context:
UserProvider,useCurrentUser,ToastProvider,useToast - Services: drawing/version CRUD via
versionServicefunctions - Types:
DrawingEditorProps,DrawingEditorHandle, models, panel props, etc.
Use a ref on DrawingEditor to access DrawingEditorHandle (saveVersion, restoreVersionById, etc.).
Saving to a Markora server
By default drawings are kept in the browser (IndexedDB). To save them to a backend running
@scopmx/markora-server, install createMarkoraStorage before the editor mounts:
import { createMarkoraStorage, setDrawingStorage } from "@scopmx/markora-client";
const storage = createMarkoraStorage({
baseUrl: "/api/markora", // where the project mounted the server router
// Your own HTTP function, so login tokens and session handling stay yours.
// Resolve with { data: responseBody }; reject with error.response.{status,data}.
request: ({ method, url, data, params }) => axios.request({ method, url, data, params }),
uploadFile: (file, fileName, { timeout }) => myUpload(file, fileName, timeout),
onConflict: ({ by }) => showReloadBanner(by),
overrides: {
updateDrawingTitle: (id, title) => api.rename(id, title),
deleteDrawing: (id) => api.remove(id),
},
});
setDrawingStorage(storage);
// While the editor is open:
useEffect(
() =>
storage.watch(drawingId, {
onPresence: (others) => setOthers(others), // [{ id, name }]
onRemoteChange: (change) => setRemoteChange(change), // { by, lostMarks } or null
}),
[drawingId],
);Show "Priya changed this drawing · Show changes" when onRemoteChange reports a change and
the editor has nothing unsaved (onDirtyChange(false)); the button reloads the editor by bumping
its loadToken.
When two people save at once:
- Comments and replies always merge.
- HTML drawings merge mark by mark.
- On image, PDF and 3D drawings, marks merge when only one person changed marks, or when both editors are the same size (the editor stores those marks in screen coordinates).
- Both changed the same mark → the last save wins for that mark. One deleted it → the delete
wins, and
lostMarkscounts the changes that were dropped. - When a merge is not safe, the person's unsaved work is saved as a "conflict copy" version in
History,
onConflictfires, the save throwsMarkoraConflictError, and later saves are refused until the editor reloads.
The connector lives in src/markora/; its tests are test/integration/markora*.test.ts.
Props worth setting
commentMaxLength
Defaults to 100 characters. Most hosts will want more — for example 2000–4000.
Comments are stored inside the drawing snapshot, not in a table of their own, so size this against your snapshot storage limit rather than against a text column.
Shell height
The editor shell fills the viewport by default. To embed it inside a page, set
--me-shell-height on or above the editor rather than overriding its height:
.my-editor-wrapper {
--me-shell-height: 720px;
}Any CSS length works, including calc(100svh - 64px) for a page with a header
of its own. Unset, it falls back to 100svh.
Security notes for hosts
The editor runs in the browser, so everything below is the host's responsibility — none of it can be enforced from here.
- Comment edit and delete are only author-checked in the browser. The check
compares the comment's
authorIdwith the current user and hides the controls; it is not an authorisation boundary. A comment with noauthorIdat all — which is what snapshots saved by older versions contain — is treated as editable by anyone. - Comments live in the drawing snapshot. A user who can save a snapshot can
save one containing any comment text, author name or
authorId. If authorship matters, your server has to enforce it when a snapshot is written, by comparing the incoming comments against the ones it already stored. - Uploaded HTML is isolated from the host origin by default. The editor
loads it in an iframe with
sandbox="allow-scripts"and noallow-same-origin, viasrcdoc. Guest scripts cannot read this page'slocalStorage, cookies or DOM. PasshtmlIsolation="legacy"only if you need the 1.x blob URL, which is same-origin with the host and can steal session tokens. - To give a prototype its own real origin (so
localStorageinside the page still works), hostsandbox.htmlon a different origin and pass that URL ashtmlIsolationOrigin. A same-origin URL is ignored and falls back to unique-originsrcdoc. Or writeHTML_SANDBOX_PAGEto a file on that origin. - Guest pages can still paint a fake UI inside the frame, and can still make uncredentialed network requests. Treat uploaded HTML as untrusted content.
- Everything the editor sends to the adapter is client-controlled data. Snapshots, screen routes, screen snapshots and activity entries are all built in the page and must be validated server-side: check sizes, check that ids belong to the drawing being written, and do not echo stored HTML back into another user's page without treating it as untrusted.
Guides
- Live-HTML screen redirect — what a host must store and return so comment redirect survives a reload.
- 3D model backgrounds — uploading and annotating GLB, glTF, OBJ, STL and FBX models, and what a host adapter must store.
Peer dependencies
react^18 or ^19react-dom^18 or ^19fabric^7.4.0pdfjs-dist^6.0.227
fabric and pdfjs-dist are peers because there has to be exactly one copy
of each. The editor writes fabric's ownDefaults and reads pdf.js's
GlobalWorkerOptions, and both are module-level state belonging to one
instance. With two copies installed, your fabric objects would not see the
editor's defaults and any pdf.js worker configuration you set would apply to a
pdf.js the editor never uses — with no error either way.
jspdf, jszip, html2canvas and three stay as regular dependencies:
they are internal, and nothing you configure reaches them.
The pdf.js worker
PDF rendering needs a pdf.js worker. The editor ships one and uses it by default — nothing to configure.
The worker is served from the package, so a bundler that copies package assets
(Vite, Next, webpack 5 with asset/resource) picks it up. If yours does not,
serve node_modules/@scopmx/markora-client/dist/pdf.worker.min.js yourself and
point the editor at it:
import { configurePdfWorker } from "@scopmx/markora-client";
configurePdfWorker({ workerSrc: "/assets/pdf.worker.min.js" });Or let your bundler build the worker, which avoids a separate request:
const Worker = (await import("@scopmx/markora-client/pdf.worker?worker")).default;
configurePdfWorker({ workerPort: new Worker() });Call it once, before the first PDF is opened. A workerPort wins over a
workerSrc — that is pdf.js's rule.
Two things worth knowing:
- The file is named
.js, not.mjs. Many servers (nginx by default) have no MIME mapping for.mjs; browsers then refuse it as a module script and every PDF fails. - In the CommonJS build the bundled default cannot be located, because a
CJS bundle has no way to resolve its own sibling asset. The editor logs an
error telling you to call
configurePdfWorker. The ESM build — whatimportresolves to, and what every modern bundler picks — works without it.
