Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
afdc6c2
feat(demos/custom-ui): consume new SD-2936 controller surfaces
caio-pizzol May 5, 2026
bb59d86
fix(demos/custom-ui): route context-menu decisions through shared store
caio-pizzol May 5, 2026
7cb1c56
fix(demos/custom-ui): gate insert-clause shortcut on the same disable…
caio-pizzol May 5, 2026
51dde69
fix(demos/custom-ui): move useDecidedChanges inside SuperDocUIProvider
caio-pizzol May 5, 2026
311891b
fix(demos/custom-ui): drop .editor-shell filter from contextmenu list…
caio-pizzol May 5, 2026
927bcaf
fix(demos/custom-ui): add Copy + Comment fallbacks to context menu
caio-pizzol May 5, 2026
2bffb9d
fix(demos/custom-ui): scope context menu to editor surface and add fa…
caio-pizzol May 5, 2026
fa82b52
fix(demos/custom-ui): show Bold/Italic in context menu without a sele…
caio-pizzol May 5, 2026
5568793
refactor(demos/custom-ui): apply strict three-surface split for menus
caio-pizzol May 5, 2026
41f4c85
refactor(demos/custom-ui): consume contextAt + invoke (SD-2945)
caio-pizzol May 5, 2026
ef78262
feat(demos/custom-ui): add point-anchored Insert clause here (SD-2945)
caio-pizzol May 5, 2026
2d6705d
fix(demos/custom-ui): stable callbacks + accurate ContextMenu docs
caio-pizzol May 5, 2026
64918d4
fix(demos/custom-ui): scope Insert clause here to non-entity clicks (…
caio-pizzol May 5, 2026
9499b5c
chore(demos/custom-ui): scrub personal data from sample-review.docx
caio-pizzol May 5, 2026
b8c65d5
chore(demos/custom-ui): public-ready pass
caio-pizzol May 5, 2026
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
80 changes: 44 additions & 36 deletions demos/custom-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <SuperDocEditor> + onReady
├── Toolbar ui.commands + setDocumentMode
└── ActivitySidebar ui.comments + ui.trackChanges + ui.selection
└── CommentComposer ui.selection.capture()
SuperDocUIProvider one controller per app
└── EditorMount <SuperDocEditor> + 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 `<InsertClauseButton>` 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 `<SuperDocEditor>` 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 `<InsertClauseButton>` 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.
Binary file modified demos/custom-ui/public/sample-review.docx
Binary file not shown.
51 changes: 43 additions & 8 deletions demos/custom-ui/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,45 +1,80 @@
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 (
<SuperDocUIProvider>
<AppInner />
</SuperDocUIProvider>
);
}

/**
* Hooks that subscribe to the controller (like `useDecidedChanges`)
* have to live INSIDE `<SuperDocUIProvider>`, 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 (
<SuperDocUIProvider>
<div className="app">
<header className="app-header">
<div className="app">
<header className="app-header">
<h1>Contract Review Workspace</h1>
<span className="subtitle">Memorandum · review pending</span>
</header>

<div className="app-body">
<section className="editor-area">
<div className="toolbar-shell">
<Toolbar onComposeComment={() => setComposeOpen(true)} />
<Toolbar onComposeComment={openComposer} />
</div>
<div className="editor-shell">
<EditorMount />
</div>
<SelectionPopover onComposeComment={openComposer} />
<ContextMenu />
<ContextMenuRegistrations decided={decided} onComposeComment={openComposer} />
</section>

<aside className="sidebar">
<div className="sidebar-header">Activity</div>
<div className="sidebar-panel">
<ActivitySidebar
composeOpen={composeOpen}
onCloseComposer={() => setComposeOpen(false)}
onCloseComposer={closeComposer}
decided={decided}
/>
</div>
</aside>
</div>
</div>
</SuperDocUIProvider>
</div>
);
}
70 changes: 14 additions & 56 deletions demos/custom-ui/src/components/ActivitySidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand All @@ -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;
}

/**
Expand All @@ -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<Map<string, DecidedChange>>(() => 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<string | null>(() => {
Expand Down Expand Up @@ -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<string>();
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<HTMLDivElement | null>(null);
useEffect(() => {
Expand Down
Loading
Loading