Skip to content

feat(superdoc/ui): selection slice exposes SelectionTarget alongside TextTarget (SD-2812)#3010

Merged
caio-pizzol merged 7 commits into
mainfrom
caio/sd-2812-selection-target-alignment
Apr 29, 2026
Merged

feat(superdoc/ui): selection slice exposes SelectionTarget alongside TextTarget (SD-2812)#3010
caio-pizzol merged 7 commits into
mainfrom
caio/sd-2812-selection-target-alignment

Conversation

@caio-pizzol
Copy link
Copy Markdown
Contributor

A consumer reading the current cursor through ui.selection got a TextTarget β€” the right shape for editor.doc.comments.create and other range mutations. The same cursor in SelectionTarget shape is what editor.doc.insert, editor.doc.text.replace, and similar point/range operations expect. Two shapes for the same cursor, no public conversion helper, so every consumer wrote the lift manually.

This was the highest-impact friction point flagged by the build-your-own-ui example (PR #3008): the custom Insert Clause command had to do this exact conversion inline.

The fix: add selectionTarget to SelectionSlice, derived from target in the same memo path so identity stays stable across no-op recomputes. Single-segment selection collapses to start/end on the same blockId; multi-block uses the first segment's start and the last segment's end.

ui.selection.getSnapshot().target          // TextTarget β€” for comments / format.apply
ui.selection.getSnapshot().selectionTarget // SelectionTarget β€” for insert / replace

Also re-exports SelectionTarget, SelectionPoint, TextTarget, TextSegment, TextAddress, and EntityAddress from superdoc/ui and the public superdoc/ui sub-entry. Consumers no longer need to know @superdoc/document-api exists. Partial close on SD-2815 (broader type re-export pass).

Verified:

  • pnpm --filter @superdoc/super-editor run test:ui β†’ 104 pass (102 prior + 2 new for multi-block lift and null pass-through)
  • pnpm exec tsc -b tsconfig.references.json β†’ clean

Closes SD-2812.

…TextTarget (SD-2812)

A consumer reading the current cursor through `ui.selection` got a
TextTarget β€” the right shape for `editor.doc.comments.create` and other
range mutations. The same cursor in SelectionTarget shape (kind:
'selection' with explicit start/end SelectionPoints) is what
`editor.doc.insert`, `editor.doc.text.replace`, and similar
point/range operations expect. Two shapes for the same cursor, no
public conversion, so every consumer wrote the lift manually:

  const seg = ui.selection.getSnapshot().target?.segments[0];
  const target = {
    kind: 'selection',
    start: { kind: 'text', blockId: seg.blockId, offset: seg.range.start },
    end: { kind: 'text', blockId: seg.blockId, offset: seg.range.end },
  };
  editor.doc.insert({ value, type: 'text', target });

This was real friction: the build-your-own-ui example app had to do
exactly this conversion in its custom Insert Clause command. Filed as
SD-2812.

Fix: add `selectionTarget` to `SelectionSlice`, computed from `target`
in the same memo path so identity stays stable across no-op
recomputes. Single-segment selection produces start/end on the same
blockId; multi-block uses the first segment's start and the last
segment's end (the doc-api adapter resolves inner blocks via the same
walk it already does internally).

  ui.selection.getSnapshot().target          // TextTarget β€” for comments / format.apply
  ui.selection.getSnapshot().selectionTarget // SelectionTarget β€” for insert / replace

Also re-exports `SelectionTarget`, `SelectionPoint`, `TextTarget`,
`TextSegment`, `TextAddress`, and `EntityAddress` from `superdoc/ui`
and the public `superdoc/ui` sub-entry. Consumers no longer need to
know `@superdoc/document-api` exists. Partial close on SD-2815 (the
broader type re-export pass).

Tests: 2 new (multi-block lift, null pass-through) + 2 updated for
the wider slice shape. 104 ui tests pass, tsc -b clean.
@caio-pizzol caio-pizzol requested a review from a team as a code owner April 29, 2026 19:24
@linear
Copy link
Copy Markdown

linear Bot commented Apr 29, 2026

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ’‘ Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6ab7b28ab2

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with πŸ‘.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/super-editor/src/ui/create-super-doc-ui.ts Outdated
@codecov-commenter

This comment was marked as outdated.

caio-pizzol and others added 6 commits April 29, 2026 16:42
…(PR #3010 review)

The TextTarget→SelectionTarget converter dropped the optional `story`
field. Mutation operations route from `target.story`; without it,
inserts and replaces against `ui.selection.selectionTarget` for a
header / footer / footnote / endnote selection would silently route
into the body and either fail to resolve the block or edit the wrong
story.

Fix: copy `story` onto every SelectionPoint and onto the
SelectionTarget root. Also fold `story` into the selection memo key
so a cursor move from one story to another (same blockId/offset by
coincidence) busts the cache and re-derives the slice.

Adds a regression test for the header-selection case.
* feat(superdoc/ui): official React provider and hooks (SD-2813)

The build-your-own-ui example app had to write four pieces of glue
from scratch: a context provider that creates one controller per app,
a hook to read it, a hook that subscribes a component to a slice of
controller state, and the unmount lifecycle that destroys the
controller. Two of those are easy to get wrong β€” the unmount in
particular has a stale-closure bug (`useEffect(() => () =>
ui?.destroy(), [])` captures the initial null, leaks subscriptions on
unmount).

Ship the bindings officially so consumers don't reinvent them.

  import {
    SuperDocUIProvider,
    useSuperDocUI,
    useSuperDocHost,
    useSetSuperDoc,
    useSuperDocSlice,
    useSuperDocSelection,
    useSuperDocComments,
    useSuperDocReview,
    useSuperDocToolbar,
    useSuperDocCommand,
  } from 'superdoc/ui/react';

Plumbing
  - New `./ui/react` exports entry on @superdoc/super-editor.
  - New Vite build input emitting `dist/ui-react.es.js`.
  - Public sub-entry on the superdoc package: `superdoc/ui/react`.
  - Vite alias ordered before `/ui` so the longer path matches first.
  - `react` and `react/jsx-runtime` externalized in the rollup config
    so the built artifact stays peer-dep-friendly.
  - tsconfig grows `"jsx": "react-jsx"` so `.tsx` files compile.
  - @testing-library/react + react-dom + @types/react-dom added as
    devDeps.

Verified
  - 10 new tests (6 provider, 4 domain hooks); 114 ui tests pass total
  - tsc -b clean
  - superdoc build emits dist/ui-react.es.js with react +
    react/jsx-runtime as external imports
  - bundle audit still clean

Open follow-ups: useSuperDocCustomCommand once `ui.commands.get(id)`
lands (SD-2814). Vue equivalent deferred until the React surface is
exercised against real consumer apps.

* fix(superdoc/ui): address PR #3011 review (StrictMode + id resubscribe + types) (SD-2813)

Four review fixes from the React-bindings PR:

1. SuperDocUIProvider.setSuperDoc no longer constructs the controller
   inside a setUI((prev) => ...) updater. Under React StrictMode, React
   double-invokes state-updater functions for purity-checking, which
   would call createSuperDocUI() twice and leak one controller's
   editor / SuperDoc subscriptions per call. Construction now lives
   in the callback body and the prior controller is torn down via the
   uiRef. A regression test reproduces the leak (24 editor.on calls
   under StrictMode without the fix vs 12 with).

2. useSuperDocCommand(id) bypasses useSuperDocSlice and subscribes
   with [ui, id] effect deps. The selector closes over id, but
   useSuperDocSlice's effect only re-runs when ui changes, so a
   toolbar that reuses one component slot with different command ids
   would observe the prior command forever. A regression test
   reproduces the stale read.

3. typesVersions adds the ui/react entry so TypeScript projects on
   the legacy moduleResolution: "node" can resolve declarations.
   exports already had it, but typesVersions is what matters for
   classic resolution (the project follows this pattern for every
   other public subpath).

4. super-editor's vite build externalizes react/jsx-runtime defensively.
   The published consumer path (superdoc/ui/react) already externalized
   correctly via aliases, but this keeps the intermediate
   @superdoc/super-editor dist compatible with React 17/18 hosts in
   case any pnpm-link / examples consumer reaches it directly.
…-2814) (#3013)

* feat(superdoc/ui): ui.commands.get(id) for dynamic toolbar lookup (SD-2814)

Adds a typed string-indexed lookup on the commands surface so consumers
iterating over command IDs from a config array can resolve handles
without unsafe casts.

The Proxy-driven `ui.commands` mixes per-command handles, the
`register()` method, and custom IDs, which makes string-indexed
lookup type-error today: consumers fall back to `as unknown as`
casts at every dispatch site.

`ui.commands.get(id)` returns a unified `DynamicCommandHandle` for
built-in or custom IDs and `undefined` for unknown IDs. Custom
takes priority so `register({ override: true })` is honored. The
emitted state carries the `source` discriminator so a single
render path can drive both built-ins and customs without branching.

* fix(superdoc/ui): cached dynamic handle dispatches through later override (SD-2814)

Addresses PR #3013 review (P1): a `DynamicCommandHandle` returned by
`ui.commands.get('bold')` before a later
`register({ id: 'bold', override: true })` kept routing execute
through `toolbarController.execute('bold', ...)` even though the same
handle's observe stream now emits the merged custom state. Config
driven toolbars that memoize handles once would render the override
visually while clicks ran the original built-in.

The built-in dynamic handle now re-resolves at dispatch time:
`customCommandsRegistry.has(id)` is checked before falling back to
the toolbar controller, so override semantics hold for long-lived
handles. Two regression tests cover the override path and the
revert-after-unregister path.
* feat(superdoc/ui): re-export public document types (SD-2815)

The browser UI controller surfaces document-side shapes everywhere:
state.comments.items returns CommentInfo records, action methods
return Receipt, ui.viewport.scrollIntoView accepts ScrollIntoViewInput,
state.review.items references TrackChangeInfo, etc. Consumers typing
their components had to reach into @superdoc/document-api directly,
which isn't on the recommended import path.

Re-exports the controller-surfaced doc-api types from superdoc/ui
and packages/superdoc/src/ui.d.ts so consumers can write the
custom-toolbar / sidebar example types entirely from one entrypoint.
The types resolve to the same shapes reached through the root
superdoc import - parity asserted in customer-scenario.ts via
distribution-equivalence checks.

Adds:
- CommentInfo / CommentsListQuery / CommentsListResult
- TrackChangeInfo / TrackChangesListResult
- Receipt
- ScrollIntoViewInput / ScrollIntoViewOutput
- SelectionInfo
- CommentAddress / TrackedChangeAddress

* fix(superdoc/ui): ship real doc-api types for packed consumers (PR #3014 review)

The PR #3014 (SD-2815) re-exports added on superdoc/ui resolved
through @superdoc/document-api, which is private to the workspace
and not published. The ensure-types.cjs post-build step generated an
ambient `declare module '@superdoc/document-api' { ... = any }` shim
in dist/_internal-shims.d.ts so consumer compiles wouldn't error,
but every doc-api type re-exported through superdoc/ui (CommentInfo,
Receipt, SelectionInfo, TextTarget, etc.) collapsed to `any` for
packed consumers. Components typed against these shapes had no
checking, defeating the purpose of the new public surface.

Three coordinated changes:

1. vite.config.js dts plugin now includes ../document-api/src/**/*
   so the document-api types emit into dist/document-api/. tsconfig
   include matches.
2. ensure-types.cjs rewrites every bare @superdoc/document-api
   specifier in emitted .d.ts files to a relative path into
   dist/document-api/, and skips the package when generating the
   _internal-shims.d.ts ambient declarations (parity with how
   @superdoc/super-editor was already handled).
3. tests/consumer-typecheck/customer-scenario.ts adds an
   `IsNotAny<T>` distribution check on every newly re-exported
   doc-api type. If a future change drops the document-api dist or
   loses the import-rewrite, the consumer-typecheck fails (assigning
   `boolean` to `true`).

Verified end-to-end by packing superdoc, installing into
consumer-typecheck, running tsc --noEmit. The IsNotAny guards pass;
_internal-shims.d.ts no longer carries the doc-api types.
…omposers (SD-2821) (#3016)

* feat(superdoc/ui): ui.selection.capture for sidebar / floating-menu composers (SD-2821)

A sidebar comment composer or floating menu takes focus into its
own input element when it opens; the editor's selection visually
clears and `state.selection.target` becomes null. A consumer that
calls `editor.doc.comments.create({ target })` on submit then has
no anchor and the create rejects.

Adds `ui.selection.capture(): SelectionCapture | null` so consumers
freeze the addressable selection at the moment the composer opens
(or the menu mounts) and pass `captured.target` /
`captured.selectionTarget` straight into `editor.doc.*` actions
when the composer submits. The captured handle is `Object.freeze`d
so a stored reference can't be accidentally mutated across renders.

Visual restore (re-focus the editor + re-highlight the captured
range) is intentionally NOT on this surface yet. The public
Document API has no `selection.set` primitive today, and routing
through `editor.commands.*` would skip the contract this controller
is explicitly built on. A `restore(capture)` method lands once the
doc-api primitive does.

Returns null on non-text selections, no-editor state, or pre-ready
snapshots so the consumer's null-guard is the explicit "capture
isn't applicable here" signal instead of silent failure later in
the flow.

3 new tests: addressable-selection capture, null-on-no-anchor,
captured value survives a later live-selection clear (the
documented sidebar-composer scenario). 131 ui tests pass; bundle
audit clean.

* fix(superdoc/ui): deep-freeze captured selection (PR #3016 review)

The shallow `Object.freeze({ ...slice })` in `ui.selection.capture()`
left nested fields (target, target.segments, activeMarks array)
mutable AND sharing references with the controller's memoized
selection slice. A consumer doing
`captured.target.segments[0].range.start = 99` or
`captured.activeMarks.push('foo')` would corrupt the shared
snapshot every other subscriber sees and feed bad targets into
later editor.doc.* calls, despite the API promising a frozen
captured handle.

Capture now deep-clones the slice before deep-freezing the clone.
The clone breaks the reference share with the memo so a frozen
captured handle is genuinely independent. JSON-style structural
clone is sufficient: the selection slice is plain data with no
functions, Dates, Maps, or cycles. Recursive freeze short-circuits
on already-frozen values so EMPTY_ACTIVE_IDS doesn't loop.

Regression test asserts every nested field is frozen and that
strict-mode mutation attempts on captured.target.segments[0].range
and captured.activeMarks throw, leaving the live snapshot from
ui.selection.getSnapshot() unaffected.
… + polish (PR #3010 review)

Six review findings rolled into one commit. Each verified and tested.

1. Memo key for selection slice was using made-up StoryLocator
   field names (`story.type` / `story.id`) instead of the real
   discriminated-union shape (`storyType` / `refId` / `noteId` /
   `section` / `headerFooterKind` / `variant`). Two selections in
   different stories collapsed to the same key, defeating the memo
   bust the comment claimed. Fixed to walk every discriminating
   field; once the doc-api resolver starts stamping `target.story`
   for non-body surfaces (separate ticket), cross-story navigation
   correctly invalidates the slice.

2. Override-routing inconsistency. `ui.commands.get(id)?.execute()`
   re-resolved through the custom registry on every call, but
   `ui.commands.bold.execute()` and `ui.toolbar.execute('bold')`
   went straight to the headless-toolbar built-in. After
   `register({ id: 'bold', override: true })`, `state.toolbar.commands.bold`
   showed `source: 'custom'` while clicks via the per-id /
   aggregate surfaces ran the original built-in. Centralized as
   `dispatchCommand(id, payload)`; all three paths use it. Two
   regression tests cover the per-id and aggregate surfaces.

3. Stale custom-command execute / observe through replacement.
   `regA.handle.execute()` after a custom-vs-custom replace ran
   B's executor because `buildHandle` closed only over `id` and
   `registry.execute(id)` was identity-blind. Bound the handle to
   its own `InternalCustomEntry` and added an identity check on
   every execute and observe emit: stale handles return `false`
   from execute and detach from observe.

4. Stale JSDoc on `ui.comments.reopen`: said the doc-api
   "currently throws INVALID_INPUT". SD-2789 shipped the lifecycle
   inverse; updated the prose to match.

5. Stale "skeleton" framing in the top-level types.ts JSDoc:
   the surface is no longer a skeleton. Reframed to describe the
   substrate vs. domain handles split.

6. `SelectionCapture` was typed as `SelectionSlice` even though
   the runtime calls `deepFreeze`. Changed to `DeepReadonly<SelectionSlice>`
   so the static type matches reality; consumer mutations on
   nested fields are TypeScript errors at compile time.

Plus: added a JSDoc paragraph on `SelectionSlice.selectionTarget`
documenting the known gap that the doc-api resolver doesn't yet
stamp `target.story` for non-body surfaces (header / footer /
footnote / endnote). Story preservation in the lift is honored
once the resolver starts stamping; tracked as a separate doc-api
ticket.

Plus: added `src/ui-react.js` to the superdoc package's coverage
exclude list. The file is a pure re-export barrel like the other
already-excluded `superdoc/headless-toolbar*` and `superdoc/ui`
barrels; missing it from the list caused the codecov patch report
to flag 8 missing lines for SD-2813.

Verified: 136 ui tests pass (4 new regressions); bundle audit clean.
@caio-pizzol caio-pizzol merged commit 4c5d84e into main Apr 29, 2026
41 of 47 checks passed
@caio-pizzol caio-pizzol deleted the caio/sd-2812-selection-target-alignment branch April 29, 2026 22:32
@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented Apr 29, 2026

πŸŽ‰ This PR is included in @superdoc-dev/mcp v0.3.0-next.13

The release is available on GitHub release

caio-pizzol added a commit that referenced this pull request Apr 29, 2026
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.
@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented Apr 29, 2026

πŸŽ‰ This PR is included in vscode-ext v2.3.0-next.59

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented Apr 29, 2026

πŸŽ‰ This PR is included in @superdoc-dev/react v1.2.0-next.57

The release is available on GitHub release

caio-pizzol added a commit that referenced this pull request Apr 29, 2026
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.
@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented Apr 29, 2026

πŸŽ‰ This PR is included in superdoc v1.30.0-next.17

The release is available on GitHub release

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented Apr 29, 2026

πŸŽ‰ This PR is included in superdoc-cli v0.8.0-next.32

The release is available on GitHub release

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented Apr 29, 2026

πŸŽ‰ This PR is included in superdoc-sdk v1.8.0-next.18

caio-pizzol added a commit that referenced this pull request Apr 30, 2026
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.
@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented Apr 30, 2026

πŸŽ‰ This PR is included in superdoc v1.30.0

The release is available on GitHub release

caio-pizzol added a commit that referenced this pull request Apr 30, 2026
…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)
@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented May 1, 2026

πŸŽ‰ This PR is included in superdoc-cli v0.8.0

The release is available on GitHub release

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented May 1, 2026

πŸŽ‰ This PR is included in superdoc-sdk v1.8.0

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented May 1, 2026

πŸŽ‰ This PR is included in @superdoc-dev/mcp v0.3.0

The release is available on GitHub release

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented May 5, 2026

πŸŽ‰ This PR is included in vscode-ext v2.3.0

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented May 7, 2026

πŸŽ‰ This PR is included in @superdoc-dev/react v1.3.0

The release is available on GitHub release

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants