Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 50 additions & 27 deletions demos/custom-ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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
Expand All @@ -47,36 +56,50 @@ function AppInner() {
return (
<div className="app">
<header className="app-header">
<h1>Contract Review Workspace</h1>
<span className="subtitle">Memorandum · review pending</span>
</header>
<h1>Contract Review Workspace</h1>
<span className="subtitle">Memorandum · review pending</span>
</header>

<div className="app-body">
<section className="editor-area">
<div className="toolbar-shell">
<Toolbar onComposeComment={openComposer} />
<div className="app-body">
<section className="editor-area">
<div className="toolbar-shell">
<Toolbar onComposeComment={openComposer} />
</div>
<div className="editor-shell">
<div className="editor-canvas">
<EditorMount />
</div>
<div className="editor-shell">
<div className="editor-canvas">
<EditorMount />
</div>
</div>
<SelectionPopover onComposeComment={openComposer} />
<ContextMenu />
<ContextMenuRegistrations decided={decided} onComposeComment={openComposer} />
</section>
</div>
<SelectionPopover onComposeComment={openComposer} />
<ContextMenu />
<ContextMenuRegistrations decided={decided} onComposeComment={openComposer} />
<CitationHighlights />
<CitationPopover />
</section>

<aside className="sidebar">
<div className="sidebar-header">Activity</div>
<div className="sidebar-panel">
<ActivitySidebar
composeOpen={composeOpen}
onCloseComposer={closeComposer}
decided={decided}
/>
</div>
</aside>
</div>
<aside className="sidebar">
<div className="sidebar-tabs">
<button
className={`sidebar-tab ${activeTab === 'activity' ? 'active' : ''}`}
onClick={() => setActiveTab('activity')}
>
Activity
</button>
<button
className={`sidebar-tab ${activeTab === 'sources' ? 'active' : ''}`}
onClick={() => setActiveTab('sources')}
>
Sources
</button>
</div>
<div className="sidebar-panel">
{activeTab === 'activity' && (
<ActivitySidebar composeOpen={composeOpen} onCloseComposer={closeComposer} decided={decided} />
)}
{activeTab === 'sources' && <CitationsPanel />}
</div>
</aside>
</div>
</div>
);
}
134 changes: 134 additions & 0 deletions demos/custom-ui/src/components/CitationHighlights.tsx
Original file line number Diff line number Diff line change
@@ -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<HighlightEntry[]>([]);

// tag (= metadata id) → PM node id. Refreshes whenever the slice
// items array reference changes.
const tagToNodeId = useMemo(() => {
const map = new Map<string, string>();
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 (
<div className="citation-highlights" aria-hidden>
{entries.flatMap((entry) =>
entry.rects.map((rect, i) => (
<div
key={`${entry.metadataId}:${i}`}
className="citation-highlight"
data-citation-id={entry.metadataId}
title={entry.tooltip}
style={{
position: 'fixed',
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
}}
/>
)),
)}
</div>
);
}
128 changes: 128 additions & 0 deletions demos/custom-ui/src/components/CitationPopover.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof useSuperDocHost>): 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<HoverState>(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 (
<div
className="citation-popover"
style={{
position: 'fixed',
left: hover.x + 12,
top: hover.y + 12,
pointerEvents: 'none',
}}
role="tooltip"
>
<div className="citation-popover-source">{p.displayText}</div>
{p.locator && <div className="citation-popover-locator">{p.locator}</div>}
{p.excerpt && (
<div className="citation-popover-excerpt">
&ldquo;{p.excerpt}&rdquo;
</div>
)}
<div className="citation-popover-meta">
<span className="citation-popover-provider">{p.provider}</span>
{typeof p.confidence === 'number' && (
<>
{' · '}
<span className="citation-popover-confidence">conf {p.confidence.toFixed(2)}</span>
</>
)}
</div>
</div>
);
}
Loading
Loading