diff --git a/apps/docs/editor/custom-ui/api-reference.mdx b/apps/docs/editor/custom-ui/api-reference.mdx index 07fbebb188..2033c484f7 100644 --- a/apps/docs/editor/custom-ui/api-reference.mdx +++ b/apps/docs/editor/custom-ui/api-reference.mdx @@ -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); ``` @@ -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) diff --git a/apps/docs/editor/custom-ui/comments.mdx b/apps/docs/editor/custom-ui/comments.mdx index 54ad352cc1..211e8dd68c 100644 --- a/apps/docs/editor/custom-ui/comments.mdx +++ b/apps/docs/editor/custom-ui/comments.mdx @@ -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(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 @@ -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 diff --git a/apps/docs/editor/custom-ui/selection-and-viewport.mdx b/apps/docs/editor/custom-ui/selection-and-viewport.mdx index df4260521c..b0db6bfd5e 100644 --- a/apps/docs/editor/custom-ui/selection-and-viewport.mdx +++ b/apps/docs/editor/custom-ui/selection-and-viewport.mdx @@ -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; @@ -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. @@ -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: @@ -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. diff --git a/demos/editor/custom-ui/README.md b/demos/editor/custom-ui/README.md index e0fcaff3b8..879c931923 100644 --- a/demos/editor/custom-ui/README.md +++ b/demos/editor/custom-ui/README.md @@ -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.*` | diff --git a/demos/editor/custom-ui/src/components/CitationHighlights.tsx b/demos/editor/custom-ui/src/components/CitationHighlights.tsx index cb48330f14..bfb2eb19bd 100644 --- a/demos/editor/custom-ui/src/components/CitationHighlights.tsx +++ b/demos/editor/custom-ui/src/components/CitationHighlights.tsx @@ -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[] }; @@ -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; } @@ -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; @@ -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]); diff --git a/demos/editor/custom-ui/src/components/SelectionPopover.tsx b/demos/editor/custom-ui/src/components/SelectionPopover.tsx index 84c1f7e91b..723e8a83e2 100644 --- a/demos/editor/custom-ui/src/components/SelectionPopover.tsx +++ b/demos/editor/custom-ui/src/components/SelectionPopover.tsx @@ -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(); @@ -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