Annotations
CoarDocumentViewer ships with a built-in annotation layer that supports four types — marker highlights, comment notes, free-hand ink, and freetext boxes. Annotations are controlled: the consumer owns the data, the viewer emits events, the consumer applies changes.
import {
CoarDocumentViewer,
type CoarPdfAnnotation,
type CoarPdfAnnotationMode,
type CoarPdfAnnotationCreatePayload,
type CoarPdfAnnotationUpdatePayload,
} from '@cocoar/vue-document-viewer';Live demo
<template>
<ClientOnly>
<div style="display: flex; flex-direction: column; gap: 8px;">
<div style="display: flex; gap: 8px; flex-wrap: wrap; font-size: 13px;">
<span style="color: var(--vp-c-text-2);">Mode: <strong>{{ annotationMode }}</strong></span>
<span style="color: var(--vp-c-text-2);">Annotations: <strong>{{ annotations.length }}</strong></span>
<button
type="button"
:disabled="annotations.length === 0"
style="font-size: 12px; padding: 2px 8px; border-radius: 4px; cursor: pointer;"
@click="annotations = []"
>Clear all</button>
</div>
<div style="height: 520px; border: 1px solid var(--vp-c-divider); border-radius: 8px; overflow: hidden;">
<component
:is="Viewer"
v-if="Viewer && source"
:source="source"
v-model:annotation-mode="annotationMode"
:annotations="annotations"
:show-annotations-panel="true"
@annotation:create="onCreate"
@annotation:update="onUpdate"
@annotation:delete="onDelete"
/>
<div v-else class="dv-loading">Loading viewer…</div>
</div>
<p style="font-size: 12px; color: var(--vp-c-text-2); margin: 0;">
Pick a drawing mode (marker / note / draw / text) in the toolbar, then
interact with the image. The consumer owns the annotations array; the
viewer emits create/update/delete and the parent assigns IDs.
</p>
</div>
</ClientOnly>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, shallowRef, type Component } from 'vue';
import { SINGLE_IMAGE_URL } from './_shared';
// Loose runtime types — full types are imported at code-block level on the
// annotations.md page itself. SSR has to skip the package entirely (it pulls
// browser-only DOM APIs at module eval time).
type Anno = { id: string; type: string; pageIndex: number; color: string; createdAt: string; comment?: string } & Record<string, unknown>;
type CreatePayload = Omit<Anno, 'id' | 'createdAt'>;
type UpdatePayload = { id: string; patch: Partial<Anno> };
const Viewer = shallowRef<Component | null>(null);
const imageSource = shallowRef<((opts: { url: string }) => unknown) | null>(null);
const source = computed(() => imageSource.value?.({ url: SINGLE_IMAGE_URL }) ?? null);
const annotationMode = ref<'view' | 'select' | 'eraser' | 'marker' | 'comment' | 'ink' | 'freetext'>('view');
const annotations = ref<Anno[]>([]);
let nextId = 1;
function makeId() {
return `demo-${Date.now()}-${nextId++}`;
}
function onCreate(payload: CreatePayload) {
annotations.value = [
...annotations.value,
{ ...payload, id: makeId(), createdAt: new Date().toISOString() } as Anno,
];
}
function onUpdate(payload: UpdatePayload) {
annotations.value = annotations.value.map((a) =>
a.id === payload.id ? ({ ...a, ...payload.patch } as Anno) : a,
);
}
function onDelete(id: string) {
annotations.value = annotations.value.filter((a) => a.id !== id);
}
onMounted(async () => {
const mod = await import('@cocoar/vue-document-viewer');
await import('@cocoar/vue-document-viewer/styles');
imageSource.value = mod.imageSource;
Viewer.value = mod.CoarDocumentViewer;
});
</script>
<style scoped>
.dv-loading {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--coar-text-neutral-tertiary, #6b7280);
font-size: 13px;
}
</style>
Pick a mode in the toolbar (marker / note / draw / text), then interact with the image. The consumer assigns id + createdAt on creation, merges patches on update, and removes on delete. Try switch to Select, then drag an existing annotation, or click the 3-dot menu in the right panel.
Annotation types
Four discriminated-union types, all sharing a BaseAnnotation shape:
interface BaseAnnotation {
id: string;
type: 'marker' | 'comment' | 'ink' | 'freetext';
pageIndex: number; // 0-based
color: string; // CSS color
createdAt: string; // ISO timestamp
createdBy?: string; // Consumer-provided display string
comment?: string; // Every type may carry a side comment
}Marker (highlighter)
interface CoarPdfMarkerAnnotation extends BaseAnnotation {
type: 'marker';
strokes: CoarPdfPoint[][]; // SVG-style polyline list
width: number; // Stroke width in CSS px at zoom=1
}Drawn like a felt-tip marker, rendered with mix-blend-mode: multiply so the underlying text reads through.
Comment pin
interface CoarPdfCommentAnnotation extends BaseAnnotation {
type: 'comment';
anchor: CoarPdfPoint; // Pin location in normalized page coords
comment: string; // REQUIRED for comment annotations
}Drops a pin at the click point and opens a popover for the comment body. The comment text is mandatory — empty pins are never created.
Ink (freehand)
interface CoarPdfInkAnnotation extends BaseAnnotation {
type: 'ink';
strokes: CoarPdfPoint[][];
width: number;
}Same wire shape as marker but rendered as opaque strokes (no blend mode, thinner default width).
Freetext
interface CoarPdfFreetextAnnotation extends BaseAnnotation {
type: 'freetext';
rect: CoarPdfRect; // Box geometry in normalized page coords
text: string; // The visible label
fontSize: number; // CSS px at zoom=1
}User clicks to drop a text box; the textarea writes to text (not comment). Optional comment still works as a side note.
Coordinate system
All coordinates are page-relative and normalized to [0..1]:
interface CoarPdfPoint { x: number; y: number; }
interface CoarPdfRect { x: number; y: number; w: number; h: number; }This means the same annotation renders correctly at any zoom level and rotation — the viewer multiplies by the current viewport at render time. Storage stays portable: an annotation drawn at 100 % zoom plays back identically at 250 %, on a different screen, or after a rotation.
Lifecycle
The viewer never mutates the annotations array directly. Instead it emits three events that the consumer applies:
Create
import { v4 as uuid } from 'uuid';
function onCreate(payload: CoarPdfAnnotationCreatePayload) {
annotations.value = [
...annotations.value,
{
...payload,
id: uuid(),
createdAt: new Date().toISOString(),
createdBy: currentUser.displayName,
} as CoarPdfAnnotation,
];
}CoarPdfAnnotationCreatePayload is CoarPdfAnnotation with the consumer-owned fields stripped ('id' | 'createdAt' | 'createdBy'). The viewer fills in everything else — type, color, geometry, stroke data — based on the active mode and pointer input.
Update
function onUpdate({ id, patch }: CoarPdfAnnotationUpdatePayload) {
annotations.value = annotations.value.map((a) =>
a.id === id ? ({ ...a, ...patch } as CoarPdfAnnotation) : a,
);
}Fired when the user edits an annotation through its popover (color, comment, freetext body) or drags it to a new position (anchor / rect / strokes). patch is a partial of the relevant annotation shape — apply with a plain spread.
Delete
function onDelete(id: string) {
annotations.value = annotations.value.filter((a) => a.id !== id);
}Fired when the user picks "Delete" from the panel's 3-dot menu, or when the eraser tool removes the last stroke from a marker / ink annotation.
Modes
type CoarPdfAnnotationMode =
| 'view' // Read-only — existing annotations clickable, no new ones
| 'select' // Existing annotations clickable AND draggable
| 'eraser' // Click a stroke on marker/ink to remove it
| 'marker' // Drawing
| 'comment' // Drawing — drops a pin
| 'ink' // Drawing
| 'freetext'; // Drawing — drops a text boxMode is two-way bound — the toolbar's mode buttons update it, but the consumer can also drive it from outside (e.g. a keyboard shortcut, a parent's "Start commenting" CTA):
<CoarDocumentViewer
:source="src"
v-model:annotation-mode="mode"
:annotations="annotations"
/>// Keyboard shortcut from anywhere
useEventListener(window, 'keydown', (e) => {
if (e.key === 'm' && (e.ctrlKey || e.metaKey)) mode.value = 'marker';
});Color palette
The mode picker shows a color selector with seven defaults:
| Color | Hex |
|---|---|
| Yellow | #fde68a |
| Pink | #fca5a5 |
| Green | #86efac |
| Blue | #93c5fd |
| Purple | #c4b5fd |
| Saturated yellow | #facc15 |
| Hot pink | #ec4899 |
Pass :annotation-colors="[...]" to override the entire palette. Each color is a CSS string — anything rgb(), hsl(), #xxx, oklch() etc. that the browser accepts works.
Panel + filters
:show-annotations-panel="true" opens the right rail with:
- Info section at the top (source metadata — see Overview).
- Annotation list — every annotation, grouped by page or chronologically. Click an entry to select + scroll-to. 3-dot menu per entry: edit comment, change color, delete.
- Filter chips — toggle each type on/off (marker / note / ink / freetext).
- Search input — substring search over comments and freetext bodies.
- Sort toggle — By page / Chronological.
The list is read-only — every interaction goes through the same annotation:update / annotation:delete event pipeline as the in-page surface, so the consumer-owned state stays canonical.
Worked example — collaborative review
<script setup lang="ts">
import { ref, computed } from 'vue';
import {
CoarDocumentViewer,
type CoarPdfAnnotation,
type CoarPdfAnnotationMode,
type CoarPdfAnnotationCreatePayload,
type CoarPdfAnnotationUpdatePayload,
} from '@cocoar/vue-document-viewer';
import { pdfSource } from '@cocoar/vue-document-viewer/pdf';
import { v4 as uuid } from 'uuid';
const props = defineProps<{
documentId: string;
currentUser: { id: string; displayName: string };
}>();
const annotations = ref<CoarPdfAnnotation[]>([]);
const mode = ref<CoarPdfAnnotationMode>('view');
const source = computed(() =>
pdfSource({ url: `/api/files/${props.documentId}/source.pdf`, withCredentials: true }),
);
// Initial load
onMounted(async () => {
annotations.value = await api.listAnnotations(props.documentId);
});
async function onCreate(payload: CoarPdfAnnotationCreatePayload) {
const next: CoarPdfAnnotation = {
...payload,
id: uuid(),
createdAt: new Date().toISOString(),
createdBy: props.currentUser.displayName,
} as CoarPdfAnnotation;
// Optimistic insert; rollback on failure
annotations.value = [...annotations.value, next];
try { await api.createAnnotation(props.documentId, next); }
catch (err) { annotations.value = annotations.value.filter(a => a.id !== next.id); throw err; }
}
async function onUpdate({ id, patch }: CoarPdfAnnotationUpdatePayload) {
const before = annotations.value;
annotations.value = annotations.value.map(a => a.id === id ? { ...a, ...patch } as CoarPdfAnnotation : a);
try { await api.updateAnnotation(props.documentId, id, patch); }
catch (err) { annotations.value = before; throw err; }
}
async function onDelete(id: string) {
const before = annotations.value;
annotations.value = annotations.value.filter(a => a.id !== id);
try { await api.deleteAnnotation(props.documentId, id); }
catch (err) { annotations.value = before; throw err; }
}
</script>
<template>
<CoarDocumentViewer
:source="source"
v-model:annotation-mode="mode"
:annotations="annotations"
:show-annotations-panel="true"
storage-key="`doc-${documentId}`"
@annotation:create="onCreate"
@annotation:update="onUpdate"
@annotation:delete="onDelete"
/>
</template>The controlled pattern makes optimistic updates + rollback trivial: snapshot annotations.value, apply locally, and revert on API failure.
Persistence shape
Annotations are plain JSON — no Date objects, no class instances, no internal references. Store as-is:
// PostgreSQL JSONB column
CREATE TABLE annotations (
id UUID PRIMARY KEY,
document_id UUID NOT NULL REFERENCES documents,
data JSONB NOT NULL -- one CoarPdfAnnotation
);// MongoDB / Firestore — embed directly
{ documentId: "...", annotations: CoarPdfAnnotation[] }Because coordinates are normalized, annotations migrate across documents only when the page geometry matches; they're fully portable across rendering environments (different screens, zoom levels, rotation).
Tips
- Always wrap
annotationsmutations in a fresh array ([...],.map,.filter) so Vue's reactivity sees the change. - Generate the id on create, not before — the create event fires only after the user finishes the drawing gesture (releases the mouse / taps "Save"). Pre-creating ids causes orphan entries when the gesture is cancelled.
- Touch interactions work the same as mouse — drawing modes accept both. The viewer normalizes pointer events internally.
- Eraser is destructive — clicking a stroke on a marker/ink annotation deletes that stroke; deleting the last stroke fires
annotation:deletefor the whole annotation. There's no per-stroke undo built in; consumers wanting one should keep a history snapshot ring around theirannotationsarray.