diff --git a/demos/custom-ui/README.md b/demos/custom-ui/README.md index fadf93afa5..48088cf15d 100644 --- a/demos/custom-ui/README.md +++ b/demos/custom-ui/README.md @@ -4,10 +4,12 @@ A reference workspace built on the `superdoc/ui/react` surface. Toolbar, comment See the [Custom UI docs](https://docs.superdoc.dev/editor/custom-ui/overview) for the conceptual guide. -This is a demo, not a minimal canonical recipe. It shows how the pieces compose in a real product. For copy-paste-ready single-concept patterns (toolbar only, comments only, etc.), see the `examples/` folder once those land. +This demo shows how the pieces compose in a real product, not a single-concept recipe. Read it alongside the docs above when you're wiring your own toolbar or panel. ## Run +Prerequisites: Node 20+, pnpm 9+, run from inside the SuperDoc monorepo. + ```bash pnpm install pnpm --filter superdoc run build @@ -20,60 +22,66 @@ Open http://localhost:5189. ## What you can do here - Click toolbar buttons (bold, italic, lists, undo, redo) wired through `useSuperDocCommand`. -- Insert a custom clause registered with `ui.commands.register`. +- Insert a custom clause registered with `ui.commands.register`. The button works, and so does its keyboard shortcut `Mod-Shift-C`, declared on the registration rather than wired in a separate keydown listener. - Switch between Edit and Suggest. In Suggest, every edit lands as a tracked change. -- Select text and add a comment. Reply threads render under their parent. +- Select text and watch the floating bubble menu appear next to the selection (anchored via `ui.selection.getAnchorRect()`, not `window.getSelection()`). +- Right-click on a tracked change, comment, inside a selection, or on plain text. The menu adapts to the click target: Accept / Reject / Resolve on entities, Copy / Comment on a selection, Insert clause here on plain caret-only text. +- Add a comment. The composer captures the selection on open, posts on submit, and restores the visible range on close so the user keeps their place. - Accept or reject tracked changes. Decided ones move to a Resolved section. - Export the doc, edit it in Word, click Import, watch the activity feed update. ## Architecture ``` -SuperDocUIProvider one controller per app -└── EditorMount + onReady - ├── Toolbar ui.commands + setDocumentMode - └── ActivitySidebar ui.comments + ui.trackChanges + ui.selection - └── CommentComposer ui.selection.capture() +SuperDocUIProvider one controller per app +└── EditorMount + onReady + disableContextMenu + ├── Toolbar ui.commands + setDocumentMode + ├── SelectionPopover ui.selection.getAnchorRect, bubble menu over the selection + ├── ContextMenu ui.viewport.contextAt + ui.commands.getContextMenuItems(context) + item.invoke() + ├── ContextMenuRegistrations ui.commands.register({ contextMenu: { when } }) + └── ActivitySidebar ui.comments + ui.trackChanges + ui.selection + └── CommentComposer ui.selection.capture / restore + ui.comments.createFromCapture ``` Components consume the controller via `useSuperDocUI()`. They never reach into `editor.state` or `editor.view`. -## App-level: a merged Activity feed +## Three surfaces, three subjects -The demo's `ActivitySidebar` shows a single panel that interleaves comments and tracked changes — Word / Google Docs style. The controller exposes `ui.comments` and `ui.trackChanges` as separate slices on purpose, so apps that only render one don't pay for the other. If you want the merged view, compose it in your component: +The demo keeps a strict separation between the three editor UI surfaces. Each one answers a different "what's the subject of this action?" question: -```tsx -import { useMemo } from 'react'; -import { useSuperDocComments, useSuperDocTrackChanges } from 'superdoc/ui/react'; +| Surface | Subject | Items in the demo | +| --- | --- | --- | +| **Toolbar** | The **document** | Bold, Italic, Lists, Undo, Redo, Mode toggle, Insert clause, Export, Import. | +| **Floating bubble menu** | The **selection** | Bold, Italic, Comment on selection. | +| **Right-click context menu** | The **clicked target** | Accept / Reject on tracked change, Resolve on comment, Copy / Comment on selection (when the click is inside the selection rect), Insert clause here (when the click lands on plain caret-only text). | -function useActivityFeed() { - const comments = useSuperDocComments(); - const trackChanges = useSuperDocTrackChanges(); +`ui.viewport.contextAt({ x, y })` returns one bundle with the click point, the entities under it, the resolved caret position, the live selection, and `insideSelection` (whether the click landed in the painted selection rects). Each predicate filters on the same shape its handler receives, so "Copy" / "Comment on selection" gate themselves on `insideSelection === true` and "Insert clause here" gates on `position !== null && entities.length === 0 && insideSelection !== true`. A stale selection elsewhere on the page can't leak into a right-click somewhere else. - return useMemo(() => { - const feed = []; - for (const c of comments.items) feed.push({ kind: 'comment', id: c.id, comment: c }); - for (const tc of trackChanges.items) feed.push({ kind: 'change', id: tc.id, change: tc.change }); - return feed; - }, [comments.items, trackChanges.items]); -} -``` +The `Insert clause here` handler reads `context.position.target` (a collapsed `SelectionTarget` at the click point) and passes it straight to `editor.doc.insert`. The same predicate the menu was filtered with becomes the target the action acts on. Without the bundle, the registration would have to insert against the user's prior selection somewhere else in the doc, making the label a lie. -Sort or partition the result however the UI wants. This demo's `ActivitySidebar` partitions by Active vs Resolved, threads replies under their parent, and tracks locally-decided changes in a roll-up so accepted suggestions still show as audit rows. Roughly thirty lines of merge logic on top of the two slices. +Right-click on plain text where no item matches falls through to the browser's native menu. The handler deliberately doesn't `preventDefault` when `getContextMenuItems(context)` returns nothing, so the user gets Copy / Paste / Inspect from the browser instead of a dead right-click. -## What this demo deliberately doesn't do +## The four custom-UI patterns -- No design system. Plain React, plain CSS. Drop the same patterns into your Tailwind / shadcn / MUI / Mantine stack. -- No backend. The clause library in `` is hardcoded. Real consumers fetch from their own API and call `reg.invalidate()` when permissions or availability change. -- No AI provider. Custom commands can call any LLM from `execute`; the demo picked "Insert clause" because it's concrete and self-contained. -- No floating bubble menu or link popover. To position one today, read the browser's selection rect from a `useSuperDocSelection()` effect: `window.getSelection()?.getRangeAt(0)?.getBoundingClientRect()`. +1. **Floating selection toolbar.** `ui.selection.getAnchorRect({ placement: 'start' })` returns viewport-relative coords for the painted selection. Re-position on `useSuperDocSelection()` change plus `scroll`/`resize`. Don't reach for `window.getSelection()`; SuperDoc's painted DOM is separate from the offscreen ProseMirror DOM and the browser API returns the wrong rect. See `SelectionPopover.tsx`. + +2. **Right-click context menu.** Set `disableContextMenu` on `` to suppress the built-in. On `contextmenu`, call `ui.viewport.contextAt({ x, y })` to get the bundle, then `ui.commands.getContextMenuItems(context)` to get items contributed via `register({ contextMenu })`. Each item carries `invoke()`, which fires the registered `execute({ context })` with the bundle bound, so handlers act on the click target without the menu component threading payloads. Scope the listener with `ui.viewport.getHost()` instead of a CSS class. See `ContextMenu.tsx` and `ContextMenuRegistrations.tsx`. -## Three takeaways for your own UI +3. **Custom command + keyboard shortcut.** Declare `shortcut: 'Mod-Shift-C'` on the registration. The controller installs a single bubble-phase keydown listener scoped to the painted host; matched shortcuts dispatch through the same path the toolbar button uses. No per-command keymap wiring. See `InsertClauseButton.tsx`. -1. **One provider, many components.** The toolbar, sidebar, and review panel all subscribe to the same controller via hooks. They don't pass props down a tree. -2. **`modules: { comments: false }` and your own panel.** The demo turns off the built-in comments UI and renders its own. Imported comments still flow through export and import. -3. **Capture, then post.** Composers freeze the selection at open and pass the snapshot at submit. The textarea taking focus doesn't lose the anchor. +4. **Composer capture + restore.** `ui.selection.capture()` on open holds the selection across focus moves. `ui.comments.createFromCapture(captured, { text })` posts the comment using the frozen target. `ui.selection.restore(captured)` puts the visible selection back so the user keeps their place. See `CommentComposer.tsx`. -## Telemetry +## Adapting this to your stack -`telemetry: { enabled: false }` is set in `EditorMount.tsx`. SuperDoc defaults to enabled. +- **One provider, many components.** Toolbar, sidebar, and review panel all subscribe to the same controller via hooks. They don't pass props down a tree. +- **No design system.** Plain React, plain CSS. Drop the same patterns into Tailwind / shadcn / MUI / Mantine. +- **`modules: { comments: false }` and your own panel.** The demo turns off the built-in comments UI and renders its own. Imported comments still flow through export and import. +- **Capture, then restore.** Composers freeze the selection at open, post on submit, then `restore(capture)` on close. The user sees their range come back instead of typing into a vanished selection. +- **Activity feed merge.** `ActivitySidebar.tsx` interleaves `ui.comments` and `ui.trackChanges` into one panel with about thirty lines of merge logic. The two slices stay separate on the controller so apps that only render one don't pay for the other. + +## What this demo deliberately doesn't do + +- No design system. Patterns over CSS, copy them into yours. +- No backend. The clause library in `` is hardcoded. Real consumers fetch from their own API and call `reg.invalidate()` when permissions or availability change. +- No AI provider. Custom commands can call any LLM from `execute`. The demo picked "Insert clause" because it's concrete and self-contained. +- Telemetry is off (`telemetry: { enabled: false }` in `EditorMount.tsx`) because there's no analytics endpoint to receive events. SuperDoc defaults to enabled. diff --git a/demos/custom-ui/public/sample-review.docx b/demos/custom-ui/public/sample-review.docx index db3ecdca5b..daa1b619c6 100644 Binary files a/demos/custom-ui/public/sample-review.docx and b/demos/custom-ui/public/sample-review.docx differ diff --git a/demos/custom-ui/src/App.tsx b/demos/custom-ui/src/App.tsx index e1e06901f3..3fe7177f00 100644 --- a/demos/custom-ui/src/App.tsx +++ b/demos/custom-ui/src/App.tsx @@ -1,20 +1,52 @@ -import { useState } from 'react'; +import { useCallback, useState } from 'react'; import { SuperDocUIProvider } from 'superdoc/ui/react'; import { EditorMount } from './editor/EditorMount'; import { Toolbar } from './components/Toolbar'; import { ActivitySidebar } from './components/ActivitySidebar'; +import { SelectionPopover } from './components/SelectionPopover'; +import { ContextMenu } from './components/ContextMenu'; +import { ContextMenuRegistrations } from './components/ContextMenuRegistrations'; +import { useDecidedChanges } from './components/useDecidedChanges'; export function App() { + return ( + + + + ); +} + +/** + * Hooks that subscribe to the controller (like `useDecidedChanges`) + * have to live INSIDE ``, so the page-level hook + * work happens here rather than in `App`. Keeping `App` as a thin + * provider wrapper also matches what a real consumer's root usually + * does. + */ +function AppInner() { // The composer is sidebar-side UI but is triggered from the toolbar's // comment button. Lifting the open/close state to the layout root is // the simplest path; a real product might dispatch through a state // store, but the example keeps the wiring obvious. const [composeOpen, setComposeOpen] = useState(false); + // 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 + // up regardless of which surface fired the decision. + const decided = useDecidedChanges(); + // Stable callbacks so the effect-driven `ContextMenuRegistrations` + // (and similar children whose deps include these handlers) don't + // unregister and re-register every time `composeOpen` toggles or a + // track-change tick re-runs `useDecidedChanges`. The demo is the + // canonical example consumers copy; teaching "register inside an + // effect with unstable deps" would re-emerge as registry churn in + // every consumer that follows the pattern. + const openComposer = useCallback(() => setComposeOpen(true), []); + const closeComposer = useCallback(() => setComposeOpen(false), []); return ( - -
-
+
+

Contract Review Workspace

Memorandum · review pending
@@ -22,11 +54,14 @@ export function App() {
- setComposeOpen(true)} /> +
+ + +
-
- +
); } diff --git a/demos/custom-ui/src/components/ActivitySidebar.tsx b/demos/custom-ui/src/components/ActivitySidebar.tsx index ef5bd892e3..2c2ba5f069 100644 --- a/demos/custom-ui/src/components/ActivitySidebar.tsx +++ b/demos/custom-ui/src/components/ActivitySidebar.tsx @@ -7,6 +7,7 @@ import { useSuperDocUI, } from 'superdoc/ui/react'; import { CommentComposer } from './CommentComposer'; +import type { DecidedChange, DecidedChangesState } from './useDecidedChanges'; type CommentItem = CommentsListResult['items'][number]; @@ -25,14 +26,13 @@ interface Props { composeOpen: boolean; /** Close the composer without posting. */ onCloseComposer(): void; -} - -interface DecidedChange { - id: string; - decision: 'accepted' | 'rejected'; - decidedAt: number; - /** Snapshot taken before the doc-api call so we can render it post-accept. */ - snapshot: { type?: string; author?: string; authorEmail?: string; excerpt?: string }; + /** + * Shared decided-changes store. The accept/reject buttons on each + * card and the right-click context menu both route through the + * store's `decideChange` so a tracked-change decision shows up in + * the Resolved section regardless of which surface fired it. + */ + decided: DecidedChangesState; } /** @@ -45,21 +45,17 @@ interface DecidedChange { * via `ui.selection.activeCommentIds` / `activeChangeIds`, and the * panel highlights that card and scrolls it into view. */ -export function ActivitySidebar({ composeOpen, onCloseComposer }: Props) { +export function ActivitySidebar({ composeOpen, onCloseComposer, decided }: Props) { const ui = useSuperDocUI(); const comments = useSuperDocComments(); const trackChanges = useSuperDocTrackChanges(); const selection = useSuperDocSelection(); - // Track tracked-changes that the user has accepted/rejected. Once - // decided, the change leaves the live `ui.trackChanges` feed (the - // tracked-change row in the document is gone — accepted means - // applied, rejected means discarded). To mimic the Google Docs - // experience, we capture the change snapshot before calling - // accept/reject and render it in the Resolved section as an audit - // row. State is component-local: refresh wipes it, which is fine - // for a demo. - const [decidedChanges, setDecidedChanges] = useState>(() => new Map()); + // Decided-changes state is owned by the parent (App) via + // `useDecidedChanges` so the right-click context menu can dispatch + // through the same `decideChange` and the Resolved audit row shows + // up regardless of which surface fired the decision. + const { decidedChanges, decideChange } = decided; // Track which entity (if any) is currently under the editor cursor. const activeEntityId = useMemo(() => { @@ -126,44 +122,6 @@ export function ActivitySidebar({ composeOpen, onCloseComposer }: Props) { return map; }, [feed]); - const decideChange = (id: string, decision: 'accepted' | 'rejected') => { - if (!ui) return; - // Capture a snapshot from the live feed BEFORE we mutate, since - // accept/reject removes the tracked-change row entirely. - const liveItem = trackChanges.items.find((it) => it.id === id); - const change = (liveItem?.change ?? null) as DecidedChange['snapshot'] | null; - if (decision === 'accepted') ui.trackChanges.accept(id); - else ui.trackChanges.reject(id); - if (change) { - setDecidedChanges((prev) => { - const next = new Map(prev); - next.set(id, { id, decision, decidedAt: Date.now(), snapshot: change }); - return next; - }); - } - }; - - // Reconcile `decidedChanges` against the live track-changes feed: - // when a tracked change we previously decided reappears in - // `trackChanges.items` (undo of the accept/reject, collaborator - // restore, etc.), drop it from the local decided roll-up. - useEffect(() => { - setDecidedChanges((prev) => { - if (prev.size === 0) return prev; - const liveChangeIds = new Set(); - for (const item of trackChanges.items) liveChangeIds.add(item.id); - let mutated = false; - const next = new Map(prev); - for (const id of prev.keys()) { - if (liveChangeIds.has(id)) { - next.delete(id); - mutated = true; - } - } - return mutated ? next : prev; - }); - }, [trackChanges.items]); - // Auto-scroll the matching card into view when the active entity changes. const containerRef = useRef(null); useEffect(() => { diff --git a/demos/custom-ui/src/components/CommentComposer.tsx b/demos/custom-ui/src/components/CommentComposer.tsx index c44cfbbee6..041d10e3f6 100644 --- a/demos/custom-ui/src/components/CommentComposer.tsx +++ b/demos/custom-ui/src/components/CommentComposer.tsx @@ -53,6 +53,13 @@ export function CommentComposer({ onCancel, onPosted }: Props) { onPosted(null); return; } + // Put the editor's visible selection back where the user picked + // it — `createFromCapture` only writes the comment; the live + // editor selection is wherever it was when the textarea took + // focus, which is usually nothing visible. `restore` rejoins + // the user where they were so the next keystroke continues + // their flow. + ui.selection.restore(captured); const entity = (receipt.inserted as Array<{ entityId?: string }> | undefined)?.[0]; onPosted(entity?.entityId ?? null); } catch (err) { @@ -61,6 +68,16 @@ export function CommentComposer({ onCancel, onPosted }: Props) { } }; + const cancel = () => { + // Same restore call on cancel: the user clicked into the textarea + // (losing the visible selection), then bailed without posting. + // Without restore, the editor stays unfocused with no visible + // range; the next keystroke would land at whatever caret position + // the editor last knew, which is rarely what the user expects. + if (ui && captured) ui.selection.restore(captured); + onCancel(); + }; + return (
@@ -75,11 +92,11 @@ export function CommentComposer({ onCancel, onPosted }: Props) { onChange={(e) => setText(e.target.value)} onKeyDown={(e) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') post(); - if (e.key === 'Escape') onCancel(); + if (e.key === 'Escape') cancel(); }} />
- + diff --git a/demos/custom-ui/src/components/ContextMenu.tsx b/demos/custom-ui/src/components/ContextMenu.tsx new file mode 100644 index 0000000000..ba6f6a2502 --- /dev/null +++ b/demos/custom-ui/src/components/ContextMenu.tsx @@ -0,0 +1,173 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import type { ContextMenuItem } from 'superdoc/ui'; +import { useSuperDocUI } from 'superdoc/ui/react'; + +interface OpenState { + x: number; + y: number; + items: ContextMenuItem[]; +} + +const VIEWPORT_MARGIN = 8; + +/** + * Right-click context menu wired through the controller's bundle API. + * + * - `ui.viewport.contextAt({ x, y })` returns one object with the + * entities under the click, the resolved caret position, the live + * selection, and `insideSelection` (whether the click landed in + * the painted selection rects). The demo no longer assembles + * these by hand. + * - `ui.commands.getContextMenuItems(context)` filters contributions + * against the same shape predicates see, and stamps each returned + * item with `invoke()`. Calling `item.invoke()` fires the + * registered `execute({ context })` with the bundle bound, so + * handlers act on the click target without the demo threading + * entity ids through a payload. + * - `ui.viewport.getHost()` returns the painted host element, so + * scoping to "events inside the editor" doesn't depend on a + * consumer-side CSS class. + * + * Built-in editor context menu is suppressed via `disableContextMenu` + * on ``. When `getContextMenuItems(context)` returns + * items, the demo's menu opens and `preventDefault` blocks the native + * one. When the result is empty (no contribution matches the click + * target), the handler returns without `preventDefault` so the + * browser native menu falls through. + */ +export function ContextMenu() { + const ui = useSuperDocUI(); + const [state, setState] = useState(null); + const [position, setPosition] = useState<{ left: number; top: number } | null>(null); + const menuRef = useRef(null); + const itemRefs = useRef>([]); + + useEffect(() => { + if (!ui) return; + const onContextMenu = (event: MouseEvent) => { + // Scope to the painted host before reading `contextAt`. An empty + // bundle can mean "outside the editor" OR "inside plain text + // with no selection and no entities", so emptiness alone isn't + // a scope signal. Without the host check, a right-click on the + // consumer's own toolbar or sidebar would still open this menu. + const host = ui.viewport.getHost(); + const target = event.target; + if (!host || !(target instanceof Node) || !host.contains(target)) { + return; + } + + const context = ui.viewport.contextAt({ x: event.clientX, y: event.clientY }); + const items = ui.commands.getContextMenuItems(context); + if (items.length === 0) { + setState(null); + return; + } + event.preventDefault(); + setState({ x: event.clientX, y: event.clientY, items }); + }; + const onPointerDown = (event: PointerEvent) => { + const target = event.target; + if (target instanceof Element && target.closest?.('.context-menu')) return; + setState(null); + }; + document.addEventListener('contextmenu', onContextMenu); + document.addEventListener('pointerdown', onPointerDown); + return () => { + document.removeEventListener('contextmenu', onContextMenu); + document.removeEventListener('pointerdown', onPointerDown); + }; + }, [ui]); + + // Clamp the menu inside the viewport once we know its size. Reading + // offsetWidth/offsetHeight inside useLayoutEffect runs after layout + // but before paint, so the menu is never visibly placed off-screen + // first and snapped back. + useLayoutEffect(() => { + if (!state || !menuRef.current) { + setPosition(null); + return; + } + const menu = menuRef.current; + const { offsetWidth: w, offsetHeight: h } = menu; + const vw = window.innerWidth; + const vh = window.innerHeight; + const left = Math.min(Math.max(state.x, VIEWPORT_MARGIN), vw - w - VIEWPORT_MARGIN); + const top = Math.min(Math.max(state.y, VIEWPORT_MARGIN), vh - h - VIEWPORT_MARGIN); + setPosition({ left, top }); + // Focus the first item so keyboard users can navigate immediately. + itemRefs.current[0]?.focus(); + }, [state]); + + // Roving keyboard navigation. Up/Down moves focus, Home/End jump to + // the ends, Escape closes. Clicks dismiss via `pointerdown` above. + const onKeyDown = (event: React.KeyboardEvent) => { + if (!state) return; + const last = state.items.length - 1; + const current = itemRefs.current.findIndex((el) => el === document.activeElement); + if (event.key === 'Escape') { + event.preventDefault(); + setState(null); + return; + } + if (event.key === 'ArrowDown') { + event.preventDefault(); + const next = current < 0 ? 0 : Math.min(current + 1, last); + itemRefs.current[next]?.focus(); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + const next = current < 0 ? last : Math.max(current - 1, 0); + itemRefs.current[next]?.focus(); + } else if (event.key === 'Home') { + event.preventDefault(); + itemRefs.current[0]?.focus(); + } else if (event.key === 'End') { + event.preventDefault(); + itemRefs.current[last]?.focus(); + } + }; + + if (!state || !ui) return null; + + return ( +
e.stopPropagation()} + style={{ + position: 'fixed', + left: position?.left ?? state.x, + top: position?.top ?? state.y, + // Hide the menu for the single frame it takes useLayoutEffect + // to measure and clamp. Avoids a flash at the unclamped coords. + visibility: position ? 'visible' : 'hidden', + }} + > + {state.items.map((item, idx) => { + const prev = state.items[idx - 1]; + const showSeparator = prev && prev.group !== item.group; + return ( +
+ {showSeparator &&
} + +
+ ); + })} +
+ ); +} diff --git a/demos/custom-ui/src/components/ContextMenuRegistrations.tsx b/demos/custom-ui/src/components/ContextMenuRegistrations.tsx new file mode 100644 index 0000000000..0e39d0d968 --- /dev/null +++ b/demos/custom-ui/src/components/ContextMenuRegistrations.tsx @@ -0,0 +1,181 @@ +import { useEffect } from 'react'; +import type { ViewportEntityHit } from 'superdoc/ui'; +import { useSuperDocUI } from 'superdoc/ui/react'; +import type { DecidedChangesState } from './useDecidedChanges'; + +interface Props { + /** + * Shared accept/reject dispatcher. The Activity sidebar uses the + * same store; routing context-menu decisions through it keeps the + * Resolved audit row in sync regardless of which surface the user + * clicked. + */ + decided: DecidedChangesState; + /** + * Open the comment composer with the current selection. Wired to + * the same App-level open/close state the toolbar's Comment button + * uses, so a context-menu trigger lands on the same composer. + */ + onComposeComment(): void; +} + +/** + * Registers the demo's context-menu contributions. + * + * Each registration declares its own visibility via `when({ entities, + * insideSelection, ... })` and reads the click subject from + * `context.entities` / `context.selection` inside `execute`. Both + * predicate and handler see the same {@link ViewportContext} bundle + * the menu was opened on, so the demo doesn't thread payloads through + * the menu UI to keep them in sync. + */ +export function ContextMenuRegistrations({ decided, onComposeComment }: Props) { + const ui = useSuperDocUI(); + + useEffect(() => { + if (!ui) return; + const trackedChangeId = (entities: ReadonlyArray | undefined) => + entities?.find((e) => e.type === 'trackedChange')?.id; + const commentId = (entities: ReadonlyArray | undefined) => + entities?.find((e) => e.type === 'comment')?.id; + + const accept = ui.commands.register({ + id: 'demo.acceptSuggestion', + execute: ({ context }) => { + const id = trackedChangeId(context?.entities); + if (!id) return false; + // Route through the shared store so the Resolved audit row + // shows up — calling `ui.trackChanges.accept(id)` directly + // would skip the snapshot pass that the sidebar's Resolved + // section reads from. + decided.decideChange(id, 'accepted'); + return true; + }, + contextMenu: { + label: 'Accept suggestion', + group: 'review', + order: 0, + when: ({ entities }) => entities.some((e) => e.type === 'trackedChange'), + }, + }); + const reject = ui.commands.register({ + id: 'demo.rejectSuggestion', + execute: ({ context }) => { + const id = trackedChangeId(context?.entities); + if (!id) return false; + decided.decideChange(id, 'rejected'); + return true; + }, + contextMenu: { + label: 'Reject suggestion', + group: 'review', + order: 1, + when: ({ entities }) => entities.some((e) => e.type === 'trackedChange'), + }, + }); + const resolve = ui.commands.register({ + id: 'demo.resolveComment', + execute: ({ context }) => { + const id = commentId(context?.entities); + if (!id) return false; + ui.comments.resolve(id); + return true; + }, + contextMenu: { + label: 'Resolve comment', + group: 'comment', + when: ({ entities }) => entities.some((e) => e.type === 'comment'), + }, + }); + + // Selection-scoped items. The `insideSelection` predicate field + // gates these to clicks INSIDE the painted selection rects, so a + // stale selection from elsewhere in the doc doesn't leak into a + // right-click far away. The controller computes that flag once + // per menu open and hands the same value to every predicate. + // + // Format items (Bold / Italic / Link) deliberately live in the + // floating bubble menu rather than here. The right-click target + // model is "the thing under the pointer," and a format toggle + // doesn't belong to a target — it belongs to the active + // selection. The bubble menu owns that. + const copy = ui.commands.register({ + id: 'demo.copy', + execute: ({ context }) => { + const text = context?.selection.quotedText ?? ui.selection.getSnapshot().quotedText; + if (text && typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { + navigator.clipboard.writeText(text).catch(() => {}); + } + return true; + }, + contextMenu: { + label: 'Copy', + group: 'clipboard', + when: ({ selection, insideSelection }) => !selection.empty && insideSelection === true, + }, + }); + const comment = ui.commands.register({ + id: 'demo.commentSelection', + execute: () => { + onComposeComment(); + return true; + }, + contextMenu: { + label: 'Comment on selection', + group: 'comment', + when: ({ selection, insideSelection }) => + !selection.empty && selection.target !== null && insideSelection === true, + }, + }); + + // Point-anchored insert. Reads `context.position.target` (a + // collapsed SelectionTarget at the click point) and inserts + // directly at the click. Without the bundle, this would fire + // `editor.doc.insert` against `state.selection.selectionTarget` + // and silently land at the user's prior selection somewhere else + // in the doc, making the menu label a lie. + // + // Gated on three conditions so it only shows on plain caret-only + // text: + // - `position !== null`: a caret resolved (excludes clicks + // outside the painted host). + // - `insideSelection !== true`: the click isn't inside the + // active selection (Copy / Comment on selection own that + // case). + // - `entities.length === 0`: the click isn't on a tracked + // change or comment (Accept / Reject / Resolve own those). + // Without this gate, right-clicking a tracked change would + // surface both Accept/Reject AND "Insert clause here", and + // picking the latter would insert into the entity's range + // instead of acting on the entity. + const SAMPLE_CLAUSE = + 'Each party agrees to maintain the confidentiality of all information disclosed by the other party in connection with this agreement.'; + const insertHere = ui.commands.register({ + id: 'demo.insertClauseHere', + execute: ({ context, editor }) => { + const target = context?.position?.target; + if (!target || !editor?.doc?.insert) return false; + const receipt = editor.doc.insert({ value: SAMPLE_CLAUSE, type: 'text', target }); + return receipt?.success === true; + }, + contextMenu: { + label: 'Insert clause here', + group: 'review', + order: 10, + when: ({ entities, position, insideSelection }) => + entities.length === 0 && position !== null && insideSelection !== true, + }, + }); + + return () => { + accept.unregister(); + reject.unregister(); + resolve.unregister(); + copy.unregister(); + comment.unregister(); + insertHere.unregister(); + }; + }, [ui, onComposeComment, decided]); + + return null; +} diff --git a/demos/custom-ui/src/components/InsertClauseButton.tsx b/demos/custom-ui/src/components/InsertClauseButton.tsx index 4926a1325d..436c138b4b 100644 --- a/demos/custom-ui/src/components/InsertClauseButton.tsx +++ b/demos/custom-ui/src/components/InsertClauseButton.tsx @@ -46,7 +46,7 @@ const STATIC_DISABLED: CustomCommandHandleState = { * Demonstrates `ui.commands.register({...})` — the surface SuperDoc * exposes for consumer-defined toolbar buttons. The component: * - * 1. Registers `'company.insertClause'` on mount and unregisters + * 1. Registers `'demo.insertClause'` on mount and unregisters * on unmount, so the command's lifetime matches the component's. * A real consumer app usually holds the registration for the * session, but the pattern is the same. @@ -65,7 +65,7 @@ const STATIC_DISABLED: CustomCommandHandleState = { * Capturing the registration return value (`reg.handle`) is the * typed path: it carries the consumer's `TPayload` / `TValue` * generics. Dynamic-lookup callers should use - * `ui.commands.get('company.insertClause')` (returns + * `ui.commands.get('demo.insertClause')` (returns * `DynamicCommandHandle | undefined`); the older bracket-index path * still works at runtime but loses the per-command typing. */ @@ -82,7 +82,12 @@ export function InsertClauseButton() { if (!ui) return; const reg = ui.commands.register({ - id: 'company.insertClause', + id: 'demo.insertClause', + // Mod-Shift-C dispatches `execute` with no payload, which the + // body below treats as "open the picker" rather than performing + // an insert. (A consumer with a single-clause flow would skip + // the menu and pass `{ clauseId: 'confidentiality' }` directly.) + shortcut: 'Mod-Shift-C', getState: ({ state }) => ({ active: false, // Disabled when there's nothing positional to anchor the @@ -92,8 +97,23 @@ export function InsertClauseButton() { state.documentMode === 'viewing' || state.selection.target === null, }), - execute: ({ payload, editor }) => { - if (!payload) return false; + execute: ({ payload, editor, superdoc }) => { + // The keyboard dispatch path doesn't consult `getState`; without + // this gate, Mod-Shift-C would pop the picker even when the + // toolbar button is grayed out (no selection target / viewing + // mode), letting the user choose a clause that the insert + // branch can't honor — silent dead-end. Mirror the disabled + // check from `getState` so the shortcut and the button agree. + const live = ui.selection.getSnapshot(); + const documentMode = superdoc.config?.documentMode ?? null; + const disabled = documentMode === 'viewing' || live.target === null; + + if (!payload) { + if (disabled) return false; + setOpen(true); + return true; + } + if (disabled) return false; const clause = CLAUSES.find((c) => c.id === payload.clauseId); if (!clause) return false; diff --git a/demos/custom-ui/src/components/SelectionPopover.tsx b/demos/custom-ui/src/components/SelectionPopover.tsx new file mode 100644 index 0000000000..84c1f7e91b --- /dev/null +++ b/demos/custom-ui/src/components/SelectionPopover.tsx @@ -0,0 +1,115 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import type { ViewportRect } from 'superdoc/ui'; +import { useSuperDocSelection, useSuperDocUI } from 'superdoc/ui/react'; + +interface Props { + /** Open the comment composer with the captured selection. */ + onComposeComment(): void; +} + +const VIEWPORT_MARGIN = 8; +const ANCHOR_GAP = 8; + +/** + * Floating bubble menu over the user's selection. Demonstrates the + * selection-rect path consumers used to reach for + * `window.getSelection().getRangeAt(0).getBoundingClientRect()`, which + * reads from the offscreen ProseMirror DOM and lands the popover in + * 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. + */ +export function SelectionPopover({ onComposeComment }: Props) { + const ui = useSuperDocUI(); + const selection = useSuperDocSelection(); + const [rect, setRect] = useState(null); + const [position, setPosition] = useState<{ left: number; top: number } | null>(null); + const popoverRef = useRef(null); + + useEffect(() => { + if (!ui || selection.empty || selection.target === null) { + setRect(null); + return; + } + const update = () => { + 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); + }; + }, [ui, selection.empty, selection.target, selection.quotedText]); + + // Clamp horizontally inside the viewport and flip below the selection + // when there's no room above. Reading offsetWidth / offsetHeight in + // useLayoutEffect runs after layout but before paint, so the popover + // never lands at an off-screen coord and snaps back on the next frame. + useLayoutEffect(() => { + if (!rect || !popoverRef.current) { + setPosition(null); + return; + } + const { offsetWidth: w, offsetHeight: h } = popoverRef.current; + const vw = window.innerWidth; + const vh = window.innerHeight; + const centerX = rect.left + rect.width / 2; + const left = Math.min(Math.max(centerX - w / 2, VIEWPORT_MARGIN), vw - w - VIEWPORT_MARGIN); + const above = rect.top - h - ANCHOR_GAP; + const below = rect.top + rect.height + ANCHOR_GAP; + // Prefer above when there's room; flip below otherwise. Final clamp + // covers the rare case where neither fits (selection taller than vh). + let top = above >= VIEWPORT_MARGIN ? above : below; + top = Math.min(Math.max(top, VIEWPORT_MARGIN), vh - h - VIEWPORT_MARGIN); + setPosition({ left, top }); + }, [rect]); + + if (!rect) return null; + + return ( +
e.preventDefault()} + > + + + +
+ ); +} diff --git a/demos/custom-ui/src/components/Toolbar.tsx b/demos/custom-ui/src/components/Toolbar.tsx index a0c95653ab..26c4ddfd89 100644 --- a/demos/custom-ui/src/components/Toolbar.tsx +++ b/demos/custom-ui/src/components/Toolbar.tsx @@ -110,8 +110,8 @@ export function Toolbar({ onComposeComment }: ToolbarProps) { * Edit / Suggest mode toggle. Edits in Suggest mode are recorded as * tracked changes (insertions / deletions) and surface in the activity * sidebar for accept / reject. Reads the current mode from the - * `ui.document` snapshot and writes through `ui.document.setMode` - * (SD-2816); no host-instance access required. + * `ui.document` snapshot and writes through `ui.document.setMode`; + * no host-instance access required. */ function ModeToggle() { const ui = useSuperDocUI(); @@ -223,8 +223,7 @@ function ExportButton() { * adds comments / tracks changes / edits there, then reimports the * modified file here. `ui.document.replaceFile` swaps the doc and * re-emits `commentsLoaded` internally so the activity sidebar - * refreshes regardless of `modules.comments: false` (engine fix - * tracked under SD-2839). + * refreshes regardless of `modules.comments: false`. */ function ReimportButton() { const ui = useSuperDocUI(); diff --git a/demos/custom-ui/src/components/useDecidedChanges.ts b/demos/custom-ui/src/components/useDecidedChanges.ts new file mode 100644 index 0000000000..5d266e3e1e --- /dev/null +++ b/demos/custom-ui/src/components/useDecidedChanges.ts @@ -0,0 +1,90 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useSuperDocTrackChanges, useSuperDocUI } from 'superdoc/ui/react'; + +export interface DecidedChange { + id: string; + decision: 'accepted' | 'rejected'; + decidedAt: number; + /** Snapshot taken before the doc-api call so we can render it post-accept. */ + snapshot: { type?: string; author?: string; authorEmail?: string; excerpt?: string }; +} + +export interface DecidedChangesState { + decidedChanges: Map; + decideChange(id: string, decision: 'accepted' | 'rejected'): void; +} + +/** + * Shared decided-changes store for the demo. The Activity sidebar's + * accept/reject buttons AND the right-click context menu both route + * through `decideChange` so the Resolved audit row renders regardless + * of which surface fired the decision. Without this, a context-menu + * accept would call `ui.trackChanges.accept(id)` directly and the + * change would vanish (live feed drops it; sidebar never snapshotted + * it). + * + * State is intentionally component-local for the demo — a real product + * would persist decisions in its own store. + */ +export function useDecidedChanges(): DecidedChangesState { + const ui = useSuperDocUI(); + const trackChanges = useSuperDocTrackChanges(); + const [decidedChanges, setDecidedChanges] = useState>(() => new Map()); + + // Ref-mirror the live items so `decideChange` can read them without + // listing them in its `useCallback` deps. Without this, the callback + // identity changes on every track-change tick, the wrapper object + // below breaks reference, and any consumer that lists the wrapper in + // an effect's deps re-runs that effect (and registers/unregisters + // any contributed commands) on every doc edit. + const itemsRef = useRef(trackChanges.items); + itemsRef.current = trackChanges.items; + + const decideChange = useCallback( + (id: string, decision: 'accepted' | 'rejected') => { + if (!ui) return; + // Snapshot from the live feed BEFORE we mutate, since + // accept/reject removes the tracked-change row entirely. + const liveItem = itemsRef.current.find((it) => it.id === id); + const change = (liveItem?.change ?? null) as DecidedChange['snapshot'] | null; + if (decision === 'accepted') ui.trackChanges.accept(id); + else ui.trackChanges.reject(id); + if (change) { + setDecidedChanges((prev) => { + const next = new Map(prev); + next.set(id, { id, decision, decidedAt: Date.now(), snapshot: change }); + return next; + }); + } + }, + [ui], + ); + + // Reconcile against the live feed: when a previously-decided id + // reappears (undo, collaborator restore, etc.), drop it from the + // local roll-up. + useEffect(() => { + setDecidedChanges((prev) => { + if (prev.size === 0) return prev; + const liveChangeIds = new Set(); + for (const item of trackChanges.items) liveChangeIds.add(item.id); + let mutated = false; + const next = new Map(prev); + for (const id of prev.keys()) { + if (liveChangeIds.has(id)) { + next.delete(id); + mutated = true; + } + } + return mutated ? next : prev; + }); + }, [trackChanges.items]); + + // Memoize the wrapper so consumers passing the result into an + // effect (e.g. `ContextMenuRegistrations` whose deps include the + // returned object) only see a fresh reference when the underlying + // state actually changes, not on every parent render. Combined with + // the items-ref above, the wrapper now changes only when + // `decidedChanges` does. + return useMemo(() => ({ decidedChanges, decideChange }), [decidedChanges, decideChange]); +} diff --git a/demos/custom-ui/src/editor/EditorMount.tsx b/demos/custom-ui/src/editor/EditorMount.tsx index 4342b9fb07..1622d2f2d5 100644 --- a/demos/custom-ui/src/editor/EditorMount.tsx +++ b/demos/custom-ui/src/editor/EditorMount.tsx @@ -55,6 +55,11 @@ export function EditorMount() { telemetry={TELEMETRY} hideToolbar contained + // Suppress the editor's built-in right-click menu; the demo + // renders its own via `ContextMenu`, which opens against the + // bundle from `ui.viewport.contextAt(...)` and dispatches via + // `item.invoke()`. + disableContextMenu style={{ height: '100%' }} onReady={({ superdoc }: { superdoc: unknown }) => { setSuperDoc(superdoc); diff --git a/demos/custom-ui/src/styles.css b/demos/custom-ui/src/styles.css index e7b4b0aebd..03c9449dd4 100644 --- a/demos/custom-ui/src/styles.css +++ b/demos/custom-ui/src/styles.css @@ -515,3 +515,56 @@ button { text-overflow: ellipsis; white-space: nowrap; } + +/* Selection popover (bubble menu over the user's selection) */ + +.selection-popover { + z-index: 100; + display: flex; + gap: 2px; + padding: 4px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); +} + +.selection-popover .tb-btn { + padding: 4px 9px; + font-size: 12px; +} + +/* Right-click context menu */ + +.context-menu { + z-index: 100; + min-width: 180px; + padding: 4px 0; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); +} + +.context-menu-item { + display: block; + width: 100%; + padding: 6px 12px; + border: none; + background: transparent; + color: var(--text); + font-size: 13px; + text-align: left; + cursor: pointer; +} + +.context-menu-item:hover:not(:disabled) { + background: var(--bg-muted); + color: var(--accent); +} + +.context-menu-separator { + height: 1px; + margin: 4px 0; + background: var(--border); +}