@sarc-mediq/react-diagrams
v1.0.1
Published
Freehand sketch & annotation canvas for React — draw, erase, add draggable text labels, and export to SVG/PNG/JPEG
Readme
@sarc-mediq/react-diagrams
Freehand sketch & annotation canvas for React — draw, erase, add draggable text labels, and export to SVG/PNG/JPEG. A modern, dependency-free drawing surface, used by the findings app.
Install
Published to the public npm registry — no authentication or .npmrc needed:
yarn add @sarc-mediq/react-diagrams
# peers
yarn add react react-domUsage
DiagramCanvas is the public component. Drive it through its imperative ref —
set the active mode, then draw/erase/place text on the surface; read or load
strokes via the export/load methods.
import { useRef } from "react"
import {
DiagramCanvas,
CanvasMode,
type DiagramCanvasRef,
type CanvasPath,
type CanvasText,
} from "@sarc-mediq/react-diagrams"
export function Example() {
const canvas = useRef<DiagramCanvasRef>(null)
const handleChange = (paths: CanvasPath[], texts: CanvasText[]) => {
// Persist or react to the latest strokes/labels.
console.log(paths, texts)
}
const setMode = (mode: CanvasMode) => {
if (canvas.current) {
canvas.current.mode = mode
}
}
return (
<>
<div>
<button onClick={() => setMode(CanvasMode.pen)}>Pen</button>
<button onClick={() => setMode(CanvasMode.eraser)}>Eraser</button>
<button onClick={() => setMode(CanvasMode.text)}>Text</button>
<button onClick={() => canvas.current?.undo()}>Undo</button>
<button onClick={() => canvas.current?.clearCanvas()}>Clear</button>
</div>
<DiagramCanvas
ref={canvas}
width="600px"
height="400px"
strokeColor="#1a73e8"
strokeWidth={4}
eraserWidth={8}
backgroundImage="/diagram-base.png"
onChange={handleChange}
/>
</>
)
}Exporting
const png = await canvas.current?.exportImage("png")
const svg = await canvas.current?.exportSvg()
const paths = canvas.current?.exportPaths()API
DiagramCanvas props
| Prop | Type | Notes |
|---|---|---|
| strokeColor | string | Pen colour. |
| strokeWidth | number | Pen width (default 4). |
| eraserWidth | number | Eraser width (default 8). |
| canvasColor | string | Background fill when no backgroundImage. |
| backgroundImage | string | URL drawn behind strokes. |
| backgroundImageFit | "stretch" \| "contain" | stretch (default) fills the surface, may distort; contain sizes the surface to the image's aspect ratio (centred, letterboxed, undistorted). |
| exportWithBackgroundImage | boolean | Include the background in exports. |
| width / height | string \| number | Surface dimensions. |
| keepScale | boolean | Rescale strokes when the surface resizes. |
| withTimestamp | boolean | Record per-stroke timing (getSketchingTime). |
| referenceSize | Size | Reference dimensions for addPath coordinates. |
| onChange | (paths, texts) => void | Fires on every mutation. |
| onStroke | (path, isEraser) => void | Fires when a stroke completes. |
DiagramCanvasRef methods
mode (CanvasMode), keepScale, size, clearCanvas(), undo(), redo(),
addText(text, position), addPath(points, width, color),
exportImage(type), exportSvg(), exportPaths() / exportTexts(),
exportPathsPromise() / exportTextsPromise(), loadPaths(paths, size?),
loadTexts(texts, size?), getSketchingTime(), resetCanvas().
Modes (CanvasMode)
Set the active mode through the ref (canvas.current.mode = CanvasMode.pen):
| Mode | Behaviour |
|---|---|
| pen | Freehand drawing at strokeColor / strokeWidth. |
| remove | Click a stroke to delete the whole stroke (a coarse "stroke eraser"). |
| eraser | Drag to erase pixels through an SVG mask, at eraserWidth. |
| text | Click to drop an editable, draggable text label. |
| none | Inert — no drawing, no removal. |
The surface sets a matching cursor for each mode (crosshair for pen, an eraser cursor for both erase modes, a text caret for text), so consumers ship no cursor CSS of their own.
Undo/redo is operation-based: undo() reverses the last action and redo()
reapplies it — including stroke removals (a stroke removed in remove mode is
restored with undo()). The pixel eraser only affects strokes drawn before
it; strokes drawn afterwards are not erased.
DiagramSurface (the lower-level SVG surface) and the data types
(CanvasPath, CanvasText, CanvasMode, Point, Size, ExportImageType)
are also exported.
Development
| Script | Action |
|---|---|
| yarn build | Bundle ESM + CJS + type declarations to dist/ (tsup). |
| yarn dev | Rebuild on change. |
| yarn ladle | Run the component preview (Ladle dev server). |
| yarn ladle:build | Build the static Ladle site. |
| yarn test | Unit tests once (vitest + jsdom). |
| yarn test:watch | Watch-mode unit tests. |
| yarn test:coverage | Unit tests with V8 coverage. |
| yarn e2e:install | Install the Playwright Chromium browser (run once before e2e). |
| yarn e2e | End-to-end tests (Playwright; auto-starts Ladle). |
| yarn typecheck | tsc --noEmit. |
| yarn lint | ESLint over src. |
| yarn format | Prettier write over src. |
| yarn format:check | Prettier check (no writes) over src. |
Git hooks
Git hooks live in .githooks/ and are wired automatically on yarn install
(via a prepare script that sets core.hooksPath) — no Husky, no extra deps.
They mirror CI so pushes don't fail it:
- pre-commit — fast gate:
format:check+lint+typecheck+test. - pre-push — full CI mirror: the above plus
build+e2e.
e2e needs the Playwright browser once (yarn e2e:install). Escape hatches:
SKIP_E2E=1 git push skips just e2e; git commit/push --no-verify skips the
whole gate.
Toolchain
- Build: tsup (esbuild) → ESM + CJS +
.d.ts - Preview: Ladle (Vite) —
src/**/*.stories.tsx - Test: Vitest + Testing Library (jsdom) for units; Playwright over Ladle for end-to-end
- Lint: ESLint flat config + typescript-eslint, mirroring portal-ui's type-aware strict ruleset
- Types: TypeScript strict, React 18 peer
- Versioning: the version in
package.jsonis the source of truth; a push to thereleasebranch publishes it (see below)
Releasing
Publishing to the public npm registry is publish-on-version-change, driven by
the release branch. Everyday commits on master are free-form — no prefixes, no
changeset files. Cutting a release is a deliberate act:
# 1. Bump the version on master (edits package.json only — no commit/tag)
yarn version minor # or: patch | major (yarn release:minor, …)
git commit -am "1.3.0" # commit the bump
# 2. Merge master into release and push
git checkout release
git merge master
git push # triggers .github/workflows/release.ymlThe push to release runs .github/workflows/release.yml, which compares
package.json's version against the registry and, only if it isn't published
yet, runs npm publish (gated by prepublishOnly: lint + typecheck + test +
build) and then gh release create vX.Y.Z --generate-notes to tag it and cut a
GitHub Release with notes built from the merged history.
| Bump | Command | Example |
|---|---|---|
| patch | yarn version patch | 1.2.0 → 1.2.1 |
| minor | yarn version minor | 1.2.0 → 1.3.0 |
| major | yarn version major | 1.2.0 → 2.0.0 |
Re-pushing release without a version change is a no-op (the version is already
on the registry), so there's nothing to guard against.
One-time setup: create the branch with
git switch -c release master && git push -u origin release, and configure npm trusted publishing (OIDC) for the package on npmjs (orgSARC-MedIQ, reporeact-diagrams, workflowrelease.yml). The workflow then publishes tokenless via OIDC — no stored secret. (Build provenance is intentionally off: it requires a public source repo, and this one is private.) Consumers need no token either.
Architecture decisions
The initial, hard-to-reverse decisions (standalone library, the findings data
contract, build/preview/test tooling, the erase modes, operation-based undo/redo,
backgroundImageFit, versioning) are recorded in docs/adr/.
License
MIT © SARC-MedIQ
