Skip to content
Closed
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
47 changes: 40 additions & 7 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,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
Expand All @@ -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 (
<div className="app">
Expand All @@ -61,19 +71,42 @@ function AppInner() {
<EditorMount />
</div>
</div>
<SelectionPopover onComposeComment={openComposer} />
<SelectionPopover onComposeComment={openComposer} onComposeCitation={openCitationComposer} />
<ContextMenu />
<ContextMenuRegistrations decided={decided} onComposeComment={openComposer} />
<CitationHighlights />
<CitationPopover />
</section>

<aside className="sidebar">
<div className="sidebar-header">Activity</div>
<div className="sidebar-tabs">
<button
className={`sidebar-tab ${activeTab === 'activity' ? 'active' : ''}`}
onClick={() => setActiveTab('activity')}
>
Activity
</button>
<button
className={`sidebar-tab ${activeTab === 'citations' ? 'active' : ''}`}
onClick={() => setActiveTab('citations')}
>
Citations
</button>
</div>
<div className="sidebar-panel">
<ActivitySidebar
composeOpen={composeOpen}
onCloseComposer={closeComposer}
decided={decided}
/>
{activeTab === 'activity' && (
<ActivitySidebar
composeOpen={composeOpen}
onCloseComposer={closeComposer}
decided={decided}
/>
)}
{activeTab === 'citations' && (
<CitationsPanel
composeOpen={citationComposeOpen}
onCloseComposer={closeCitationComposer}
/>
)}
</div>
</aside>
</div>
Expand Down
132 changes: 132 additions & 0 deletions demos/custom-ui/src/components/CitationComposer.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);
const citationIdRef = useRef<HTMLInputElement | null>(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, {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Guard unsupported selection targets before attaching citation

SelectionCapture.selectionTarget can represent cross-paragraph ranges, but metadata.attach rejects those targets and throws INVALID_TARGET in that case. This handler enables posting for any non-null capturedTarget and calls attach(...) directly, so selecting text across paragraph boundaries causes an uncaught exception (and leaves the composer in a broken posting state) instead of showing a validation message. Use the existing single-paragraph guard path (attachAtSelection/textTargetToSelectionTarget) or catch and map attach exceptions to setError.

Useful? React with 👍 / 👎.

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 (
<div className="composer">
<div className="composer-quote">
{captured?.quotedText ? <>“{captured.quotedText}”</> : <em>No selection</em>}
</div>
<input
ref={citationIdRef}
className="composer-input"
placeholder="Citation ID (your stable id, e.g. cite-7f3a)"
value={citationId}
onChange={(e) => setCitationId(e.target.value)}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') post();
if (e.key === 'Escape') cancel();
}}
/>
<input
className="composer-input"
placeholder="Source ID (record key in your citation DB)"
value={sourceId}
onChange={(e) => setSourceId(e.target.value)}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') post();
if (e.key === 'Escape') cancel();
}}
/>
<input
className="composer-input"
placeholder="Display text (fallback label, e.g. Smith v. Jones, 2024)"
value={displayText}
onChange={(e) => setDisplayText(e.target.value)}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') post();
if (e.key === 'Escape') cancel();
}}
/>
<input
className="composer-input"
placeholder="Locator (optional, e.g. §3.2 or p. 17)"
value={locator}
onChange={(e) => setLocator(e.target.value)}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') post();
if (e.key === 'Escape') cancel();
}}
/>
{error && <div className="composer-error">{error}</div>}
<div className="composer-actions">
<button onClick={cancel}>Cancel</button>
<button className="primary" disabled={!canPost} onClick={post}>
{posting ? 'Saving…' : 'Cite'}
</button>
</div>
</div>
);
}
98 changes: 98 additions & 0 deletions demos/custom-ui/src/components/CitationHighlights.tsx
Original file line number Diff line number Diff line change
@@ -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<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);
};

remeasure();
window.addEventListener('scroll', remeasure, true);
window.addEventListener('resize', remeasure);
return () => {
window.removeEventListener('scroll', remeasure, true);
window.removeEventListener('resize', remeasure);
};
}, [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>
);
}
Loading
Loading