React rendering and editing
On this page
Documentation index · First annotation
Render annotation layers in React and integrate optional measurements and editing while your application owns state.
React
import { useState } from 'react';
import {
applyAnnotationEdits,
generatedSurfaceLayoutDefaults
} from '@ponchia/annotations';
import { AnnotationLayer } from '@ponchia/annotations/react';
import type { Annotation } from '@ponchia/annotations';
const initialAnnotations: Annotation[] = [
{
id: 'step',
anchor: { type: 'box', box: { x: 120, y: 80, width: 160, height: 72 } },
note: { title: 'Step', body: 'Rendered by the React adapter.' }
}
];
const qualityDefaults = generatedSurfaceLayoutDefaults({
layoutLabel: 'Figure annotations'
});
export function FigureAnnotations() {
const [annotations, setAnnotations] = useState(initialAnnotations);
return (
<AnnotationLayer
annotations={annotations}
assertQuality={qualityDefaults.assertQuality}
bounds={{ x: 0, y: 0, width: 640, height: 360 }}
measure="dom"
markerIdPrefix="figure-annotations"
editHandleTabIndex={0}
noteTabIndex={0}
preserveAspectRatio="xMidYMid meet"
editable={{
includeAnchor: true,
noteHandlePosition: 'bottom-right',
keyboardStep: 2,
keyboardLargeStep: 12
}}
previewEdits
onEditEnd={(event) => {
setAnnotations((current) => applyAnnotationEdits(current, event));
}}
onQuality={(event) => console.log(event.summary)}
qualityFormat={qualityDefaults.qualityFormat}
onLayout={(layout) => console.log(layout.annotations.length)}
/>
);
}
AnnotationLayer and useAnnotations use the same headless layout path as the
SVG renderer. The component supports ResizeObserver note measurement,
renderNote, onLayout, onQuality, assertQuality, onTargetAlignment,
assertTargetAlignment, debug boxes, custom labels, host SVG
preserveAspectRatio matching, scoped connector marker IDs via
markerIdPrefix, optional tabbable notes through noteTabIndex, optional edit handles with onEdit,
onEditStart, and onEditEnd callbacks, and server rendering. Edit callbacks
map pointer events through the layer's SVG preserveAspectRatio, then return
suggested placement.manual data for note moves and suggestedAnchor data for
anchor moves; applyAnnotationEdit and applyAnnotationEdits apply those
suggestions immutably when the host app wants to persist them. Focused
edit handles can also be nudged with arrow keys. editHandleTabIndex controls
whether React edit handles participate in normal tab navigation. keyboardStep
controls normal arrow-key movement, keyboardLargeStep controls Shift+arrow movement, and
noteHandlePosition moves the note drag handle to a corner or center that fits
the host surface. Set previewEdits to show the selected note/anchor moving during
pointer gestures without recomputing the full annotation layout. It is opt-in,
keeps other notes and layout-quality diagnostics stable, and uses a direct
connector preview until the host commits with onEditEnd. In browsers,
rapid pointer movements are coalesced into the latest visual preview per
animation frame, while every onEdit movement suggestion is delivered
immediately. Pointer release, cancellation, or unmount drops any pending
visual frame so a stale preview cannot reappear after the gesture ends.
The host remains responsible for persisting the final onEditEnd suggestion.
DOM-measured note identities
Annotation IDs are unique, opaque strings. DOM measurement uses the same IDs as
layout and editing, including reserved-looking names such as __proto__ and
constructor, or an empty string. Resize notifications update only the changed
notes; removing or hiding a measured note discards its old measurement.
Use stable IDs for the lifetime of a note. Replacing the annotation collection
reconciles the full visible measurement set, while disconnected observers cannot
write measurements back into a newer collection. This does not change the
host-controlled layout mode, where the host supplies final note dimensions.
Validate edits before committing
The first example above shows the minimal single-note edit path. When a host
has protected chart/diagram content, an edit can be visually previewed over
that content. previewEdits does not certify layout quality, and
assertQuality is a throwing development assertion, not an interactive
edit admission policy. Applying every patch blindly can therefore crash a
host's next render.
Before changing authored state, resolve the proposed annotations with the
same geometry inputs as the renderer, check evaluateAnnotationLayout, and
either accept the patch or keep the previous state. For example, inside a
host-owned edit handler:
const proposed = applyAnnotationEdits(annotations, event, { missing: 'throw' });
const candidate = resolveAnnotationLayout({
annotations: proposed,
bounds,
obstacles,
noteSizes: effectiveNoteSizes
});
const quality = evaluateAnnotationLayout(candidate);
if (!quality.ok) {
setEditNotice('Move rejected: the note would obscure protected content.');
return;
}
setEditNotice(null);
setAnnotations(proposed);
The host determines effectiveNoteSizes. In measure="dom" mode, use the
latest onLayout result's note-box dimensions instead of relying on estimated
character widths. In render-only mode, validate from the same source options
used by the host-owned solver. Expose rejection feedback in a live region
(e.g. role="status"), so keyboard and touch users know the drop was not
committed. A rejected edit is not an application error and should not corrupt
undo/redo history.
Both runnable React and React Flow examples demonstrate this host boundary through a private shared edit validator, without expanding the published package API.
Render a host-owned layout
When the host resolves its own layout, pass exactly that result:
<AnnotationLayer
layout={layout}
previewEdits
editable={{ includeAnchor: true }}
onEditEnd={(edit) => {
const next = applyAnnotationEdits(annotations, edit);
setAnnotations(next);
setLayout(layoutSession.update({ annotations: next, bounds, noteSizes }));
}}
/>
layoutSession is a host-owned incremental session; layout state starts from
its layout and updates on accepted commits. The host owns persistence and undo.
The renderer derives annotations, note sizes and bounds from this result. It
never solves or self-measures in this mode. Do not also pass annotations,
bounds, placement, noteSizes, refinement, diagnostics, or measure.
TypeScript and runtime validation reject such mixed modes, and the removed
resolvedLayout prop reports a migration error.
Treat resolved layouts and React presentation props as immutable; replace objects when changing them. This removes the old dual-source snapshot and comparison machinery rather than promising detection of mutated React props.
The other mode accepts annotations and bounds, including optional
measure="dom", and owns its own resolution. See the
0.7 migration guide for both forms. Externally resolved debug
rendering uses only diagnostics already requested by the host; it never triggers
an extra solve. Layout winners do not contain alternative candidate graphs.
Custom SVG or canvas-overlay authoring tools can use the same DOM-free edit math without React:
import {
annotationEditHandles,
applyAnnotationEdits,
createAnnotationEditSession,
previewAnnotationEdit,
resolveAnnotationLayout
} from '@ponchia/annotations';
const layout = resolveAnnotationLayout({ annotations, bounds, noteSizes });
const handle = annotationEditHandles(layout, { includeAnchor: true })[0]!;
const edit = createAnnotationEditSession({
layout,
handle,
});
const moving = edit.move({ x: edit.origin.x + 8, y: edit.origin.y + 6 });
const provisional = previewAnnotationEdit(edit.annotation, moving, layout.placementBounds);
// Draw provisional.noteBox / provisional.connector while dragging;
// only commit persisted annotation state at the end of the gesture.
const event = edit.end({ x: edit.origin.x + 12, y: edit.origin.y + 8 });
const nextAnnotations = applyAnnotationEdits(annotations, event);
createAnnotationEditSession wraps start/move/end drag events and delta-based
keyboard nudges for one handle. createAnnotationEditEvent accepts
start/current coordinates directly, and createAnnotationEditDelta is the
lower-level keyboard or nudge variant when the host already has a delta. All
helpers emit the same commit-ready suggestion shape as the React adapter and
still leave persistence to the host app. previewAnnotationEdit is an
experimental, constant-sized visual projection of one edited note/anchor; its
existing candidate scores/quality evidence are intentionally not revalidated.
Commit via the host's normal edit patch and recompute the authoritative layout
at the end of a gesture, rather than persisting a provisional resolved item.
Experimental incremental committed layouts
A custom headless/React host resolving many annotations can reuse unchanged
priority-ordered placements when later annotations change, or when annotations
are appended/removed after an unchanged higher-priority prefix. This is an
opt-in optimization for committed edits, complementary to the immediate
previewAnnotationEdit drag preview:
import {
applyAnnotationEdits,
createIncrementalAnnotationLayoutSession,
evaluateAnnotationLayout
} from '@ponchia/annotations';
const options = { annotations, bounds, noteSizes, obstacles, refinement: false };
const session = createIncrementalAnnotationLayoutSession(options);
// On an edit commit, the host owns the changed annotation collection.
const changed = applyAnnotationEdits(annotations, event);
const authoritative = session.update({ ...options, annotations: changed });
const quality = evaluateAnnotationLayout(authoritative);
The experimental resolver returns identical geometry and quality to a fresh
resolveAnnotationLayout call: candidate scoring and connector routing are
recomputed from the first changed annotation forward. Stable earlier winners
retain their object identities for efficient React reconciliation. No-op updates
return the previous layout object.
It recomputes from the first changed priority-ordered note for insertions,
deletions and changed anchors, preserving the prior prefix. Changed obstacles
also allow conservative prefix reuse only where an annotation has an
explicitly unrouted connector (or an intrinsic curve/none connector) and
the changed boxes cannot touch any of its possible placement or connector
candidates. It examines candidates beyond those retained by diagnostic collection.
Routing-aware connectors, changed bounds/global placement, and iterative
refinement continue to trigger full recalculation when the change could affect
previous winners. Iterative refinement uses the full resolver because later
candidates may change earlier winners. Inputs are snapshotted by value so an in-place change to a
note, anchor or effective size is detected; avoid mutating resolved layout
objects returned by the session. Neither API schedules background work or
manages host state, pointer updates, persistence or worker threads. For a live
drag, use the lightweight visual preview and update the session at commit. For
dense changes that still require a full solver pass, see the
host-owned Web Worker integration recipe: a real
Chromium/React test demonstrates off-main-thread resolution, stale-response
handling and cancellation without adding a Worker dependency to the package.