@scaleflex/annotator
v0.1.5
Published
Framework-agnostic image & video annotation Web Component for Scaleflex VXP
Keywords
Readme
@scaleflex/annotator
A framework-agnostic Web Component (Lit 3) for image and video annotation —
pin comments, shape markup, threaded discussions, and a frame-accurate video
timeline. Ships as a standard <sfx-annotator> custom element with a
first-class React wrapper.
Part of the Scaleflex widget family alongside
@scaleflex/uploader and
@scaleflex/asset-picker.
Features
- Pin comments — click to drop a pin and open an anchored comment composer
- Shape markup — rectangle, ellipse, arrow, line, freehand (Konva canvas)
- Threaded comments — replies,
@mentions, emoji, resolve/reopen, filter & sort - Video — timeline with comment markers, frame stepping, playback speed, timestamped annotations that show/hide as the video plays
- Zoom & pan — wheel-to-zoom at cursor, space-drag / pinch pan, fit-to-screen, double-click toggle, fullscreen
- Accessible — DOM pins with ARIA + arrow-key navigation, focus-trapped
popups,
aria-liveannouncements,prefers-reduced-motionaware - Themeable —
--sfx-an-*CSS custom properties (OkLCH) - i18n — i18next-based, every string has an inline English default
Install
npm install @scaleflex/annotatorUsage — Vanilla JS
import '@scaleflex/annotator/define'; // registers <sfx-annotator>
const el = document.querySelector('sfx-annotator');
el.config = {
media: { type: 'image', src: 'https://example.com/image.jpg' },
currentUser: { id: 'u1', name: 'Ada' },
getUsers: (q) => myUsers.filter((u) => u.name.includes(q)), // @mention source
};
// Seed existing annotations
el.setAnnotations({ pins, shapes, comments });
// Listen to events
el.addEventListener('sfx-comment-added', (e) => console.log(e.detail.comment));
el.addEventListener('sfx-pin-clicked', (e) => console.log(e.detail.pin));Usage — React
import { Annotator, type AnnotatorRef } from '@scaleflex/annotator/react';
import { useRef } from 'react';
function Review() {
const ref = useRef<AnnotatorRef>(null);
return (
<Annotator
ref={ref}
config={{ media: { type: 'video', src: '/clip.mp4', fps: 30 } }}
onCommentAdded={(c) => save(c)}
onPinClicked={(pin, comment) => focus(comment)}
style={{ height: 600 }}
/>
);
}Imperative methods on the ref: setAnnotations, getPins/getShapes/getComments,
setTool, resolveComment/reopenComment/deleteComment, play/pause/seekTo,
zoomTo/fitToScreen, toggleFullscreen.
Composable API
The monolithic <sfx-annotator> renders the toolbar, canvas, and comments panel
together. When you need those pieces in different places — e.g. a drawing
overlay on your own media viewer plus a comments panel in a separate tab — mount
them independently and wire them to a single shared AnnotatorSession.
These composable elements each take a .session:
| Element | React | Purpose |
|---|---|---|
| <sfx-annotation-overlay> | <AnnotationOverlay> | Drawing surface (pins + shapes). Overlays your own media in overlay mode. |
| <sfx-annotation-toolbar> | <Toolbar> | Drawing tools only (no comment input). Pair with the on-canvas draft popover + a comments panel. |
| <sfx-composer> | <Composer> | Toolbar + mention-aware comment input (all-in-one). |
| <sfx-comments> | <Comments> | Threaded comments panel (filter/sort/resolve). |
Toolbar vs Composer — use
<Toolbar>when comments are captured elsewhere (the popover that opens after drawing/placing a pin, plus the panel's input), so you don't get a redundant comment box under the media. Use<Composer>for a single self-contained "draw + comment" field (the classic AnnotatingField).
Overlay mode (attach to your own media element)
Unlike the monolith (which renders media.src itself), the overlay can align its
annotation layers over an external <img>/<video>/<canvas> you own — the
host keeps control of the media and its zoom/pan.
import {
createAnnotatorSession,
AnnotatorProvider,
AnnotationOverlay,
Composer,
Comments,
useAnnotatorEvents,
} from '@scaleflex/annotator/react';
import { useRef, useState } from 'react';
function Review() {
const [session] = useState(() =>
createAnnotatorSession({
media: { type: 'image', src: '/image.jpg' },
currentUser: { id: 'u1', name: 'Ada' },
getUsers: (q) => searchUsers(q),
}),
);
const mediaRef = useRef<HTMLImageElement>(null);
// One place to persist every change, wherever the pieces are mounted.
useAnnotatorEvents(
{
onCommentAdded: (c) => api.create(c),
onCommentResolved: (c) => api.resolve(c.id),
onCommentLiked: (c, liked) => api.like(c.id, liked),
},
session,
);
return (
<AnnotatorProvider session={session}>
<div style={{ position: 'relative' }}>
<img ref={mediaRef} src="/image.jpg" />
{/* overlay aligns over your <img>; you own zoom/pan */}
<AnnotationOverlay mediaRef={mediaRef} style={{ position: 'absolute', inset: 0 }} />
</div>
<Composer />
{/* mount the panel anywhere — a tab, a drawer, a sibling route */}
<Comments />
</AnnotatorProvider>
);
}Each component also accepts an explicit session prop (takes precedence over the
provider), so pieces in far-apart React subtrees can share one session without a
common ancestor. The vanilla elements work the same way:
import '@scaleflex/annotator/define';
import { createAnnotatorSession } from '@scaleflex/annotator';
const session = createAnnotatorSession({ media: { type: 'image', src: '/i.jpg' } });
document.querySelector('sfx-annotation-overlay').session = session;
document.querySelector('sfx-annotation-overlay').externalMedia = myImgEl; // overlay mode
document.querySelector('sfx-comments').session = session;
session.on('sfx-comment-added', (e) => api.create(e.detail.comment));See demo/composable.html for a full working example.
Content Security Policy (CSP)
The widget is designed to run under a strict CSP with no 'unsafe-inline'
and no 'unsafe-eval':
- No inline
styleattributes. All dynamic positioning (pins, popovers, tooltips, the canvas transform, timeline markers, colour swatches) is applied through the CSSOM (element.style.setProperty) via an internal directive, which CSP'sstyle-src-attrdoes not intercept. - Component styles use Lit's constructable/adopted stylesheets, which are
CSP-exempt (no
<style>injection in modern browsers). - Konva renders to
<canvas>and only touches element styles via the CSSOM — noeval/new Functionand no injected<style>.
A policy like this is sufficient:
Content-Security-Policy: default-src 'self'; img-src 'self' data: blob:; style-src 'self';Older browsers without
adoptedStyleSheetssupport fall back to Lit injecting<style>elements. If you must support those under a strict policy, setwindow.litNonce = '<your-nonce>'before the widget loads so Lit adds the nonce to those elements.
Usage — CDN
<script src="https://cdn.example.com/sfx-annotator.min.js"></script>
<sfx-annotator id="a"></sfx-annotator>
<script>
document.getElementById('a').config = {
media: { type: 'image', src: '/image.jpg' },
};
</script>Keyboard shortcuts
| Keys | Action |
|------|--------|
| 1–7 | Select tool (select, pin, rectangle, ellipse, arrow, line, freehand) |
| Ctrl/⌘ + Z / Ctrl/⌘ + Y | Undo / redo |
| + / - | Zoom in / out |
| F | Fit to screen |
| Space (hold) + drag | Pan |
| Esc | Cancel current drawing |
| Delete / Backspace | Delete the active comment |
| Tab / arrow keys | Move focus between pins |
Configuration
interface AnnotatorConfig {
media: { type: 'image' | 'video'; src: string; poster?: string; fps?: number };
currentUser?: User;
tools?: { enabled?: ToolType[]; colors?: string[]; strokeWidths?: number[] };
getUsers?: (query: string) => User[] | Promise<User[]>;
showComments?: boolean; // default true
showToolbar?: boolean; // default true
locale?: string; // default 'en'
readOnly?: boolean;
}Events
sfx-comment-added, sfx-comment-resolved, sfx-comment-reopened,
sfx-comment-deleted, sfx-comment-edited, sfx-comment-liked,
sfx-comment-subscribed, sfx-pin-clicked, sfx-shape-added,
sfx-tool-changed, sfx-annotation-changed. All bubble and are composed.
Theming
Override any --sfx-an-* custom property on the host:
sfx-annotator {
--sfx-an-primary: oklch(0.6 0.2 250);
--sfx-an-pin-bg: #ff5a5f;
--sfx-an-panel-width: 360px;
}Migrating from react-filerobot-media-annotator
The legacy library exposed three separate React components (Canvas,
AnnotatingField, Comments) driven by callbacks. The composable API above is
the modern equivalent — map the legacy pieces to a shared session and translate
the data at the boundary (the widget stays generic; the mapping lives in your app):
| Legacy | New |
|---|---|
| <Canvas mediaRef> | <AnnotationOverlay mediaRef> (overlay mode) |
| <AnnotatingField onSubmit getUsers> | <Composer> + session.on('sfx-comment-added') |
| <Comments getComments createReply updateComment deleteComment> | <Comments> + session.setAnnotations(...) + useAnnotatorEvents({...}) |
Comment shape (user→author, meta→top-level fields):
| Legacy | New |
|---|---|
| user.photo_uri | author.avatarUrl |
| meta.resolved: boolean | status: 'open' \| 'resolved' |
| meta.likers / meta.subscribers | likes / subscribers |
| meta.displayTimeStamp | timestamp |
Shape geometry — legacy shapes were pixel coordinates plus canvasDimensions;
the new model is normalized 0–1. Convert once when seeding: divide each
coordinate by its canvas dimension, and rename types circle → ellipse,
pen → freehand. Events emit normalized shapes.
Translations — instead of a flat translations object, call addTranslations
(or set config.locale) with the widget's i18n keys.
Development
npm run dev # demo dev server
npm run build # library build → dist/ (ESM + CJS + d.ts)
npm run build:cdn # single IIFE bundle → dist-cdn/
npm run typecheck
npm testLicense
SEE LICENSE IN LICENSE
