diff --git a/demos/custom-ui/src/App.tsx b/demos/custom-ui/src/App.tsx index 691e3812e0..a064acdf18 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,12 @@ 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); + // Sidebar tab selection. Activity is the default; Sources is the + // citation/RAG panel built on editor.doc.metadata.* (SD-3208). No + // creation flow lives in the selection popover — citations arrive + // via "Generate draft with sources," which is how Harvey-class + // legal-AI products surface citations. + const [activeTab, setActiveTab] = useState<'activity' | 'sources'>('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 @@ -47,36 +56,50 @@ function AppInner() { return (
-

Contract Review Workspace

- Memorandum · review pending -
+

Contract Review Workspace

+ Memorandum · review pending + -
-
-
- +
+
+
+ +
+
+
+
-
-
- -
-
- - - -
+
+ + + + + +
- -
+ +
); } diff --git a/demos/custom-ui/src/components/CitationHighlights.tsx b/demos/custom-ui/src/components/CitationHighlights.tsx new file mode 100644 index 0000000000..d1323c8b23 --- /dev/null +++ b/demos/custom-ui/src/components/CitationHighlights.tsx @@ -0,0 +1,134 @@ +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. + * + * Remeasure triggers: window scroll/resize, ResizeObserver on the + * editor canvas (catches pagination/zoom), and MutationObserver on + * the canvas DOM (catches text edits that move cited spans without + * resizing the canvas). All paths funnel through a single rAF tick + * to coalesce bursts of keystrokes into one remeasure per frame. + * + * This is demo-tier instrumentation. A library would expose a + * dedicated layout/transaction event rather than observing the DOM. + */ +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); + }; + + // Coalesce burst triggers (multi-mutation keystrokes, ResizeObserver + // firing alongside MutationObserver, etc.) into one remeasure per frame. + let rafHandle: number | null = null; + const scheduleRemeasure = () => { + if (rafHandle !== null) return; + rafHandle = requestAnimationFrame(() => { + rafHandle = null; + remeasure(); + }); + }; + + remeasure(); + window.addEventListener('scroll', scheduleRemeasure, true); + window.addEventListener('resize', scheduleRemeasure); + + const canvas = document.querySelector('.editor-canvas'); + const resizeObserver = canvas ? new ResizeObserver(scheduleRemeasure) : null; + if (canvas && resizeObserver) resizeObserver.observe(canvas); + + // Skip the DOM-mutation observer when there are no citations to track — + // keeps the demo from observing the editor body when there's nothing to update. + const mutationObserver = + canvas && citations.length > 0 + ? new MutationObserver(scheduleRemeasure) + : null; + if (canvas && mutationObserver) { + mutationObserver.observe(canvas, { childList: true, subtree: true, characterData: true }); + } + + return () => { + window.removeEventListener('scroll', scheduleRemeasure, true); + window.removeEventListener('resize', scheduleRemeasure); + resizeObserver?.disconnect(); + mutationObserver?.disconnect(); + if (rafHandle !== null) cancelAnimationFrame(rafHandle); + }; + }, [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..b20501769c --- /dev/null +++ b/demos/custom-ui/src/components/CitationPopover.tsx @@ -0,0 +1,128 @@ +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; + + const p = hover.payload; + + return ( +
+
{p.displayText}
+ {p.locator &&
{p.locator}
} + {p.excerpt && ( +
+ “{p.excerpt}” +
+ )} +
+ {p.provider} + {typeof p.confidence === 'number' && ( + <> + {' · '} + conf {p.confidence.toFixed(2)} + + )} +
+
+ ); +} diff --git a/demos/custom-ui/src/components/CitationsPanel.tsx b/demos/custom-ui/src/components/CitationsPanel.tsx new file mode 100644 index 0000000000..5500b41b26 --- /dev/null +++ b/demos/custom-ui/src/components/CitationsPanel.tsx @@ -0,0 +1,206 @@ +import { useMemo, useState } from 'react'; +import { useSuperDocUI } from 'superdoc/ui/react'; +import { selectionTargetToTextTarget, type CitationInfo, type CitationPayload } from './citations-types'; +import { useCitations } from './useCitations'; +import { GenerateDraftButton } from './GenerateDraftButton'; + +type UpdateCitation = (id: string, payload: CitationPayload) => { error?: string }; + +/** + * References panel. Renders citations grouped by `sourceId` — the + * pattern Harvey / CoCounsel / Lexis+ use for the side panel beside + * AI-generated output. Each source group shows the metadata and links + * back to every cited span in the body. No manual composer; citations + * arrive via `metadata.attach` from the mocked generation pipeline. + */ +export function CitationsPanel() { + const ui = useSuperDocUI(); + const { citations, resolve, remove, update, loading } = useCitations(); + const [editingId, setEditingId] = useState(null); + + const groups = useMemo(() => groupBySource(citations), [citations]); + + 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 ( +
+ + + {loading &&
Loading\u2026
} + {!loading && citations.length === 0 && ( +
+ No sources cited yet. Click Insert sample cited draft to insert sample text + with citations already attached. +
+ )} + +
+ {groups.map((group) => ( +
+
+ {group.displayText} + {group.sourceType} +
+
+ {group.provider} + {group.deepLink && ( + <> + {' \u00b7 '} + + Open source + + + )} +
+
    + {group.citations.map((c) => ( +
  • +
    + {c.payload.citationId} + {c.payload.locator && ( + {c.payload.locator} + )} + {typeof c.payload.confidence === 'number' && ( + conf {c.payload.confidence.toFixed(2)} + )} +
    + {editingId === c.id ? ( + setEditingId(null)} /> + ) : ( +
    + + + +
    + )} +
  • + ))} +
+
+ ))} +
+
+ ); +} + +type ReferenceGroup = { + sourceId: string; + sourceType: CitationInfo['payload']['sourceType']; + provider: string; + displayText: string; + deepLink?: string; + citations: CitationInfo[]; +}; + +function groupBySource(citations: CitationInfo[]): ReferenceGroup[] { + const bySourceId = new Map(); + for (const c of citations) { + const p = c.payload; + const existing = bySourceId.get(p.sourceId); + if (existing) { + existing.citations.push(c); + } else { + bySourceId.set(p.sourceId, { + sourceId: p.sourceId, + sourceType: p.sourceType, + provider: p.provider, + displayText: p.displayText, + deepLink: p.deepLink, + citations: [c], + }); + } + } + return Array.from(bySourceId.values()); +} + +/** + * Inline edit form. Exercises `metadata.update` — the lawyer can fix + * displayText, locator, or excerpt without re-running the generation + * pipeline. `citationId`, `sourceId`, `sourceType`, and `provider` are + * locked here because changing those would mean a different citation, + * not an edit of this one. A real product would offer "replace this + * citation with a different source" as a separate flow. + * + * `update` is passed from the parent `CitationsPanel` rather than read + * via a child-local `useCitations()`. A payload-only `metadata.update` + * does not change the SDT structure, so the parent's content-controls + * slice does not tick — a child-local hook would only refresh the + * child's own copy of `citations`, leaving the parent panel stale on + * Save. + */ +function CitationEditor({ + citation, + update, + onClose, +}: { + citation: CitationInfo; + update: UpdateCitation; + onClose(): void; +}) { + const [displayText, setDisplayText] = useState(citation.payload.displayText); + const [locator, setLocator] = useState(citation.payload.locator ?? ''); + const [excerpt, setExcerpt] = useState(citation.payload.excerpt); + const [error, setError] = useState(null); + + const save = () => { + setError(null); + const result = update(citation.id, { + ...citation.payload, + displayText: displayText.trim(), + locator: locator.trim() || undefined, + excerpt: excerpt.trim(), + }); + 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(); + }} + /> +