diff --git a/demos/custom-ui/src/App.tsx b/demos/custom-ui/src/App.tsx
index 691e3812e0..d8a082e932 100644
--- a/demos/custom-ui/src/App.tsx
+++ b/demos/custom-ui/src/App.tsx
@@ -7,6 +7,9 @@ import { SelectionPopover } from './components/SelectionPopover';
import { ContextMenu } from './components/ContextMenu';
import { ContextMenuRegistrations } from './components/ContextMenuRegistrations';
import { useDecidedChanges } from './components/useDecidedChanges';
+import { CitationsPanel } from './components/CitationsPanel';
+import { CitationHighlights } from './components/CitationHighlights';
+import { CitationPopover } from './components/CitationPopover';
export function App() {
return (
@@ -29,6 +32,8 @@ function AppInner() {
// the simplest path; a real product might dispatch through a state
// store, but the example keeps the wiring obvious.
const [composeOpen, setComposeOpen] = useState(false);
+ const [citationComposeOpen, setCitationComposeOpen] = useState(false);
+ const [activeTab, setActiveTab] = useState<'activity' | 'citations'>('activity');
// Shared decided-changes store. Both ActivitySidebar (per-card
// accept/reject buttons) and the right-click context menu route
// through `decided.decideChange` so the Resolved audit row shows
@@ -43,6 +48,11 @@ function AppInner() {
// every consumer that follows the pattern.
const openComposer = useCallback(() => setComposeOpen(true), []);
const closeComposer = useCallback(() => setComposeOpen(false), []);
+ const openCitationComposer = useCallback(() => {
+ setCitationComposeOpen(true);
+ setActiveTab('citations');
+ }, []);
+ const closeCitationComposer = useCallback(() => setCitationComposeOpen(false), []);
return (
@@ -61,19 +71,42 @@ function AppInner() {
-
+
+
+
diff --git a/demos/custom-ui/src/components/CitationComposer.tsx b/demos/custom-ui/src/components/CitationComposer.tsx
new file mode 100644
index 0000000000..0d5f19cf81
--- /dev/null
+++ b/demos/custom-ui/src/components/CitationComposer.tsx
@@ -0,0 +1,132 @@
+import { useEffect, useMemo, useRef, useState } from 'react';
+import type { SelectionCapture } from 'superdoc/ui';
+import { useSuperDocUI } from 'superdoc/ui/react';
+import type { SelectionTarget } from './citations-types';
+import { useCitations } from './useCitations';
+
+interface Props {
+ /** Close without posting. */
+ onCancel(): void;
+ /** Called after a successful attach so the parent can dismiss / scroll. */
+ onPosted(id: string | null): void;
+}
+
+/**
+ * Inline composer for new citations. Mirrors `CommentComposer`'s
+ * capture pattern: freeze the editor selection at mount so focusing
+ * the form fields doesn't tear it down. On submit, the captured
+ * selection feeds `metadata.attach` via `useCitations.attachAtSelection`.
+ */
+export function CitationComposer({ onCancel, onPosted }: Props) {
+ const ui = useSuperDocUI();
+ const { attach } = useCitations();
+ const [citationId, setCitationId] = useState('');
+ const [sourceId, setSourceId] = useState('');
+ const [displayText, setDisplayText] = useState('');
+ const [locator, setLocator] = useState('');
+ const [posting, setPosting] = useState(false);
+ const [error, setError] = useState(null);
+ const citationIdRef = useRef(null);
+
+ // Capture once at mount and hold it. SelectionCapture's quotedText
+ // gives us a preview, and the capture's TextTarget feeds attach.
+ const captured: SelectionCapture | null = useMemo(() => ui?.selection.capture() ?? null, [ui]);
+
+ useEffect(() => {
+ citationIdRef.current?.focus();
+ }, []);
+
+ // `SelectionCapture` is a SelectionSlice; `selectionTarget` is the
+ // SelectionTarget shape `metadata.attach` accepts directly.
+ const capturedTarget =
+ ((captured as unknown as { selectionTarget?: SelectionTarget | null } | null)?.selectionTarget ?? null);
+ const canPost =
+ !!ui &&
+ !!captured &&
+ !posting &&
+ citationId.trim().length > 0 &&
+ sourceId.trim().length > 0 &&
+ displayText.trim().length > 0 &&
+ capturedTarget !== null;
+
+ const post = () => {
+ if (!ui || !canPost || !capturedTarget) return;
+ setPosting(true);
+ setError(null);
+ const result = attach(capturedTarget, {
+ citationId: citationId.trim(),
+ sourceId: sourceId.trim(),
+ displayText: displayText.trim(),
+ locator: locator.trim() || undefined,
+ });
+ setPosting(false);
+ if ('error' in result) {
+ setError(result.error);
+ return;
+ }
+ // Restore the editor's visible selection so the user can keep editing.
+ if (captured) ui.selection.restore(captured);
+ onPosted(result.id);
+ };
+
+ const cancel = () => {
+ if (ui && captured) ui.selection.restore(captured);
+ onCancel();
+ };
+
+ return (
+
+ );
+}
diff --git a/demos/custom-ui/src/components/CitationHighlights.tsx b/demos/custom-ui/src/components/CitationHighlights.tsx
new file mode 100644
index 0000000000..7138893ecc
--- /dev/null
+++ b/demos/custom-ui/src/components/CitationHighlights.tsx
@@ -0,0 +1,98 @@
+import { useEffect, useMemo, useState } from 'react';
+import type { ViewportRect } from 'superdoc/ui';
+import { useSuperDocContentControls, useSuperDocUI } from 'superdoc/ui/react';
+import { useCitations } from './useCitations';
+
+/**
+ * Renders absolute-positioned overlay rectangles on every cited span.
+ *
+ * Two-step lookup. `editor.doc.metadata.*` keys by the metadata id
+ * (which is the SDT's `w:tag`); `ui.contentControls.getRect({ id })`
+ * keys by the SDT's PM node id (which the painter stamps as
+ * `data-sdt-id`). These are different identifiers. The contentControls
+ * slice surfaces both per item (`target.nodeId` + `properties.tag`),
+ * so we build a tag → nodeId map and translate at measure time.
+ *
+ * `getRect` returns `rects[]` — one ViewportRect per painted line of a
+ * wrapped span — so line-wrapped citations get clean per-line
+ * underlines without spilling across the page margin.
+ *
+ * Scroll and resize trigger a re-measure so the highlights stay glued.
+ */
+type HighlightEntry = { metadataId: string; tooltip: string; rects: ViewportRect[] };
+
+type CCItem = { target?: { nodeId?: string }; properties?: { tag?: string } };
+
+export function CitationHighlights() {
+ const ui = useSuperDocUI();
+ const { citations } = useCitations();
+ const cc = useSuperDocContentControls();
+ const [entries, setEntries] = useState([]);
+
+ // tag (= metadata id) → PM node id. Refreshes whenever the slice
+ // items array reference changes.
+ const tagToNodeId = useMemo(() => {
+ const map = new Map();
+ for (const item of (cc.items ?? []) as unknown as CCItem[]) {
+ const tag = item.properties?.tag;
+ const nodeId = item.target?.nodeId;
+ if (typeof tag === 'string' && typeof nodeId === 'string') {
+ map.set(tag, nodeId);
+ }
+ }
+ return map;
+ }, [cc.items]);
+
+ useEffect(() => {
+ if (!ui) {
+ setEntries([]);
+ return;
+ }
+
+ const remeasure = () => {
+ const next: HighlightEntry[] = [];
+ for (const c of citations) {
+ const nodeId = tagToNodeId.get(c.id);
+ if (!nodeId) continue;
+ const result = ui.contentControls.getRect({ id: nodeId });
+ if (!result.success) continue;
+ next.push({
+ metadataId: c.id,
+ tooltip: `${c.payload.displayText} (${c.payload.citationId})`,
+ rects: result.rects,
+ });
+ }
+ setEntries(next);
+ };
+
+ remeasure();
+ window.addEventListener('scroll', remeasure, true);
+ window.addEventListener('resize', remeasure);
+ return () => {
+ window.removeEventListener('scroll', remeasure, true);
+ window.removeEventListener('resize', remeasure);
+ };
+ }, [ui, citations, tagToNodeId]);
+
+ return (
+
+ {entries.flatMap((entry) =>
+ entry.rects.map((rect, i) => (
+
+ )),
+ )}
+
+ );
+}
diff --git a/demos/custom-ui/src/components/CitationPopover.tsx b/demos/custom-ui/src/components/CitationPopover.tsx
new file mode 100644
index 0000000000..9f65c93193
--- /dev/null
+++ b/demos/custom-ui/src/components/CitationPopover.tsx
@@ -0,0 +1,117 @@
+import { useEffect, useState } from 'react';
+import { useSuperDocUI, useSuperDocHost } from 'superdoc/ui/react';
+import { CITATIONS_NAMESPACE, isCitationPayload, type CitationPayload, type MetadataDocApi } from './citations-types';
+
+const HOVER_DEBOUNCE_MS = 60;
+
+type HoverState = { id: string; payload: CitationPayload; x: number; y: number } | null;
+
+function getMetadataApi(host: ReturnType): MetadataDocApi | null {
+ if (!host) return null;
+ const editor = (host as unknown as { activeEditor?: { doc?: { metadata?: MetadataDocApi } } }).activeEditor;
+ return editor?.doc?.metadata ?? null;
+}
+
+/**
+ * Hover-driven popover for cited spans. Composes existing primitives:
+ *
+ * - `mousemove` listener (throttled) over the editor area
+ * - `ui.viewport.entityAt({ x, y })` → ordered list of hits, innermost first
+ * - Take the first `type: 'contentControl'` hit's `tag` (the metadata id)
+ * - `editor.doc.metadata.get({ id })` → payload
+ * - Filter on namespace + payload-shape guard to ignore non-citation SDTs
+ *
+ * This is exactly the surface the SD-3104 PR claims is "composable
+ * from existing primitives." Building it here proves that claim or
+ * surfaces the friction. If it turns out to be awkward, that's the
+ * evidence to file a first-class `ui.metadata({ namespace }).observe(...)`
+ * convenience handle.
+ */
+export function CitationPopover() {
+ const ui = useSuperDocUI();
+ const host = useSuperDocHost();
+ const [hover, setHover] = useState(null);
+
+ useEffect(() => {
+ if (!ui) return;
+ let raf: number | null = null;
+ let lastFire = 0;
+
+ const onMove = (e: MouseEvent) => {
+ const now = performance.now();
+ if (now - lastFire < HOVER_DEBOUNCE_MS) return;
+ lastFire = now;
+
+ if (raf != null) cancelAnimationFrame(raf);
+ raf = requestAnimationFrame(() => {
+ raf = null;
+ // `ui` is typed `any` here because the published superdoc/ui
+ // declaration file isn't picked up by the demo's tsconfig
+ // (pre-existing debt). Annotating `h` keeps this file free of
+ // implicit-any errors at the demo TS check.
+ type ViewportHit = { type: string; id?: string; tag?: string; scope?: 'block' | 'inline' };
+ type ContentControlHit = { type: 'contentControl'; id: string; tag?: string; scope?: 'block' | 'inline' };
+ const hits: ViewportHit[] = ui.viewport.entityAt({ x: e.clientX, y: e.clientY });
+ // Innermost first per the entityAt contract; the first
+ // contentControl hit is the one the cursor is currently over.
+ const ccHit = hits.find((h: ViewportHit): h is ContentControlHit =>
+ h.type === 'contentControl' && typeof h.id === 'string',
+ );
+ if (!ccHit || !ccHit.tag) {
+ setHover(null);
+ return;
+ }
+ // ccHit.tag IS the metadata id when the SDT was attached via
+ // metadata.attach (the adapter sets w:tag = metadataId).
+ const api = getMetadataApi(host);
+ if (!api) {
+ setHover(null);
+ return;
+ }
+ const info = api.get({ id: ccHit.tag });
+ if (!info || info.namespace !== CITATIONS_NAMESPACE || !isCitationPayload(info.payload)) {
+ setHover(null);
+ return;
+ }
+ setHover({ id: info.id, payload: info.payload, x: e.clientX, y: e.clientY });
+ });
+ };
+
+ const onLeave = () => {
+ if (raf != null) cancelAnimationFrame(raf);
+ raf = null;
+ setHover(null);
+ };
+
+ window.addEventListener('mousemove', onMove);
+ window.addEventListener('mouseleave', onLeave);
+ return () => {
+ if (raf != null) cancelAnimationFrame(raf);
+ window.removeEventListener('mousemove', onMove);
+ window.removeEventListener('mouseleave', onLeave);
+ };
+ }, [ui, host]);
+
+ if (!hover) return null;
+
+ return (
+
+
{hover.payload.displayText}
+ {hover.payload.locator &&
{hover.payload.locator}
}
+
+ {hover.payload.citationId}
+ {' · '}
+ {new Date(hover.payload.createdAt).toLocaleDateString()}
+
+
+ );
+}
diff --git a/demos/custom-ui/src/components/CitationsPanel.tsx b/demos/custom-ui/src/components/CitationsPanel.tsx
new file mode 100644
index 0000000000..e00f3179b1
--- /dev/null
+++ b/demos/custom-ui/src/components/CitationsPanel.tsx
@@ -0,0 +1,134 @@
+import { useState } from 'react';
+import { useSuperDocUI } from 'superdoc/ui/react';
+import { selectionTargetToTextTarget, type CitationInfo } from './citations-types';
+import { useCitations } from './useCitations';
+import { CitationComposer } from './CitationComposer';
+
+interface Props {
+ composeOpen: boolean;
+ onCloseComposer(): void;
+}
+
+/**
+ * Sidebar panel showing every citation in the document plus an
+ * inline composer when triggered from the toolbar / selection
+ * popover. Each card has Scroll-to (resolve + viewport.scrollIntoView),
+ * Edit (inline update form → metadata.update), and Remove (metadata.remove).
+ */
+export function CitationsPanel({ composeOpen, onCloseComposer }: Props) {
+ const ui = useSuperDocUI();
+ const { citations, resolve, remove, loading } = useCitations();
+ const [editingId, setEditingId] = useState(null);
+
+ const scrollTo = async (id: string) => {
+ if (!ui) return;
+ const selectionTarget = resolve(id);
+ const textTarget = selectionTargetToTextTarget(selectionTarget);
+ if (!textTarget) return;
+ await ui.viewport.scrollIntoView({ target: textTarget });
+ };
+
+ return (
+
+ {composeOpen && (
+
{
+ onCloseComposer();
+ if (id) void scrollTo(id);
+ }}
+ />
+ )}
+
+ {loading &&
Loading…
}
+ {!loading && citations.length === 0 && (
+
+ No citations yet. Select text and click Cite.
+
+ )}
+ {citations.map((c) => (
+
+
+ {c.payload.displayText}
+ {c.payload.locator && {c.payload.locator}}
+
+
+ {c.payload.citationId}
+ {' · '}
+ {new Date(c.payload.createdAt).toLocaleString()}
+
+ {editingId === c.id ? (
+ setEditingId(null)} />
+ ) : (
+
+
+
+
+
+ )}
+
+ ))}
+
+
+ );
+}
+
+/**
+ * Inline edit form. Exercises `metadata.update` — the sixth method on
+ * the namespace, so the demo's coverage matches SD-3199's scope (all
+ * six metadata.* operations get UI paths, not just the hook). Edit is
+ * payload-only; the anchor stays put.
+ */
+function CitationEditor({ citation, onClose }: { citation: CitationInfo; onClose(): void }) {
+ const { update } = useCitations();
+ const [displayText, setDisplayText] = useState(citation.payload.displayText);
+ const [locator, setLocator] = useState(citation.payload.locator ?? '');
+ const [error, setError] = useState(null);
+
+ const save = () => {
+ setError(null);
+ const result = update(citation.id, {
+ citationId: citation.payload.citationId,
+ sourceId: citation.payload.sourceId,
+ displayText: displayText.trim(),
+ locator: locator.trim() || undefined,
+ confidence: citation.payload.confidence,
+ createdAt: citation.payload.createdAt,
+ });
+ if (result.error) {
+ setError(result.error);
+ return;
+ }
+ onClose();
+ };
+
+ return (
+
+
setDisplayText(e.target.value)}
+ placeholder="Display text"
+ onKeyDown={(e) => {
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') save();
+ if (e.key === 'Escape') onClose();
+ }}
+ />
+
setLocator(e.target.value)}
+ placeholder="Locator (optional)"
+ onKeyDown={(e) => {
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') save();
+ if (e.key === 'Escape') onClose();
+ }}
+ />
+ {error &&
{error}
}
+
+
+
+
+
+ );
+}
diff --git a/demos/custom-ui/src/components/SelectionPopover.tsx b/demos/custom-ui/src/components/SelectionPopover.tsx
index 84c1f7e91b..4be654f86d 100644
--- a/demos/custom-ui/src/components/SelectionPopover.tsx
+++ b/demos/custom-ui/src/components/SelectionPopover.tsx
@@ -5,6 +5,8 @@ import { useSuperDocSelection, useSuperDocUI } from 'superdoc/ui/react';
interface Props {
/** Open the comment composer with the captured selection. */
onComposeComment(): void;
+ /** Open the citation composer with the captured selection. */
+ onComposeCitation(): void;
}
const VIEWPORT_MARGIN = 8;
@@ -23,7 +25,7 @@ const ANCHOR_GAP = 8;
* so the anchor stays glued through layout shifts; the rect is
* viewport-relative so `position: fixed` is enough.
*/
-export function SelectionPopover({ onComposeComment }: Props) {
+export function SelectionPopover({ onComposeComment, onComposeCitation }: Props) {
const ui = useSuperDocUI();
const selection = useSuperDocSelection();
const [rect, setRect] = useState(null);
@@ -110,6 +112,9 @@ export function SelectionPopover({ onComposeComment }: Props) {
+
);
}
diff --git a/demos/custom-ui/src/components/citations-types.ts b/demos/custom-ui/src/components/citations-types.ts
new file mode 100644
index 0000000000..0575b75fcc
--- /dev/null
+++ b/demos/custom-ui/src/components/citations-types.ts
@@ -0,0 +1,118 @@
+/**
+ * Local type declarations for the metadata.* citation flow.
+ *
+ * `editor.doc.metadata.*` exists at runtime (SD-3104) but the
+ * published SuperDocEditorLike `doc?` stub hasn't caught up yet, so
+ * the demo declares the slice it uses here.
+ */
+
+export type SelectionPoint =
+ | { kind: 'text'; blockId: string; offset: number }
+ | { kind: 'nodeEdge'; node: { kind: 'block'; nodeType: string; nodeId: string }; edge: 'before' | 'after' };
+
+export type SelectionTarget = {
+ kind: 'selection';
+ start: SelectionPoint;
+ end: SelectionPoint;
+};
+
+export type TextSegment = { blockId: string; range: { start: number; end: number } };
+export type TextTarget = { kind: 'text'; segments: TextSegment[] };
+
+/**
+ * Per-citation payload. JSON-serializable so it survives DOCX round-trips.
+ *
+ * The SDT `w:tag` is the document anchor key SuperDoc owns. Citation
+ * identity is the customer's foreign key inside the payload:
+ *
+ * - `citationId`: stable id from the customer's citation database.
+ * - `sourceId`: stable id of the cited source (the customer's record
+ * key, not a URL — URLs can change).
+ * - `displayText`: fallback shown when the customer hasn't yet
+ * resolved `sourceId` to a real record. Display only, not identity.
+ * - `locator`: optional pinpoint citation (page, section, etc.).
+ * - `confidence`: optional AI-generated confidence score 0..1.
+ */
+export type CitationPayload = {
+ citationId: string;
+ sourceId: string;
+ displayText: string;
+ locator?: string;
+ confidence?: number;
+ createdAt: string;
+};
+
+export type CitationInfo = {
+ id: string;
+ namespace: string;
+ partName: string;
+ payload: CitationPayload;
+};
+
+export type MetadataAttachResult =
+ | { success: true; id: string; namespace: string; partName: string }
+ | { success: false; failure: { code: string; message: string } };
+
+export type MetadataMutationResult =
+ | { success: true; id: string }
+ | { success: false; failure: { code: string; message: string } };
+
+/** The metadata.* slice this demo reaches into via `editor.doc.metadata`. */
+export type MetadataDocApi = {
+ attach(input: {
+ target: SelectionTarget;
+ namespace: string;
+ payload: unknown;
+ id?: string;
+ }): MetadataAttachResult;
+ list(input?: { namespace?: string; within?: SelectionTarget }): {
+ items: Array<{ id: string; namespace: string; partName: string }>;
+ total: number;
+ };
+ get(input: { id: string }): { id: string; namespace: string; partName: string; payload: unknown } | null;
+ update(input: { id: string; payload: unknown }): MetadataMutationResult;
+ remove(input: { id: string }): MetadataMutationResult;
+ resolve(input: { id: string }): { id: string; target: SelectionTarget } | null;
+};
+
+export const CITATIONS_NAMESPACE = 'urn:superdoc:demo:citations:1';
+
+export function isCitationPayload(payload: unknown): payload is CitationPayload {
+ if (typeof payload !== 'object' || payload === null) return false;
+ const p = payload as Record;
+ return (
+ typeof p.citationId === 'string' &&
+ typeof p.sourceId === 'string' &&
+ typeof p.displayText === 'string' &&
+ typeof p.createdAt === 'string'
+ );
+}
+
+/**
+ * Convert the SelectionInfo TextTarget (from `ui.state.selection.target`)
+ * to a SelectionTarget for `metadata.attach`. v1 requires both endpoints
+ * in the same block; returns null when the selection is empty,
+ * multi-block, or otherwise unsupported.
+ */
+export function textTargetToSelectionTarget(target: TextTarget | null | undefined): SelectionTarget | null {
+ if (!target || target.segments.length === 0) return null;
+ const first = target.segments[0]!;
+ const last = target.segments[target.segments.length - 1]!;
+ if (first.blockId !== last.blockId) return null;
+ if (last.range.end <= first.range.start) return null;
+ return {
+ kind: 'selection',
+ start: { kind: 'text', blockId: first.blockId, offset: first.range.start },
+ end: { kind: 'text', blockId: last.blockId, offset: last.range.end },
+ };
+}
+
+/** Reverse: SelectionTarget (from metadata.resolve) → TextTarget (for viewport.scrollIntoView). */
+export function selectionTargetToTextTarget(target: SelectionTarget | null): TextTarget | null {
+ if (!target) return null;
+ if (target.start.kind !== 'text' || target.end.kind !== 'text') return null;
+ return {
+ kind: 'text',
+ segments: [{ blockId: target.start.blockId, range: { start: target.start.offset, end: target.end.offset } }],
+ };
+}
diff --git a/demos/custom-ui/src/components/useCitations.ts b/demos/custom-ui/src/components/useCitations.ts
new file mode 100644
index 0000000000..fed6e6df8b
--- /dev/null
+++ b/demos/custom-ui/src/components/useCitations.ts
@@ -0,0 +1,153 @@
+import { useCallback, useEffect, useState } from 'react';
+import { useSuperDocContentControls, useSuperDocHost } from 'superdoc/ui/react';
+import {
+ CITATIONS_NAMESPACE,
+ isCitationPayload,
+ type CitationInfo,
+ type CitationPayload,
+ type MetadataDocApi,
+ type SelectionTarget,
+ textTargetToSelectionTarget,
+ type TextTarget,
+} from './citations-types';
+
+/**
+ * Reach into `editor.doc.metadata` even though the published
+ * SuperDocEditorLike `doc?` stub doesn't expose it yet. v1 path
+ * because the metadata.* surface lands in SD-3104; the controller
+ * type can catch up later.
+ */
+function readMetadataApi(host: ReturnType): MetadataDocApi | null {
+ if (!host) return null;
+ const editor = (host as unknown as { activeEditor?: { doc?: { metadata?: MetadataDocApi } } }).activeEditor;
+ return editor?.doc?.metadata ?? null;
+}
+
+/** Hydrate a list of citation entries by fetching their payloads. */
+function hydrate(api: MetadataDocApi): CitationInfo[] {
+ const result = api.list({ namespace: CITATIONS_NAMESPACE });
+ const out: CitationInfo[] = [];
+ for (const summary of result.items) {
+ const info = api.get({ id: summary.id });
+ if (!info || !isCitationPayload(info.payload)) continue;
+ out.push({ id: info.id, namespace: info.namespace, partName: info.partName, payload: info.payload });
+ }
+ return out;
+}
+
+export type UseCitationsResult = {
+ /** Current list. Refreshes when content-controls slice ticks or after each mutation. */
+ citations: CitationInfo[];
+ /** True until SuperDoc is ready. */
+ loading: boolean;
+ /** Attach a citation to a text-range SelectionTarget. */
+ attach(target: SelectionTarget, payload: Omit): { id: string } | { error: string };
+ /** Attach using the current selection (from the UI controller). */
+ attachAtSelection(
+ textTarget: TextTarget | null,
+ payload: Omit,
+ ): { id: string } | { error: string };
+ /** Update an existing citation's payload. */
+ update(id: string, payload: Omit & { createdAt?: string }): { error?: string };
+ /** Remove a citation; strips both anchor + payload. */
+ remove(id: string): { error?: string };
+ /** Resolve a citation id to its current SelectionTarget. */
+ resolve(id: string): SelectionTarget | null;
+ /** Force a re-list (rarely needed; the contentControls slice usually covers it). */
+ refresh(): void;
+};
+
+/**
+ * One-stop hook for the citation demo. Wraps `editor.doc.metadata.*`
+ * and re-lists whenever the content-controls slice changes — that
+ * slice fires for every SDT mutation, so attach/remove flow automatic
+ * refreshes through it. Adapter mutations also call `refresh()`
+ * directly to cover the brief window before the slice tick lands.
+ */
+export function useCitations(): UseCitationsResult {
+ const host = useSuperDocHost();
+ // Subscribe to the contentControls slice so any SDT mutation re-runs us.
+ const cc = useSuperDocContentControls();
+ const [citations, setCitations] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ const refresh = useCallback(() => {
+ const api = readMetadataApi(host);
+ if (!api) return;
+ setCitations(hydrate(api));
+ setLoading(false);
+ }, [host]);
+
+ // Re-run on host availability and on every contentControls slice
+ // tick. ContentControlsSlice exposes `items`, `total`, `activeIds`,
+ // `activeId` — keying on `items` (array reference changes on every
+ // meaningful slice update per shallow-equality) plus `activeId`
+ // gives us SDT-mutation + active-selection coverage. The slice has
+ // no `list` field; an earlier draft referenced one and silently
+ // resolved to `undefined`, leaving the effect frozen after mount.
+ useEffect(() => {
+ refresh();
+ }, [refresh, cc.items, cc.activeId]);
+
+ const attach = useCallback(
+ (target: SelectionTarget, payload: Omit): { id: string } | { error: string } => {
+ const api = readMetadataApi(host);
+ if (!api) return { error: 'Editor not ready.' };
+ const full: CitationPayload = { ...payload, createdAt: new Date().toISOString() };
+ const result = api.attach({ target, namespace: CITATIONS_NAMESPACE, payload: full });
+ if (!result.success) return { error: result.failure.message };
+ refresh();
+ return { id: result.id };
+ },
+ [host, refresh],
+ );
+
+ const attachAtSelection = useCallback(
+ (textTarget: TextTarget | null, payload: Omit) => {
+ const target = textTargetToSelectionTarget(textTarget);
+ if (!target) {
+ return { error: 'Select a non-empty text range inside a single paragraph.' };
+ }
+ return attach(target, payload);
+ },
+ [attach],
+ );
+
+ const update = useCallback(
+ (id: string, payload: Omit & { createdAt?: string }) => {
+ const api = readMetadataApi(host);
+ if (!api) return { error: 'Editor not ready.' };
+ const existing = citations.find((c) => c.id === id);
+ const createdAt = payload.createdAt ?? existing?.payload.createdAt ?? new Date().toISOString();
+ const full: CitationPayload = { ...payload, createdAt };
+ const result = api.update({ id, payload: full });
+ if (!result.success) return { error: result.failure.message };
+ refresh();
+ return {};
+ },
+ [host, citations, refresh],
+ );
+
+ const remove = useCallback(
+ (id: string) => {
+ const api = readMetadataApi(host);
+ if (!api) return { error: 'Editor not ready.' };
+ const result = api.remove({ id });
+ if (!result.success) return { error: result.failure.message };
+ refresh();
+ return {};
+ },
+ [host, refresh],
+ );
+
+ const resolve = useCallback(
+ (id: string): SelectionTarget | null => {
+ const api = readMetadataApi(host);
+ if (!api) return null;
+ return api.resolve({ id })?.target ?? null;
+ },
+ [host],
+ );
+
+ return { citations, loading, attach, attachAtSelection, update, remove, resolve, refresh };
+}
diff --git a/demos/custom-ui/src/styles.css b/demos/custom-ui/src/styles.css
index 0d79c2d23d..91efbe3241 100644
--- a/demos/custom-ui/src/styles.css
+++ b/demos/custom-ui/src/styles.css
@@ -614,3 +614,145 @@ button {
margin: 4px 0;
background: var(--border);
}
+
+/* Citations demo — SD-3104 metadata.* */
+
+.sidebar-tabs {
+ display: flex;
+ border-bottom: 1px solid var(--border);
+}
+.sidebar-tab {
+ flex: 1;
+ padding: 8px 12px;
+ background: transparent;
+ border: none;
+ border-bottom: 2px solid transparent;
+ font-size: 13px;
+ color: var(--text-muted);
+ cursor: pointer;
+}
+.sidebar-tab.active {
+ color: var(--accent);
+ border-bottom-color: var(--accent);
+ font-weight: 600;
+}
+.sidebar-tab:hover {
+ color: var(--text);
+}
+
+.citations-panel {
+ padding: 12px;
+}
+.citations-empty {
+ font-size: 13px;
+ color: var(--text-muted);
+ text-align: center;
+ padding: 24px 8px;
+}
+.citation-card {
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 10px 12px;
+ margin-bottom: 8px;
+ background: var(--bg);
+ box-shadow: var(--shadow);
+}
+.citation-card-header {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ margin-bottom: 6px;
+}
+.citation-source {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text);
+}
+.citation-locator {
+ font-size: 11px;
+ color: var(--text-muted);
+ font-family: 'SF Mono', Menlo, monospace;
+}
+.citation-meta {
+ font-size: 11px;
+ color: var(--text-muted);
+ margin-bottom: 8px;
+}
+.citation-actions {
+ display: flex;
+ gap: 6px;
+}
+.citation-editor {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding-top: 6px;
+ border-top: 1px solid var(--border);
+}
+.citation-editor .composer-input {
+ font-size: 12px;
+ padding: 4px 6px;
+}
+.citation-actions button {
+ font-size: 11px;
+ padding: 4px 8px;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ background: var(--bg);
+ color: var(--text);
+ cursor: pointer;
+}
+.citation-actions button:hover {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+.composer-error {
+ font-size: 12px;
+ color: var(--danger);
+ padding: 4px 0;
+}
+
+/* Highlight overlay for cited spans. Pointer-events disabled so the
+ user can still select text and the painter receives the events. */
+.citation-highlights {
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ z-index: 5;
+}
+.citation-highlight {
+ background: var(--accent-soft);
+ border-bottom: 2px solid var(--accent);
+ pointer-events: none;
+ border-radius: 2px;
+}
+
+/* Hover popover for cited spans. Light-themed to match the rest of
+ the demo's surfaces (context menu, selection popover) instead of
+ the dark tooltip pattern. */
+.citation-popover {
+ background: var(--bg);
+ color: var(--text);
+ padding: 8px 12px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ font-size: 12px;
+ max-width: 280px;
+ box-shadow: var(--shadow);
+ z-index: 30;
+}
+.citation-popover-source {
+ font-weight: 600;
+ margin-bottom: 2px;
+}
+.citation-popover-locator {
+ color: var(--text-muted);
+ font-family: 'SF Mono', Menlo, monospace;
+ font-size: 11px;
+}
+.citation-popover-meta {
+ color: var(--text-muted);
+ font-size: 10px;
+ margin-top: 4px;
+}