From ba77d0327496f069560125ac6d227701045d9ab0 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 18 May 2026 17:23:34 -0300 Subject: [PATCH] feat(custom-ui): citation demo over editor.doc.metadata.* (SD-3199) Adds an end-to-end citation flow to the custom-ui demo, exercising every metadata.* method on a real Editor: - Selection popover gains a Cite button next to Comment. - Composer captures the selection (mirrors CommentComposer's pattern) and prompts for the customer-owned identity model: citationId, sourceId, displayText, optional locator. The payload makes explicit that the SDT w:tag is the document anchor key while citation identity is the customer's foreign key. - Citations sidebar tab lists every entry. Scroll-to uses metadata.resolve + ui.viewport.scrollIntoView. Edit reveals an inline form that calls metadata.update. Remove cleans the payload and the anchor. - Highlight overlay paints rects[] from ui.contentControls.getRect for each anchor; line-wrapped citations get per-line underlines. Two-step lookup: metadata id (w:tag) -> SDT node id via the contentControls slice. - Hover popover composes ui.viewport.entityAt + metadata.get, throttled to 60ms + RAF. No new public API surface. Validates the SD-3104 metadata stack against the customer-shaped flow. --- demos/custom-ui/src/App.tsx | 47 +++++- .../src/components/CitationComposer.tsx | 132 +++++++++++++++ .../src/components/CitationHighlights.tsx | 98 +++++++++++ .../src/components/CitationPopover.tsx | 117 ++++++++++++++ .../src/components/CitationsPanel.tsx | 134 +++++++++++++++ .../src/components/SelectionPopover.tsx | 7 +- .../src/components/citations-types.ts | 118 ++++++++++++++ .../custom-ui/src/components/useCitations.ts | 153 ++++++++++++++++++ demos/custom-ui/src/styles.css | 142 ++++++++++++++++ 9 files changed, 940 insertions(+), 8 deletions(-) create mode 100644 demos/custom-ui/src/components/CitationComposer.tsx create mode 100644 demos/custom-ui/src/components/CitationHighlights.tsx create mode 100644 demos/custom-ui/src/components/CitationPopover.tsx create mode 100644 demos/custom-ui/src/components/CitationsPanel.tsx create mode 100644 demos/custom-ui/src/components/citations-types.ts create mode 100644 demos/custom-ui/src/components/useCitations.ts 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 ( +
+
+ {captured?.quotedText ? <>“{captured.quotedText}” : No selection} +
+ setCitationId(e.target.value)} + onKeyDown={(e) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') post(); + if (e.key === 'Escape') cancel(); + }} + /> + setSourceId(e.target.value)} + onKeyDown={(e) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') post(); + if (e.key === 'Escape') cancel(); + }} + /> + setDisplayText(e.target.value)} + onKeyDown={(e) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') post(); + if (e.key === 'Escape') cancel(); + }} + /> + setLocator(e.target.value)} + onKeyDown={(e) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') post(); + if (e.key === 'Escape') cancel(); + }} + /> + {error &&
{error}
} +
+ + +
+
+ ); +} 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; +}