Skip to content

Commit c6af392

Browse files
authored
feat(examples): build-your-own-ui contract review example (SD-2671) (#3008)
* feat(examples): build-your-own-ui example app on superdoc/ui (SD-2671) Public-facing example showing how a consumer wires their own toolbar, comments sidebar, review sidebar, and custom toolbar commands to SuperDoc through createSuperDocUI({ superdoc }). Replaces the earlier dropin-assessment harness (closed PR #3006). The TipTap-vs-SuperDoc EditorAdapter shape was useful for internal validation but teaches the wrong mental model: consumers don't wrap SuperDoc to make it look like TipTap, they bind their UI to the controller. The new example reflects that posture. What it demonstrates - <SuperDocEditor> mounted inside a custom three-pane layout (contained + hideToolbar) so the wrapper doesn't take over the page. - Custom toolbar (bold/italic/underline, undo/redo, lists, comment) bound to ui.toolbar snapshot + ui.commands.<id>.execute. - Comments sidebar bound to ui.comments.subscribe + resolve / reopen / scrollTo / createFromSelection. - Review sidebar (merged comments + tracked changes feed) bound to ui.review.subscribe + accept / reject / next / previous / scrollTo. - Custom command via ui.commands.register({...}) — a legal-tech "Insert Clause" button with a hardcoded local clause library. Registered from its own component, not at boot. - useSuperDocSlice(pickSubscribable, initial) glue hook in SuperDocUIProvider.tsx — consumers will copy this. Architecture - SuperDocUIProvider holds exactly one controller per app, created on the first onReady, destroyed on unmount. - Components consume the controller via useSuperDocUI() (returns null until the editor is ready) and useSuperDocSlice for typed snapshot bindings via ui.select(...). - No EditorAdapter abstraction. No TipTap dependency. No UI kit dependency. No backend. No AI provider. No direct ProseMirror access. README leads with "build your own UI" and includes a "what this intentionally does not do" section so the architectural posture is explicit. Verified in browser (port 5189): - Editor loads sample-review.docx with comments + tracked changes - Toolbar reflects active mark state - Comments sidebar shows 3 cards with resolve / scroll-to - Review tab shows 3 comments + 3 insertion changes with accept / reject and previous / next - Insert clause menu opens with three clauses * refactor(examples): unified Activity sidebar, public-API custom command, app polish (SD-2671) Iterates on the build-your-own-ui example based on UX review: - Move to `examples/advanced/build-your-own-ui/`. The taxonomy is examples/ for narrow how-tos, demos/ for full workflows, labs/ for internal artifacts. This combines several public primitives (toolbar, comments, review, custom command) so it lives under examples/advanced/. - Replace the dual Comments/Review tabs with a single Activity panel rendering the merged ui.review feed. Cards split into Active / Resolved sections; resolved comments dim with a strikethrough on body text; clicking any card scrolls the editor to its anchor (no explicit "Scroll to" button needed). - Wire active-card highlight to the document selection. SD-2792 already exposes activeCommentIds / activeChangeIds on the selection slice; the panel watches them and auto-scrolls the matching card into view. No extra event needed from the controller. - Mimic the Google Docs accept-to-resolved trail: when the user clicks Accept or Reject on a tracked change, capture a snapshot locally before the doc-api call (the change vanishes from the live `ui.review` feed once decided) and render it in the Resolved section with a "Suggestion accepted/rejected" footer. State is component-local; refresh wipes it, which is fine for a demo. - Replace the toolbar's `window.prompt` comment flow with an inline `<CommentComposer>` in the activity panel. The toolbar's comment button delegates open-state to App-root via prop; the composer captures the current selection target on submit and posts via `ui.comments.createFromSelection({ text })`. - Custom command (Insert clause) now uses public APIs only: routes through `editor.doc.insert({ value, type: 'text', target })` instead of `editor.commands.insertContent`. The doc-api expects a SelectionTarget (kind: 'selection' with start/end points), so the example shows the conversion from `ui.selection`'s `TextTarget` inline. Verified end-to-end: selecting a clause inserts the paragraph at the cursor. - Custom command observes its own state via `reg.handle.observe(...)` instead of duplicating the readiness logic locally. The button's disabled state now flows from the registered command's snapshot, proving custom commands are first-class citizens of `ui.commands`. - Fix provider unmount destroy bug. The previous `useEffect(() => () => ui?.destroy(), [])` captured the initial null. Switched to a ref so the cleanup walks the latest controller without re-running the effect on every change. - Add `useSuperDocHost()` to the provider for operations not on the controller surface (currently: `superdoc.export({...})`). Toolbar grows an Export DOCX button so the user can confirm comments, tracked-change decisions, and inserted clauses round-trip into the downloaded file. - Rename in-app title from "SuperDoc — Build your own UI" to "Contract Review Workspace" so the running app feels like a consumer product, not a labeled demo. Explanatory copy stays in the README. - Fix undo/redo icons (the previous Lucide path data rendered as incomplete circles). Verified in the dev app at port 5189: - Toolbar reflects mark state, undo/redo render correctly - Inline composer creates comments anchored to the selection - Click-card scrolls editor; cursor in document highlights card - Accept/reject moves the change to the Resolved section with the Google Docs-style audit row - Insert clause adds the clause text at the cursor and the document remains exportable to DOCX Closes SD-2802 friction in the example app (custom command is now purely public-API). Closes the open feedback from the model review. * refactor(examples): migrate BYO-UI to PR #3010 official APIs (SD-2671) PR #3010 ships the public surface this example was previously building glue against: - `superdoc/ui/react`: official `SuperDocUIProvider` + typed domain hooks. Drops the local `lib/SuperDocUIProvider.tsx` (158 lines) and the ad-hoc `useSuperDocSlice` calls in every component. - `state.selection.selectionTarget`: pre-derived SelectionTarget for point/range doc-api operations. `InsertClauseButton` drops the inline `TextTarget -> SelectionTarget` lift; just passes the slice field straight to `editor.doc.insert({ target })`. - `ui.commands.get(id)`: typed dynamic command lookup. The `Toolbar` no longer casts `(ui.commands as Record<string, ...>)[id]` to call a string-keyed handle. - `useSuperDocCommand(id)`: per-button granular subscription. The toolbar's built-in buttons each subscribe to ONLY their own command's state, instead of a single `useSuperDocSlice` over the whole toolbar snapshot. - `ui.selection.capture()`: addresses the sidebar-composer focus loss the example previously punted on. `CommentComposer` now freezes the selection at mount and posts against `captured.target` instead of a live read that's null while the textarea has focus. The CommentComposer reaches `editor.doc.comments.create` through the host (`useSuperDocHost`) until `ui.comments.createFromCapture` lands as a typed action method. The escape-hatch is documented inline. The example app builds cleanly against the rebased SD-2812 stack. * fix(superdoc/ui): revert SelectionCapture readonly type (consumer-facing friction) The DeepReadonly typing forced a cast at every `editor.doc.*` call site that consumes a captured target — the canonical use case for capture. The runtime deep-freeze plus the existing regression test catch mutation attempts; the static type signal isn't worth the per-call cast tax in consumer code. Walking the BYO UI example exposed this: `editor.doc.comments.create({ target: captured.target })` failed because TextTarget's `[TextSegment, ...TextSegment[]]` non-empty tuple isn't assignable from `readonly TextSegment[]`. Forcing every consumer to write `captured.target as TextTarget` is hostile DX for a public surface. * fix(examples): reconcile decidedChanges with live review feed (PR #3008 review) The local `decidedChanges` map was populated when the user clicked accept / reject but never reconciled against `review.items`. If a change came back into the live feed (undo of the decision, collaborator restore, etc.), the same id rendered in BOTH the Active and Resolved sections with a stale "accepted" / "rejected" label. Added an effect keyed on `review.items` that prunes any decided entry whose id is back in the live feed. Single-pass, no-op fast path when the prev map is empty or no overlap is found. * fix(examples): export DOCX preserves imported comments (PR #3008 review) Setting `modules: { comments: false }` on the React wrapper to hide SuperDoc's built-in floating-comment UI also short-circuits comment ingest at the data layer (`packages/superdoc/src/composables/use-document.js` line 88: when the flag is falsy, `initConversations()` returns []). The commentsStore stays empty, and `host.export({ commentsType: 'external' })` then writes that empty list into the DOCX, dropping every imported comment from the round-trip. The user reported it directly: "default comments and tracked changes displayed in the editor are not persisted in the export." The bot review caught the same root cause. Removed the flag. The default config loads imported comments, the export round-trips correctly, and the built-in floating UI stays hidden via the `contained` layout the example already uses. Tracked changes were already fine (they live as PM marks on the doc and round-trip through `editor.exportDocx` regardless); this fix restores the comments half of the round-trip. A follow-up ticket should add a "hide UI without disabling storage" option so consumers who really want to suppress the built-in floating UI in non-contained layouts have a non-destructive switch. * fix(superdoc): export reads engine comments when UI store is empty (PR #3008 review) Decouples the DOCX comment-export round-trip from `modules.comments`'s UI flag. The previous behavior conflated two responsibilities into the same export path: - `Editor.exportDocx({ comments })` already had the right contract: `effectiveComments = comments ?? this.converter.comments ?? []`, falling back to the engine's imported comments when the caller passes `undefined`. - `SuperDoc.exportEditorsToDOCX` always passed a defined `comments` array (often `[]`), which silently overrode that fallback. When a consumer set `modules.comments: false` to hide SuperDoc's built-in floating comment UI, the UI commentsStore stayed empty and the export wrote `comments: []` to `Editor.exportDocx`, dropping every comment imported from the source DOCX. Imports survived in `editor.converter.comments`; the export just refused to read them. The fix is in the export adapter, not in the import gate. Comments are now only passed through when the UI store has them OR when `commentsType: 'clean'` explicitly demands stripping; otherwise pass `undefined` and let the engine fall back to its own imported set. Three regression tests pin the boundary: - empty UI store + default `commentsType` => `comments: undefined` so the engine's converter-comments fallback fires - `commentsType: 'clean'` => `comments: []` regardless of UI store - non-empty UI store => UI snapshot wins Restored `modules: { comments: false }` in the BYO UI example to demonstrate the canonical "hide built-in UI, drive comments through ui.comments" pattern. Documented the data-vs-UI split inline so a reader of the example understands why the flag is safe to set now. A broader architectural cleanup (split `commentsDataEnabled` from `builtInCommentsUiEnabled`, remove the UI-flag gate from `Editor.#initComments` and the collaboration sync helpers) is a follow-up; this commit closes the consumer-visible export bug without that scope. * test(superdoc): pin export-comments contract at both layers Reviewer flagged a real ambiguity in the previous patch: an empty UI commentsStore meant either "store unhydrated, fall back to converter.comments" or "store hydrated and user deleted everything, authoritative empty." The fix now branches on `modules.comments === false` to distinguish those, but the regression coverage was thin. This commit pins the boundary at both layers. SuperDoc adapter (packages/superdoc/src/core/SuperDoc.test.js): 1. modules.comments: false + UI store empty -> comments: undefined (BYO consumers keep imported comments via engine fallback). 2. modules.comments: enabled + UI store hydrated and empty -> comments: [] (deletion does NOT resurrect imports — this is the bug the reviewer caught). 3. UI store has entries -> entries pass through. 4. commentsType: 'clean' overrides everything -> comments: []. 5. commentsType: 'clean' even with modules.comments: false -> comments: [] (clean must beat the BYO fallback). 6. Missing commentsStore (race / partial init) -> comments: undefined and no throw. Editor engine (packages/super-editor/src/editors/v1/tests/export/ exportDocx.commentsFallback.test.js): 1. exportDocx() with no caller comments -> spy on `converter.exportToDocx` confirms the engine fell back to `converter.comments`. 2. exportDocx({ comments: [] }) -> spy confirms zero comments reached the writer (no `??` -> `||` regression). 3. exportDocx({ comments: [...] }) -> caller array is the source of truth. 4. converter.comments null/undefined -> resolves to [] without throwing. The Editor-level test set is the contract layer the SuperDoc adapter relies on. Pinning both is the only way to keep the "empty means different things in different layers" invariant honest under future refactors. * feat(examples): reimport-DOCX button for round-trip testing (SD-2671) Adds "Reimport DOCX" next to the Export button. The user exports a DOCX, opens it in Word (or anything that emits OOXML), edits / comments / accept-rejects there, then reimports the modified file. The Activity sidebar updates automatically because: - Tracked changes live as PM marks; `replaceFile` swaps the doc and fires `transaction`, which the controller already listens to. `ui.review` re-emits its merged feed on the next microtask. - Imported comments end up in `editor.converter.comments` after `replaceFile` runs `#createConverter`. The controller normally refreshes its `ui.comments` cache on the editor's `commentsLoaded` event, but with `modules.comments: false` (our BYO posture), `Editor.#initComments()` short-circuits and never emits. Until SD-2839 splits "comment data" from "comment UI" properly, the button manually re-emits `commentsLoaded` after `replaceFile` resolves so the controller picks up the new converter.comments. The manual emit is documented inline as a workaround tied to a specific Linear issue. Once SD-2839 lands, the emit becomes a no-op (the platform will emit on its own) and can be removed without changing the example's user-visible behavior. This unblocks the canonical demo flow: export, edit in Word, see the changes show up here. Tracked changes round-trip without any manual help — the test from earlier (deleting "ordinary course" in suggesting mode) demonstrated the export side; this completes the loop. * refactor(examples): shorten Toolbar button labels to Import / Export (SD-2671) * feat(examples): thread comment replies and split paired replacements (SD-2671) * fix(presentation-editor): honor caller scroll behavior in navigateTo (SD-2671) * chore(byo-ui): final pass — viewport test, README rewrite, EOF whitespace (SD-2671) * docs(byo-ui): replace em dashes per project style (SD-2671) * docs(byo-ui): tighten README to brand voice (SD-2671) * refactor(byo-ui): move to demos and wire smoke test (SD-2671) * chore(byo-ui): drop unused sample.docx (SD-2671) * feat(ui): ui.comments.createFromCapture for selection-snapshot composers (SD-2817) * feat(byo-ui): edit / suggest mode toggle for tracked-change workflow (SD-2671) * refactor(byo-ui): use exported SuperDoc types end-to-end (SD-2671) * docs(byo-ui): drop README Types section as internal trivia (SD-2671) * chore: standardize AGENTS.md as symlink to CLAUDE.md (SD-2671) * ci(demos): build @superdoc-dev/react before smoke tests (SD-2671) * refactor(byo-ui): use ui.document for mode toggle and export (SD-2671) * feat(ui): ui.document.replaceFile + useSuperDocDocument hook (SD-2671) * feat(byo-ui): inline reply composer on comment cards (SD-2671)
1 parent 45c0532 commit c6af392

35 files changed

Lines changed: 2749 additions & 354 deletions

.github/workflows/ci-demos.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ jobs:
3333
- name: Build superdoc
3434
run: pnpm build:superdoc
3535

36+
- name: Build @superdoc-dev/react
37+
# build-your-own-ui imports the React wrapper from its dist/.
38+
# Other demos don't need this; cheap enough to run unconditionally.
39+
run: pnpm --filter @superdoc-dev/react run build
40+
3641
- name: Install Playwright
3742
working-directory: demos/__tests__
3843
run: npx playwright install chromium
@@ -52,6 +57,7 @@ jobs:
5257
fail-fast: false
5358
matrix:
5459
demo:
60+
- build-your-own-ui
5561
- cdn
5662
- custom-mark
5763
- custom-node

AGENTS.md

Lines changed: 0 additions & 281 deletions
This file was deleted.

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
CLAUDE.md

CLAUDE.md

Lines changed: 0 additions & 1 deletion
This file was deleted.

0 commit comments

Comments
 (0)