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
4 changes: 4 additions & 0 deletions apps/docs/editor/custom-ui/api-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ ui.comments.reply(parentCommentId, { text });
ui.comments.resolve(commentId);
ui.comments.reopen(commentId);
ui.comments.delete(commentId);
ui.comments.setActive(commentId); // highlight without scrolling or moving selection
ui.comments.setActive(null); // clear active highlight
ui.comments.scrollTo(commentId);
```

Expand Down Expand Up @@ -242,8 +244,10 @@ Geometry. Browser-only.
```ts
ui.viewport.getRect({ target: { kind: 'entity', entityType: 'comment', entityId } });
await ui.viewport.scrollIntoView({ target, block: 'center', behavior: 'smooth' });
ui.viewport.observe(({ reason }) => {});

ui.viewport.getHost(); // painted host element | null
ui.viewport.getScrollContainer(); // scroll element | null
ui.viewport.entityAt({ x, y }); // ViewportEntityHit[]
ui.viewport.positionAt({ x, y }); // ViewportPositionHit | null
ui.viewport.contextAt({ x, y }); // ViewportContext (always returns)
Expand Down
41 changes: 41 additions & 0 deletions apps/docs/editor/custom-ui/comments.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,36 @@ export function CommentComposer({ onPosted }: { onPosted(): void }) {

See the [selection capture example](https://github.com/superdoc-dev/superdoc/tree/main/examples/editor/custom-ui/selection-capture) for a runnable vanilla version: open a composer, move focus to a textarea, and post against the original selection.

### When the built-in bubble opens your composer

If you keep the built-in floating comment bubble but render your own composer, listen for the pending comment event. The event carries `pendingSelection`, captured before SuperDoc inserts the pending mark, so you do not need to continuously cache the last non-empty selection.

```tsx
import { useEffect, useState } from 'react';
import type { SuperDocCommentsUpdatePayload } from 'superdoc';
import type { SelectionInfo } from 'superdoc/ui';

const [pendingSelection, setPendingSelection] = useState<SelectionInfo | null>(null);

useEffect(() => {
if (!superdoc) return;

const handleCommentsUpdate = ({ type, pendingSelection }: SuperDocCommentsUpdatePayload) => {
if (type === 'pending') setPendingSelection(pendingSelection ?? null);
};

superdoc.on('comments-update', handleCommentsUpdate);
return () => superdoc.off('comments-update', handleCommentsUpdate);
}, [superdoc]);

function postComment(text: string) {
if (!ui || !pendingSelection) return;
return ui.comments.createFromCapture(pendingSelection, { text: text.trim() });
}
```

`pendingSelection` is a Document API selection snapshot. It has the `target` that `createFromCapture` needs, but it can be `null` for PDF selections, mismatched documents, or selections with no text anchor.

## Resolve and reopen

```tsx
Expand All @@ -136,6 +166,17 @@ ui.comments.delete(commentId);

All three return a Document API receipt. The next snapshot from `useSuperDocComments()` reflects the change.

## Activate a comment highlight without scrolling

Use `setActive` when your UI handles scrolling and only needs SuperDoc to highlight the comment.

```tsx
scrollYourSidebarOrEditor(commentId);
const activated = ui.comments.setActive(commentId);
```

Pass `null` to clear the highlight. Reply ids activate their anchored thread root, unknown ids return `false`, and a later editor selection change can clear the highlight because `setActive` does not move the caret.

## Scroll to a comment

```tsx
Expand Down
28 changes: 21 additions & 7 deletions apps/docs/editor/custom-ui/selection-and-viewport.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -116,12 +116,7 @@ export function SelectionPopover() {
}
const update = () => setRect(ui.selection.getAnchorRect({ placement: 'start' }));
update();
window.addEventListener('scroll', update, true);
window.addEventListener('resize', update);
return () => {
window.removeEventListener('scroll', update, true);
window.removeEventListener('resize', update);
};
return ui.viewport.observe(update);
}, [ui, selection.empty, selection.target, selection.quotedText]);

if (!rect) return null;
Expand Down Expand Up @@ -182,6 +177,17 @@ document.addEventListener('contextmenu', (event) => {

The painted host is separate from the offscreen ProseMirror DOM. Reach for `getHost()` when you need the element a `MouseEvent.target` would resolve to inside the editor.

## Get the scroll container

Use `ui.viewport.getScrollContainer()` when your UI needs to scroll the editor or attach scroll listeners. It returns `null` when the page window is the scroller.

```ts
const scroller = ui.viewport.getScrollContainer();
(scroller ?? window).scrollTo({ top: 0, behavior: 'smooth' });
```

If you cache rects from `getRect`, subscribe to `ui.viewport.observe(() => remeasure())` and re-query them when geometry changes.

## The right-click bundle

`ui.viewport.contextAt({ x, y })` composes the four primitives above into one bundle. Use it for custom right-click menus where the same shape filters items and runs handlers. See [Custom right-click menu](/editor/custom-ui/context-menu) for the full pattern.
Expand Down Expand Up @@ -231,6 +237,14 @@ if (result.success) {
}
```

For a body text range, pass the same `TextAddress` or `TextTarget` shape you get from selection and Document API calls:

```ts
const result = ui.viewport.getRect({
target: { kind: 'text', blockId: 'p-123', range: { start: 8, end: 21 } },
});
```

Rects are plain values in viewport coordinates. Multi-page or multi-line targets return `rects` carrying every painted occurrence in document order.

`success: false` reasons:
Expand All @@ -245,6 +259,6 @@ Rects are plain values in viewport coordinates. Multi-page or multi-line targets
## Trade-offs

- The selection slice updates on every editor change. Subscribe via the typed hook, not via raw `ui.select(...)`, so the controller can dedupe by shallow equality.
- `getRect`, `getRects`, `getAnchorRect`, `entityAt`, and `positionAt` are synchronous and DOM-driven. Run them inside a `useLayoutEffect` if you need them to settle before paint. Text-anchored `getRect` targets are deferred until story-aware text resolution lands.
- `getRect`, `getRects`, `getAnchorRect`, `entityAt`, and `positionAt` are synchronous and DOM-driven. Run them inside a `useLayoutEffect` if you need them to settle before paint. Body text-range `getRect` targets are supported; header, footer, footnote, and endnote text targets are deferred until story-aware text resolution lands.
- `scrollIntoView` honors `behavior: 'smooth'` for body content. Non-body entities (header / footer / footnote / endnote) snap to view because story activation has to mount the surface synchronously before alignment. If smooth animation across non-body entities matters, scroll the editor surface yourself first with `behavior: 'smooth'`, then call `scrollIntoView` to land the entity.
- `contextAt({ x, y })` always returns a bundle. Non-numeric coords coerce to `(0, 0)`, which is a real point and may sit inside the painted host. Pass real coordinates if you want the result to reflect a specific click.
2 changes: 1 addition & 1 deletion demos/editor/custom-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ The demo composes anchored citation pointers on top of `editor.doc.metadata.*` a
| **Mock RAG output** | Pre-canned text + per-citation payloads (sourceId, displayText, locator, excerpt, confidence). Stand-in for a real generation pipeline. | `mockDraft.ts` |
| **Insert + attach** | Inserts the text via `editor.doc.insert`, then computes a `SelectionTarget` for each cited span and calls `editor.doc.metadata.attach` per citation. | `GenerateDraftButton.tsx`, `useCitations.ts` |
| **Sources panel** | Lists citations grouped by `sourceId`. Scroll-to navigation uses `ui.metadata.scrollIntoView({ id })`. Edit form calls `editor.doc.metadata.update`. | `CitationsPanel.tsx` |
| **Highlight overlay** | Renders one absolute-positioned rectangle per painted line of each cited span. Rects come from `ui.metadata.getRect({ id })`. Remeasures on scroll, resize, ResizeObserver, and MutationObserver. | `CitationHighlights.tsx` |
| **Highlight overlay** | Renders one absolute-positioned rectangle per painted line of each cited span. Rects come from `ui.metadata.getRect({ id })` and remeasure through `ui.viewport.observe`. | `CitationHighlights.tsx` |
| **Hover popover** | `ui.viewport.entityAt({ x, y })` returns the content control under the cursor; `metadata.get({ id })` fetches the payload to render. | `CitationPopover.tsx` |
| **Persistence** | Hidden inline content controls in the body carry the stable id in `w:tag`; payloads live in a namespaced custom XML data part. Survives DOCX export, reopen, and Word save (validated by the `word-roundtrip` fixtures in the monorepo). | `editor.doc.metadata.*` |

Expand Down
49 changes: 6 additions & 43 deletions demos/editor/custom-ui/src/components/CitationHighlights.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,7 @@ import { useCitations } from './useCitations';

/**
* Renders absolute-positioned overlay rectangles on every cited span.
*
* Geometry comes from `ui.metadata.getRect({ id })`. The handle hides
* the underlying lookup (metadata id = SDT `w:tag` → SDT node id →
* painter rect) so consumers only carry the metadata id they originally
* passed to `editor.doc.metadata.attach`. Before SD-3204 this demo had
* to compose `useSuperDocContentControls` + a tag → nodeId map +
* `ui.contentControls.getRect`; that bridge is now obviated.
*
* `getRect` returns `rects[]` on success (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.
* `ui.viewport.observe` tells the demo when cached rects may have moved.
*/
type HighlightEntry = { metadataId: string; tooltip: string; rects: ViewportRect[] };

Expand All @@ -35,7 +16,8 @@ export function CitationHighlights() {

useEffect(() => {
const metadata = ui?.metadata;
if (!metadata?.getRect) {
const viewport = ui?.viewport;
if (!metadata?.getRect || !viewport?.observe) {
setEntries([]);
return;
}
Expand All @@ -54,8 +36,7 @@ export function CitationHighlights() {
setEntries(next);
};

// Coalesce burst triggers (multi-mutation keystrokes, ResizeObserver
// firing alongside MutationObserver, etc.) into one remeasure per frame.
// Coalesce burst geometry events into one remeasure per frame.
let rafHandle: number | null = null;
const scheduleRemeasure = () => {
if (rafHandle !== null) return;
Expand All @@ -66,28 +47,10 @@ export function CitationHighlights() {
};

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,
// so the demo doesn't observe 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 });
}
const stopObservingViewport = viewport.observe(scheduleRemeasure);

return () => {
window.removeEventListener('scroll', scheduleRemeasure, true);
window.removeEventListener('resize', scheduleRemeasure);
resizeObserver?.disconnect();
mutationObserver?.disconnect();
stopObservingViewport();
if (rafHandle !== null) cancelAnimationFrame(rafHandle);
};
}, [ui, citations]);
Expand Down
18 changes: 4 additions & 14 deletions demos/editor/custom-ui/src/components/SelectionPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,9 @@ const ANCHOR_GAP = 8;
* the wrong place. `ui.selection.getAnchorRect()` reads from the
* painted layout instead.
*
* The popover only re-positions when the selection slice meaningfully
* changes (range / quoted text). Scroll and resize trigger a refresh
* so the anchor stays glued through layout shifts; the rect is
* viewport-relative so `position: fixed` is enough.
* The popover re-positions when the selection changes or viewport
* geometry invalidates. Rects are viewport-relative, so
* `position: fixed` is enough.
*/
export function SelectionPopover({ onComposeComment }: Props) {
const ui = useSuperDocUI();
Expand All @@ -39,16 +38,7 @@ export function SelectionPopover({ onComposeComment }: Props) {
setRect(ui.selection.getAnchorRect({ placement: 'start' }));
};
update();
// Scroll-capture listener so the popover follows the page when the
// user scrolls. The document is paginated so scroll happens
// somewhere up the DOM chain; `capture: true` catches scroll on
// any scrollable ancestor.
window.addEventListener('scroll', update, true);
window.addEventListener('resize', update);
return () => {
window.removeEventListener('scroll', update, true);
window.removeEventListener('resize', update);
};
return ui.viewport.observe(update);
}, [ui, selection.empty, selection.target, selection.quotedText]);

// Clamp horizontally inside the viewport and flip below the selection
Expand Down
Loading