From afdc6c26c9d6b9cb3a4d963fb1cfd390f3dbde98 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 5 May 2026 08:35:36 -0300 Subject: [PATCH 01/15] feat(demos/custom-ui): consume new SD-2936 controller surfaces --- demos/custom-ui/README.md | 32 ++++-- demos/custom-ui/src/App.tsx | 6 ++ .../src/components/CommentComposer.tsx | 21 +++- .../custom-ui/src/components/ContextMenu.tsx | 97 +++++++++++++++++++ .../components/ContextMenuRegistrations.tsx | 75 ++++++++++++++ .../src/components/InsertClauseButton.tsx | 14 ++- .../src/components/SelectionPopover.tsx | 90 +++++++++++++++++ demos/custom-ui/src/editor/EditorMount.tsx | 4 + demos/custom-ui/src/styles.css | 53 ++++++++++ 9 files changed, 380 insertions(+), 12 deletions(-) create mode 100644 demos/custom-ui/src/components/ContextMenu.tsx create mode 100644 demos/custom-ui/src/components/ContextMenuRegistrations.tsx create mode 100644 demos/custom-ui/src/components/SelectionPopover.tsx diff --git a/demos/custom-ui/README.md b/demos/custom-ui/README.md index fadf93afa5..fc2ea927f5 100644 --- a/demos/custom-ui/README.md +++ b/demos/custom-ui/README.md @@ -20,20 +20,25 @@ 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, not 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 or comment to see the custom context menu — items appear via `register({ contextMenu: { when } })` and the click target's entities come from `ui.viewport.entityAt({ x, y })`. +- Add a comment. The composer captures the selection on open, posts on submit, and `restore`s 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.entityAt + ui.commands.getContextMenuItems + ├── 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`. @@ -66,13 +71,22 @@ Sort or partition the result however the UI wants. This demo's `ActivitySidebar` - 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()`. + +## The custom-UI recipe (after SD-2936) + +1. **Floating selection toolbar** — `ui.selection.getAnchorRect({ placement: 'start' })` returns viewport-relative coords for the painted selection. Re-position on `useSuperDocSelection()` change + `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.entityAt({ x: event.clientX, y: event.clientY })` to get the entities under the cursor, then `ui.commands.getContextMenuItems({ entities })` to get items contributed via `register({ contextMenu })`. Pass `entities` as the payload when dispatching so `execute` can act on the right id. See `ContextMenu.tsx` + `ContextMenuRegistrations.tsx`. + +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`. + +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`. ## Three takeaways for your own UI 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. +3. **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. ## Telemetry diff --git a/demos/custom-ui/src/App.tsx b/demos/custom-ui/src/App.tsx index e1e06901f3..1fa40b389e 100644 --- a/demos/custom-ui/src/App.tsx +++ b/demos/custom-ui/src/App.tsx @@ -3,6 +3,9 @@ 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'; export function App() { // The composer is sidebar-side UI but is triggered from the toolbar's @@ -27,6 +30,9 @@ 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/ContextMenuRegistrations.tsx b/demos/custom-ui/src/components/ContextMenuRegistrations.tsx index 85ec021258..bf61d50573 100644 --- a/demos/custom-ui/src/components/ContextMenuRegistrations.tsx +++ b/demos/custom-ui/src/components/ContextMenuRegistrations.tsx @@ -1,6 +1,17 @@ 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; +} /** * Registers the demo's context-menu contributions. A real consumer @@ -9,7 +20,7 @@ import { useSuperDocUI } from 'superdoc/ui/react'; * surface and `when({ entities })` predicates that scope items to * specific click targets. */ -export function ContextMenuRegistrations() { +export function ContextMenuRegistrations({ decided }: Props) { const ui = useSuperDocUI(); useEffect(() => { @@ -24,7 +35,11 @@ export function ContextMenuRegistrations() { execute: ({ payload }) => { const id = trackedChangeId(payload?.entities); if (!id) return false; - ui.trackChanges.accept(id); + // 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: { @@ -39,7 +54,7 @@ export function ContextMenuRegistrations() { execute: ({ payload }) => { const id = trackedChangeId(payload?.entities); if (!id) return false; - ui.trackChanges.reject(id); + decided.decideChange(id, 'rejected'); return true; }, contextMenu: { diff --git a/demos/custom-ui/src/components/useDecidedChanges.ts b/demos/custom-ui/src/components/useDecidedChanges.ts new file mode 100644 index 0000000000..60dcaf77d4 --- /dev/null +++ b/demos/custom-ui/src/components/useDecidedChanges.ts @@ -0,0 +1,75 @@ +import { useCallback, useEffect, 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()); + + const decideChange = useCallback( + (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; + }); + } + }, + [ui, trackChanges.items], + ); + + // 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]); + + return { decidedChanges, decideChange }; +} From 7cb1c5679bd062cc2f29d3b6aa0f8bfd736f5367 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 5 May 2026 08:50:00 -0300 Subject: [PATCH 03/15] fix(demos/custom-ui): gate insert-clause shortcut on the same disabled state as the button --- .../src/components/InsertClauseButton.tsx | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/demos/custom-ui/src/components/InsertClauseButton.tsx b/demos/custom-ui/src/components/InsertClauseButton.tsx index 206112bdb7..193c553677 100644 --- a/demos/custom-ui/src/components/InsertClauseButton.tsx +++ b/demos/custom-ui/src/components/InsertClauseButton.tsx @@ -83,11 +83,9 @@ export function InsertClauseButton() { const reg = ui.commands.register({ id: 'company.insertClause', - // Mod-Shift-C opens the menu rather than running execute() with - // a payload — there's no clause id to insert until the user - // picks one. The shortcut is wired to dispatch the SAME `execute` - // body the button click does, but with a sentinel that opens - // the menu. (A consumer with a single-clause flow would skip + // 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 }) => ({ @@ -99,13 +97,23 @@ export function InsertClauseButton() { state.documentMode === 'viewing' || state.selection.target === null, }), - execute: ({ payload, editor }) => { - // No payload → user pressed the shortcut. Open the menu and - // let them pick a clause. + 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; From 51dde69d69a4141a7980b478af29ca57590275c7 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 5 May 2026 10:02:57 -0300 Subject: [PATCH 04/15] fix(demos/custom-ui): move useDecidedChanges inside SuperDocUIProvider --- demos/custom-ui/src/App.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/demos/custom-ui/src/App.tsx b/demos/custom-ui/src/App.tsx index ef35bd7639..319c9d8014 100644 --- a/demos/custom-ui/src/App.tsx +++ b/demos/custom-ui/src/App.tsx @@ -9,6 +9,21 @@ 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 @@ -21,7 +36,7 @@ export function App() { const decided = useDecidedChanges(); return ( - + <>

Contract Review Workspace

@@ -53,6 +68,6 @@ export function App() {
-
+ ); } From 311891b8141bd0d1b675298c44391276bcfd023a Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 5 May 2026 10:05:44 -0300 Subject: [PATCH 05/15] fix(demos/custom-ui): drop .editor-shell filter from contextmenu listener --- demos/custom-ui/src/components/ContextMenu.tsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/demos/custom-ui/src/components/ContextMenu.tsx b/demos/custom-ui/src/components/ContextMenu.tsx index d58ae203d2..5a7fc27f55 100644 --- a/demos/custom-ui/src/components/ContextMenu.tsx +++ b/demos/custom-ui/src/components/ContextMenu.tsx @@ -30,19 +30,22 @@ export function ContextMenu() { useEffect(() => { if (!ui) return; const onContextMenu = (event: MouseEvent) => { - const target = event.target; - if (!(target instanceof Node)) return; - // Only handle right-clicks inside the editor area. Anywhere - // else, let the browser's native menu run. - const editorShell = (target as Element).closest?.('.editor-shell'); - if (!editorShell) return; - event.preventDefault(); + // `entityAt` is already scoped to the controller's painted host + // (PR #3139) — it returns `[]` for points outside the editor. + // Use that as the scope check rather than a CSS-class filter + // like `.editor-shell`, which fails when the painter routes the + // event through a hidden ProseMirror DOM that lives outside + // `visibleHost`. const entities = ui.viewport.entityAt({ x: event.clientX, y: event.clientY }); const items = ui.commands.getContextMenuItems({ entities }); if (items.length === 0) { + // Outside the editor or over a region with no contributed + // items — let the browser's native menu run rather than + // suppressing it silently. setState(null); return; } + event.preventDefault(); setState({ x: event.clientX, y: event.clientY, items, entities }); }; const onPointerDown = (event: PointerEvent) => { From 927bcaf511f68dea130a9e155decd6598183c849 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 5 May 2026 10:17:22 -0300 Subject: [PATCH 06/15] fix(demos/custom-ui): add Copy + Comment fallbacks to context menu --- demos/custom-ui/src/App.tsx | 2 +- .../components/ContextMenuRegistrations.tsx | 49 ++++++++++++++++++- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/demos/custom-ui/src/App.tsx b/demos/custom-ui/src/App.tsx index 319c9d8014..d15fc7677d 100644 --- a/demos/custom-ui/src/App.tsx +++ b/demos/custom-ui/src/App.tsx @@ -53,7 +53,7 @@ function AppInner() { setComposeOpen(true)} /> - + setComposeOpen(true)} />