feat(superdoc/ui): toolbar domain + per-command observables (SD-2796)#2980
Merged
caio-pizzol merged 1 commit intoApr 28, 2026
Merged
Conversation
Adds `ui.toolbar` (aggregate) and `ui.commands.<id>` (per-command) on
top of the SD-2794 selector substrate. The toolbar surface is the
first domain to land on `superdoc/ui`; it makes "bring your own
toolbar" a single mental model that consumers wire to React, Vue, or
vanilla DOM.
```ts
import { createSuperDocUI } from 'superdoc/ui';
const ui = createSuperDocUI({ superdoc });
// Aggregate (HeadlessToolbarController-shaped)
ui.toolbar.subscribe(({ snapshot }) => render(snapshot));
ui.toolbar.execute('bold');
// Per-command observable (CKEditor-style fine-grained binding)
ui.commands.bold.observe(({ active, disabled }) => boldBtn.set(active));
ui.commands.bold.execute();
ui.commands['font-size'].observe(({ value }) => fontInput.set(value));
ui.commands['font-size'].execute('14pt');
```
Implementation:
- Internal `createHeadlessToolbar({ superdoc })` is instantiated once
per `createSuperDocUI` call and feeds `state.toolbar`. `ui.toolbar`
delegates `getSnapshot` / `execute` to that controller and routes
`subscribe` through the substrate so subscribers ride the same
microtask-coalesced burst pattern as `ui.select` consumers.
- `ui.commands` is a Proxy returning per-id handles, cached by id so
reference identity is stable (matters for React `useMemo` deps and
for consumers comparing handles). Each handle's `observe` is
internally `ui.select((s) => s.toolbar.commands[id], shallowEqual)`,
so a button bound to one command does not re-render when an
unrelated command's state changes.
- Unknown command ids fall back to `{ active: false, disabled: true,
value: undefined }` rather than throwing, so consumer code paths
remain forgiving while custom-command registration (FRICTION S3)
is filed as follow-up scope.
- Built-in `SuperToolbar.vue` and `superdoc/headless-toolbar` are
unchanged here; both will migrate to consume `ui.toolbar` in
follow-up tickets so this PR stays small and review-friendly.
Why these three shapes coexist (per SD-2667 research):
- Aggregate (`ui.toolbar.subscribe`): matches today's
`HeadlessToolbarController` shape; lets external consumers using
`superdoc/headless-toolbar` migrate without rewriting render code.
- Per-command (`ui.commands.<id>.observe`): CKEditor 5 pattern. A 50-
button toolbar with per-command observables only re-renders the
button whose state changed, not the whole bar.
- Selector substrate (`ui.select`): the underlying primitive. Custom
toolbars with cross-command derived state (e.g. "is any heading
active?") consume that.
Stub-bug found in the SD-2794 test fixture: `fireEditor` iterated a
live Set while the internal headless-toolbar rebound listeners on
every change, picking up the new handler mid-loop and recursing. Fix
snapshots the handler list before iterating, matching how real editor
event buses behave.
Verified: 19 unit tests (11 selector substrate + 8 toolbar/commands).
Full super-editor suite: 11906/13 skipped/0 fail. `pnpm --filter
superdoc build` clean.
Out of scope (filed under SD-2796 as follow-ups inside SD-2667):
- `ui.commands.register({ id, execute, getState })` (closes FRICTION
S3, the closed toolbar registry).
- Refactor `superdoc/headless-toolbar` into a shim around
`createSuperDocUI({ superdoc }).toolbar`.
- Refactor `SuperToolbar.vue` to consume `ui.toolbar` (visual /
behavior parity validation).
85ea258 to
75cb241
Compare
457841c
into
caio/sd-2794-superdoc-ui-skeleton
42 checks passed
caio-pizzol
added a commit
that referenced
this pull request
Apr 29, 2026
Adds `ui.comments` to `createSuperDocUI`: a single subscription +
actions surface for custom comments sidebars built on top of SuperDoc.
```ts
import { createSuperDocUI } from 'superdoc/ui';
const ui = createSuperDocUI({ superdoc });
// Snapshot
ui.comments.subscribe(({ snapshot }) => {
// snapshot.items: CommentInfo[]
// snapshot.activeIds: string[] (mirrors selection.current().activeCommentIds)
// snapshot.total: number
});
// Actions β every mutation routes through editor.doc.* (Document API)
ui.comments.createFromSelection({ text });
ui.comments.resolve(commentId);
ui.comments.reopen(commentId); // routes to comments.patch({ status: 'active' })
ui.comments.delete(commentId);
ui.comments.scrollTo(commentId); // ranges.scrollIntoView({ kind: 'entity', ... })
```
Architecture:
- Subscribe runs through the SD-2794 selector substrate
(`shallowEqual`-deduped). Comments-list is cached at controller
level and refreshed on `commentsUpdate` / `commentsLoaded` editor
events ahead of `scheduleNotify` so `state.comments.items` is fresh
when subscribers see the next snapshot. `activeIds` reads from
`selection.current()` per `computeState()` call (cheap; one walk
the resolver already does for `activeMarks`).
- `activeIds` falls back to `[]` when `selection.current()` predates
SD-2792 (no `activeCommentIds` field). When SD-2792 lands, the
fallback becomes never-used; nothing in this PR breaks if SD-2792
is reverted.
- All execution paths route through `editor.doc.comments.*` and
`editor.doc.ranges.scrollIntoView`. `ui.comments` is a UI-facing
adapter, NOT a parallel mutation contract β `ui.comments.resolve(id)`
and `editor.doc.comments.patch({ id, status: 'resolved' })` produce
the same document mutation by construction.
- `ui.comments.reopen(id)` routes to `comments.patch({ status:
'active' })`. Today doc-API validation rejects 'active' until
SD-2789 ships the lifecycle inverse β that surfaces an
INVALID_INPUT receipt rather than a silent no-op, which is the
correct visible behavior for a not-yet-shipped operation.
Stacks on PR #2979 (SD-2794 skeleton). Unrelated to PR #2980 (SD-2796
toolbar) and PR #2981 (SD-2792 active ids); rebases when those merge.
Verified: 26 unit tests (15 substrate + 11 comments). Full
super-editor: 11913 / 13 skipped / 0 fail. `pnpm --filter superdoc
build` clean β `dist/superdoc/src/ui.d.ts` re-exports the new
`CommentsHandle` and `CommentsSlice` types.
Out of scope (separate tickets):
- Threaded reply UX
- `ui.comments.reopen` body β depends on SD-2789 (today routes to a
doc-API path that throws INVALID_INPUT)
- Refactoring `dropin-assessment` SuperDocAdapter to consume
`ui.comments` instead of `superdoc.on('commentsUpdate')` β separate
PR once stack lands
caio-pizzol
added a commit
that referenced
this pull request
Apr 29, 2026
β¦2982) * feat(superdoc/ui): comments domain (subscribe + actions) (SD-2790) Adds `ui.comments` to `createSuperDocUI`: a single subscription + actions surface for custom comments sidebars built on top of SuperDoc. ```ts import { createSuperDocUI } from 'superdoc/ui'; const ui = createSuperDocUI({ superdoc }); // Snapshot ui.comments.subscribe(({ snapshot }) => { // snapshot.items: CommentInfo[] // snapshot.activeIds: string[] (mirrors selection.current().activeCommentIds) // snapshot.total: number }); // Actions β every mutation routes through editor.doc.* (Document API) ui.comments.createFromSelection({ text }); ui.comments.resolve(commentId); ui.comments.reopen(commentId); // routes to comments.patch({ status: 'active' }) ui.comments.delete(commentId); ui.comments.scrollTo(commentId); // ranges.scrollIntoView({ kind: 'entity', ... }) ``` Architecture: - Subscribe runs through the SD-2794 selector substrate (`shallowEqual`-deduped). Comments-list is cached at controller level and refreshed on `commentsUpdate` / `commentsLoaded` editor events ahead of `scheduleNotify` so `state.comments.items` is fresh when subscribers see the next snapshot. `activeIds` reads from `selection.current()` per `computeState()` call (cheap; one walk the resolver already does for `activeMarks`). - `activeIds` falls back to `[]` when `selection.current()` predates SD-2792 (no `activeCommentIds` field). When SD-2792 lands, the fallback becomes never-used; nothing in this PR breaks if SD-2792 is reverted. - All execution paths route through `editor.doc.comments.*` and `editor.doc.ranges.scrollIntoView`. `ui.comments` is a UI-facing adapter, NOT a parallel mutation contract β `ui.comments.resolve(id)` and `editor.doc.comments.patch({ id, status: 'resolved' })` produce the same document mutation by construction. - `ui.comments.reopen(id)` routes to `comments.patch({ status: 'active' })`. Today doc-API validation rejects 'active' until SD-2789 ships the lifecycle inverse β that surfaces an INVALID_INPUT receipt rather than a silent no-op, which is the correct visible behavior for a not-yet-shipped operation. Stacks on PR #2979 (SD-2794 skeleton). Unrelated to PR #2980 (SD-2796 toolbar) and PR #2981 (SD-2792 active ids); rebases when those merge. Verified: 26 unit tests (15 substrate + 11 comments). Full super-editor: 11913 / 13 skipped / 0 fail. `pnpm --filter superdoc build` clean β `dist/superdoc/src/ui.d.ts` re-exports the new `CommentsHandle` and `CommentsSlice` types. Out of scope (separate tickets): - Threaded reply UX - `ui.comments.reopen` body β depends on SD-2789 (today routes to a doc-API path that throws INVALID_INPUT) - Refactoring `dropin-assessment` SuperDocAdapter to consume `ui.comments` instead of `superdoc.on('commentsUpdate')` β separate PR once stack lands * fix(superdoc/ui): clear comments cache when list() refresh fails Addresses PR #2982 review (P1). When `editor.doc.comments.list()` threw mid-refresh, the previous code preserved the prior cache so a "transient" failure wouldn't blank the UI. Real failure mode that matters more: during a document / editor swap, the new editor can throw transiently while initializing, and keeping the prior value silently leaks the old document's comments into the new editor's snapshot until the next successful refresh. Reset to `EMPTY_COMMENTS_LIST` on failure. Briefly rendering an empty list is a much better failure mode than rendering the wrong document's comments. Regression test: a `list()` that throws on `commentsUpdate` clears the cache so the next snapshot reports `total: 0` / `items: []`. * fix(superdoc/ui): refresh on own mutations + stable activeIds + barrel exports Addresses three review findings on PR #2982: P1: own-mutation refresh. Each ui.comments.* mutation now calls refreshAndNotify() after the doc-API call returns. The underlying wrappers don't emit a single canonical event for every mutation (some go through transaction only, some emit commentsUpdate ahead of the entity-store finishing) β relying on those events alone left subscribers seeing stale items/total until some later event fired. Refreshing here makes the post-mutation state visible synchronously to the next snapshot. P2-3: activeIds reference stability. Pre-SD-2792 selection results have no activeCommentIds field; the previous fallback `?? []` allocated a fresh array per computeState() call, defeating shallowEqual on the comments snapshot β every selection event re-fired ui.comments.subscribe even when the slice was unchanged. Use a frozen module-scope sentinel (`EMPTY_ACTIVE_IDS`) so the array reference is stable. P2-1: barrel exports. The public `superdoc/ui` shim re-exports `CommentsHandle` and `CommentsSlice` from `@superdoc/super-editor`, but the super-editor `src/index.ts` UI types block didn't include them, so TypeScript consumers got "no exported member" on import. Added both to the barrel. P2-2 (clear comments cache on list refresh errors) was already addressed in 12155be from the previous review round. Tests: regression for own-mutation refresh (createFromSelection / resolve / delete each produce a fresh snapshot synchronously) and for empty-activeIds reference stability across snapshots. 29 UI tests; super-editor 11916 / 13 skipped / 0 fail.
caio-pizzol
added a commit
that referenced
this pull request
Apr 29, 2026
β¦D-2791)
Adds `ui.review` to `createSuperDocUI`: a single subscription +
actions surface for Word/Google-Docs-style review sidebars that merge
comments and tracked changes into one chronological feed.
```ts
import { createSuperDocUI } from 'superdoc/ui';
const ui = createSuperDocUI({ superdoc });
// Snapshot β items + openCount + activeId
ui.review.subscribe(({ snapshot }) => {
// snapshot.items: ReviewItem[] (discriminated by `kind: 'comment' | 'change'`)
// snapshot.openCount: number (open comments + every tracked change)
// snapshot.activeId: string|null (selection-driven, plus next/previous)
});
// Decide actions (route through editor.doc.trackChanges.decide)
ui.review.accept(changeId);
ui.review.reject(changeId);
ui.review.acceptAll(); // decide({ scope: 'all' })
ui.review.rejectAll();
// Navigation (UI-only, advances activeId in document order, wraps)
ui.review.next(); // null active β first item; last β wraps to first
ui.review.previous(); // null active β last item; first β wraps to last
ui.review.scrollTo(id); // ranges.scrollIntoView; sets activeId
// Recording (temporary documentMode flip until SD-2667/S4)
ui.review.setRecording(true); // 'suggesting' (recording on)
ui.review.setRecording(false); // 'editing'
```
Architecture mirrors SD-2790's comments domain:
- Tracked-changes list cached at controller level alongside the
comments cache; refreshes on the same `commentsUpdate` /
`commentsLoaded` events the existing wrappers fire (track-changes
events ride that channel today). `list({ in: 'all' })` is requested
so non-body stories (header / footer / footnote / endnote) are
included in the merged feed.
- All decide mutations route through `editor.doc.trackChanges.decide`
(the Document API contract); `ui.review` is a UI-facing adapter,
not a parallel mutation contract.
- Refresh + notify after every own mutation β same posture as
ui.comments after the SD-2790 review fix.
- Empty-cache fallback on list() throw (cross-document leak
prevention β same posture as ui.comments).
Document-order ranking note: cross-list interleaving between comments
and tracked changes is *not* fully resolved because public
`TrackChangeInfo` lacks a positional `target` field today (separate
ticket from SD-2667). The initial implementation interleaves
comments first (in `comments.list()` order) then tracked changes (in
`list()` order); migration-guide consumers get a stable iteration
order and dense `documentOrder` ranks for next/previous navigation.
When `TrackChangeInfo.target` lands, the merge sort gets refined
transparently.
`ui.review.setRecording(enabled)` is the temporary path until
SD-2667/S4 splits recording from view mode; today it flips
`superdoc.config.documentMode` between 'suggesting' and 'editing'
via `superdoc.setDocumentMode?.()`. Once an independent
`trackChanges.setRecording` primitive ships, the implementation
swaps internally without API churn.
Stacks on PR #2982 (SD-2790 comments domain). Independent of
PR #2980 (SD-2796 toolbar) and PR #2981 (SD-2792 active ids).
Verified:
- 44 unit tests (15 substrate + 14 comments + 15 review covering
merged-feed shape, openCount, selection-driven activeId, decide
routing for all four kinds, next/previous wrap-around behavior,
empty-feed null returns, scrollTo entity-type routing, recording
flip)
- super-editor: 11931 / 13 skipped / 0 fail
- `pnpm --filter superdoc build` clean β `dist/superdoc/src/ui.d.ts`
re-exports the new `ReviewHandle`, `ReviewItem`, `ReviewSlice` types.
Out of scope (separate tickets):
- True document-order merge (depends on `TrackChangeInfo.target?:
TextTarget`)
- Address fidelity for non-body tracked-change scrollTo (SD-2750)
- Independent `trackChanges.setRecording` primitive (SD-2667/S4)
- Refactor `examples/headless/dropin-assessment` SuperDocAdapter to
consume `ui.review` instead of the manual merge β separate PR
after the stack lands
caio-pizzol
added a commit
that referenced
this pull request
Apr 29, 2026
β¦D-2791)
Adds `ui.review` to `createSuperDocUI`: a single subscription +
actions surface for Word/Google-Docs-style review sidebars that merge
comments and tracked changes into one chronological feed.
```ts
import { createSuperDocUI } from 'superdoc/ui';
const ui = createSuperDocUI({ superdoc });
// Snapshot β items + openCount + activeId
ui.review.subscribe(({ snapshot }) => {
// snapshot.items: ReviewItem[] (discriminated by `kind: 'comment' | 'change'`)
// snapshot.openCount: number (open comments + every tracked change)
// snapshot.activeId: string|null (selection-driven, plus next/previous)
});
// Decide actions (route through editor.doc.trackChanges.decide)
ui.review.accept(changeId);
ui.review.reject(changeId);
ui.review.acceptAll(); // decide({ scope: 'all' })
ui.review.rejectAll();
// Navigation (UI-only, advances activeId in document order, wraps)
ui.review.next(); // null active β first item; last β wraps to first
ui.review.previous(); // null active β last item; first β wraps to last
ui.review.scrollTo(id); // ranges.scrollIntoView; sets activeId
// Recording (temporary documentMode flip until SD-2667/S4)
ui.review.setRecording(true); // 'suggesting' (recording on)
ui.review.setRecording(false); // 'editing'
```
Architecture mirrors SD-2790's comments domain:
- Tracked-changes list cached at controller level alongside the
comments cache; refreshes on the same `commentsUpdate` /
`commentsLoaded` events the existing wrappers fire (track-changes
events ride that channel today). `list({ in: 'all' })` is requested
so non-body stories (header / footer / footnote / endnote) are
included in the merged feed.
- All decide mutations route through `editor.doc.trackChanges.decide`
(the Document API contract); `ui.review` is a UI-facing adapter,
not a parallel mutation contract.
- Refresh + notify after every own mutation β same posture as
ui.comments after the SD-2790 review fix.
- Empty-cache fallback on list() throw (cross-document leak
prevention β same posture as ui.comments).
Document-order ranking note: cross-list interleaving between comments
and tracked changes is *not* fully resolved because public
`TrackChangeInfo` lacks a positional `target` field today (separate
ticket from SD-2667). The initial implementation interleaves
comments first (in `comments.list()` order) then tracked changes (in
`list()` order); migration-guide consumers get a stable iteration
order and dense `documentOrder` ranks for next/previous navigation.
When `TrackChangeInfo.target` lands, the merge sort gets refined
transparently.
`ui.review.setRecording(enabled)` is the temporary path until
SD-2667/S4 splits recording from view mode; today it flips
`superdoc.config.documentMode` between 'suggesting' and 'editing'
via `superdoc.setDocumentMode?.()`. Once an independent
`trackChanges.setRecording` primitive ships, the implementation
swaps internally without API churn.
Stacks on PR #2982 (SD-2790 comments domain). Independent of
PR #2980 (SD-2796 toolbar) and PR #2981 (SD-2792 active ids).
Verified:
- 44 unit tests (15 substrate + 14 comments + 15 review covering
merged-feed shape, openCount, selection-driven activeId, decide
routing for all four kinds, next/previous wrap-around behavior,
empty-feed null returns, scrollTo entity-type routing, recording
flip)
- super-editor: 11931 / 13 skipped / 0 fail
- `pnpm --filter superdoc build` clean β `dist/superdoc/src/ui.d.ts`
re-exports the new `ReviewHandle`, `ReviewItem`, `ReviewSlice` types.
Out of scope (separate tickets):
- True document-order merge (depends on `TrackChangeInfo.target?:
TextTarget`)
- Address fidelity for non-body tracked-change scrollTo (SD-2750)
- Independent `trackChanges.setRecording` primitive (SD-2667/S4)
- Refactor `examples/headless/dropin-assessment` SuperDocAdapter to
consume `ui.review` instead of the manual merge β separate PR
after the stack lands
caio-pizzol
added a commit
that referenced
this pull request
Apr 29, 2026
β¦D-2791) (#2983) * feat(superdoc/ui): review domain (comments + tracked changes feed) (SD-2791) Adds `ui.review` to `createSuperDocUI`: a single subscription + actions surface for Word/Google-Docs-style review sidebars that merge comments and tracked changes into one chronological feed. ```ts import { createSuperDocUI } from 'superdoc/ui'; const ui = createSuperDocUI({ superdoc }); // Snapshot β items + openCount + activeId ui.review.subscribe(({ snapshot }) => { // snapshot.items: ReviewItem[] (discriminated by `kind: 'comment' | 'change'`) // snapshot.openCount: number (open comments + every tracked change) // snapshot.activeId: string|null (selection-driven, plus next/previous) }); // Decide actions (route through editor.doc.trackChanges.decide) ui.review.accept(changeId); ui.review.reject(changeId); ui.review.acceptAll(); // decide({ scope: 'all' }) ui.review.rejectAll(); // Navigation (UI-only, advances activeId in document order, wraps) ui.review.next(); // null active β first item; last β wraps to first ui.review.previous(); // null active β last item; first β wraps to last ui.review.scrollTo(id); // ranges.scrollIntoView; sets activeId // Recording (temporary documentMode flip until SD-2667/S4) ui.review.setRecording(true); // 'suggesting' (recording on) ui.review.setRecording(false); // 'editing' ``` Architecture mirrors SD-2790's comments domain: - Tracked-changes list cached at controller level alongside the comments cache; refreshes on the same `commentsUpdate` / `commentsLoaded` events the existing wrappers fire (track-changes events ride that channel today). `list({ in: 'all' })` is requested so non-body stories (header / footer / footnote / endnote) are included in the merged feed. - All decide mutations route through `editor.doc.trackChanges.decide` (the Document API contract); `ui.review` is a UI-facing adapter, not a parallel mutation contract. - Refresh + notify after every own mutation β same posture as ui.comments after the SD-2790 review fix. - Empty-cache fallback on list() throw (cross-document leak prevention β same posture as ui.comments). Document-order ranking note: cross-list interleaving between comments and tracked changes is *not* fully resolved because public `TrackChangeInfo` lacks a positional `target` field today (separate ticket from SD-2667). The initial implementation interleaves comments first (in `comments.list()` order) then tracked changes (in `list()` order); migration-guide consumers get a stable iteration order and dense `documentOrder` ranks for next/previous navigation. When `TrackChangeInfo.target` lands, the merge sort gets refined transparently. `ui.review.setRecording(enabled)` is the temporary path until SD-2667/S4 splits recording from view mode; today it flips `superdoc.config.documentMode` between 'suggesting' and 'editing' via `superdoc.setDocumentMode?.()`. Once an independent `trackChanges.setRecording` primitive ships, the implementation swaps internally without API churn. Stacks on PR #2982 (SD-2790 comments domain). Independent of PR #2980 (SD-2796 toolbar) and PR #2981 (SD-2792 active ids). Verified: - 44 unit tests (15 substrate + 14 comments + 15 review covering merged-feed shape, openCount, selection-driven activeId, decide routing for all four kinds, next/previous wrap-around behavior, empty-feed null returns, scrollTo entity-type routing, recording flip) - super-editor: 11931 / 13 skipped / 0 fail - `pnpm --filter superdoc build` clean β `dist/superdoc/src/ui.d.ts` re-exports the new `ReviewHandle`, `ReviewItem`, `ReviewSlice` types. Out of scope (separate tickets): - True document-order merge (depends on `TrackChangeInfo.target?: TextTarget`) - Address fidelity for non-body tracked-change scrollTo (SD-2750) - Independent `trackChanges.setRecording` primitive (SD-2667/S4) - Refactor `examples/headless/dropin-assessment` SuperDocAdapter to consume `ui.review` instead of the manual merge β separate PR after the stack lands * fix(superdoc/ui): review domain edge cases (PR #2983 review) Four fixes addressing review feedback: 1. Preserve explicit navigation over current selection. Tracks lastSelectionDrivenId across computeState calls so that when the user calls next()/previous() while the cursor is still on the formerly-active item, the next recompute does not snap activeReviewId back to the unchanged selection-driven id. 2. Refresh tracked-change cache on external updates. Renames COMMENTS_REFRESH_EVENTS to LIST_REFRESH_EVENTS and includes trackedChangesUpdate, so a collaborator's accept/reject (or any external mutation) refreshes trackChangesListCache without waiting for an unrelated comments event. 3. Include story when deciding non-body changes. accept(id)/reject(id) look up the change in the cached feed and forward address.story to trackChanges.decide so header/footer/footnote targets resolve correctly instead of failing target-not-found under the body-default lookup. 4. Reuse review items between unchanged snapshots. Memoizes the merged feed by source-cache references + activeReviewId so the review slice keeps identity stability across typing/selection bursts. Subscribers stop re-firing on the editing hot path. Also restores the JSDoc opener on SuperDocUI.toolbar that was clipped during a prior rebase, and drops an unused CommentsSlice type import. * fix(superdoc/ui): use comments.list discovery id (PR #2983 bot P1) `comments.list()` returns `DiscoveryItem<CommentDomain>` whose canonical identifier lives on `id` (set by the adapter from the underlying commentId). The legacy `commentId` field only exists on `CommentInfo` (`comments.get`). Reading `comment.commentId` here emitted `undefined` for every comment row in the merged review feed, breaking active-id matching and `next/previous/scrollTo` whenever comments were present. Updates the review-domain stub to match the production discovery shape (no `commentId` on items) and pins the regression with a test that asserts non-empty string ids and end-to-end navigation.
caio-pizzol
added a commit
that referenced
this pull request
Apr 29, 2026
β¦#2980) Adds `ui.toolbar` (aggregate) and `ui.commands.<id>` (per-command) on top of the SD-2794 selector substrate. The toolbar surface is the first domain to land on `superdoc/ui`; it makes "bring your own toolbar" a single mental model that consumers wire to React, Vue, or vanilla DOM. ```ts import { createSuperDocUI } from 'superdoc/ui'; const ui = createSuperDocUI({ superdoc }); // Aggregate (HeadlessToolbarController-shaped) ui.toolbar.subscribe(({ snapshot }) => render(snapshot)); ui.toolbar.execute('bold'); // Per-command observable (CKEditor-style fine-grained binding) ui.commands.bold.observe(({ active, disabled }) => boldBtn.set(active)); ui.commands.bold.execute(); ui.commands['font-size'].observe(({ value }) => fontInput.set(value)); ui.commands['font-size'].execute('14pt'); ``` Implementation: - Internal `createHeadlessToolbar({ superdoc })` is instantiated once per `createSuperDocUI` call and feeds `state.toolbar`. `ui.toolbar` delegates `getSnapshot` / `execute` to that controller and routes `subscribe` through the substrate so subscribers ride the same microtask-coalesced burst pattern as `ui.select` consumers. - `ui.commands` is a Proxy returning per-id handles, cached by id so reference identity is stable (matters for React `useMemo` deps and for consumers comparing handles). Each handle's `observe` is internally `ui.select((s) => s.toolbar.commands[id], shallowEqual)`, so a button bound to one command does not re-render when an unrelated command's state changes. - Unknown command ids fall back to `{ active: false, disabled: true, value: undefined }` rather than throwing, so consumer code paths remain forgiving while custom-command registration (FRICTION S3) is filed as follow-up scope. - Built-in `SuperToolbar.vue` and `superdoc/headless-toolbar` are unchanged here; both will migrate to consume `ui.toolbar` in follow-up tickets so this PR stays small and review-friendly. Why these three shapes coexist (per SD-2667 research): - Aggregate (`ui.toolbar.subscribe`): matches today's `HeadlessToolbarController` shape; lets external consumers using `superdoc/headless-toolbar` migrate without rewriting render code. - Per-command (`ui.commands.<id>.observe`): CKEditor 5 pattern. A 50- button toolbar with per-command observables only re-renders the button whose state changed, not the whole bar. - Selector substrate (`ui.select`): the underlying primitive. Custom toolbars with cross-command derived state (e.g. "is any heading active?") consume that. Stub-bug found in the SD-2794 test fixture: `fireEditor` iterated a live Set while the internal headless-toolbar rebound listeners on every change, picking up the new handler mid-loop and recursing. Fix snapshots the handler list before iterating, matching how real editor event buses behave. Verified: 19 unit tests (11 selector substrate + 8 toolbar/commands). Full super-editor suite: 11906/13 skipped/0 fail. `pnpm --filter superdoc build` clean. Out of scope (filed under SD-2796 as follow-ups inside SD-2667): - `ui.commands.register({ id, execute, getState })` (closes FRICTION S3, the closed toolbar registry). - Refactor `superdoc/headless-toolbar` into a shim around `createSuperDocUI({ superdoc }).toolbar`. - Refactor `SuperToolbar.vue` to consume `ui.toolbar` (visual / behavior parity validation).
caio-pizzol
added a commit
that referenced
this pull request
Apr 29, 2026
β¦2982) * feat(superdoc/ui): comments domain (subscribe + actions) (SD-2790) Adds `ui.comments` to `createSuperDocUI`: a single subscription + actions surface for custom comments sidebars built on top of SuperDoc. ```ts import { createSuperDocUI } from 'superdoc/ui'; const ui = createSuperDocUI({ superdoc }); // Snapshot ui.comments.subscribe(({ snapshot }) => { // snapshot.items: CommentInfo[] // snapshot.activeIds: string[] (mirrors selection.current().activeCommentIds) // snapshot.total: number }); // Actions β every mutation routes through editor.doc.* (Document API) ui.comments.createFromSelection({ text }); ui.comments.resolve(commentId); ui.comments.reopen(commentId); // routes to comments.patch({ status: 'active' }) ui.comments.delete(commentId); ui.comments.scrollTo(commentId); // ranges.scrollIntoView({ kind: 'entity', ... }) ``` Architecture: - Subscribe runs through the SD-2794 selector substrate (`shallowEqual`-deduped). Comments-list is cached at controller level and refreshed on `commentsUpdate` / `commentsLoaded` editor events ahead of `scheduleNotify` so `state.comments.items` is fresh when subscribers see the next snapshot. `activeIds` reads from `selection.current()` per `computeState()` call (cheap; one walk the resolver already does for `activeMarks`). - `activeIds` falls back to `[]` when `selection.current()` predates SD-2792 (no `activeCommentIds` field). When SD-2792 lands, the fallback becomes never-used; nothing in this PR breaks if SD-2792 is reverted. - All execution paths route through `editor.doc.comments.*` and `editor.doc.ranges.scrollIntoView`. `ui.comments` is a UI-facing adapter, NOT a parallel mutation contract β `ui.comments.resolve(id)` and `editor.doc.comments.patch({ id, status: 'resolved' })` produce the same document mutation by construction. - `ui.comments.reopen(id)` routes to `comments.patch({ status: 'active' })`. Today doc-API validation rejects 'active' until SD-2789 ships the lifecycle inverse β that surfaces an INVALID_INPUT receipt rather than a silent no-op, which is the correct visible behavior for a not-yet-shipped operation. Stacks on PR #2979 (SD-2794 skeleton). Unrelated to PR #2980 (SD-2796 toolbar) and PR #2981 (SD-2792 active ids); rebases when those merge. Verified: 26 unit tests (15 substrate + 11 comments). Full super-editor: 11913 / 13 skipped / 0 fail. `pnpm --filter superdoc build` clean β `dist/superdoc/src/ui.d.ts` re-exports the new `CommentsHandle` and `CommentsSlice` types. Out of scope (separate tickets): - Threaded reply UX - `ui.comments.reopen` body β depends on SD-2789 (today routes to a doc-API path that throws INVALID_INPUT) - Refactoring `dropin-assessment` SuperDocAdapter to consume `ui.comments` instead of `superdoc.on('commentsUpdate')` β separate PR once stack lands * fix(superdoc/ui): clear comments cache when list() refresh fails Addresses PR #2982 review (P1). When `editor.doc.comments.list()` threw mid-refresh, the previous code preserved the prior cache so a "transient" failure wouldn't blank the UI. Real failure mode that matters more: during a document / editor swap, the new editor can throw transiently while initializing, and keeping the prior value silently leaks the old document's comments into the new editor's snapshot until the next successful refresh. Reset to `EMPTY_COMMENTS_LIST` on failure. Briefly rendering an empty list is a much better failure mode than rendering the wrong document's comments. Regression test: a `list()` that throws on `commentsUpdate` clears the cache so the next snapshot reports `total: 0` / `items: []`. * fix(superdoc/ui): refresh on own mutations + stable activeIds + barrel exports Addresses three review findings on PR #2982: P1: own-mutation refresh. Each ui.comments.* mutation now calls refreshAndNotify() after the doc-API call returns. The underlying wrappers don't emit a single canonical event for every mutation (some go through transaction only, some emit commentsUpdate ahead of the entity-store finishing) β relying on those events alone left subscribers seeing stale items/total until some later event fired. Refreshing here makes the post-mutation state visible synchronously to the next snapshot. P2-3: activeIds reference stability. Pre-SD-2792 selection results have no activeCommentIds field; the previous fallback `?? []` allocated a fresh array per computeState() call, defeating shallowEqual on the comments snapshot β every selection event re-fired ui.comments.subscribe even when the slice was unchanged. Use a frozen module-scope sentinel (`EMPTY_ACTIVE_IDS`) so the array reference is stable. P2-1: barrel exports. The public `superdoc/ui` shim re-exports `CommentsHandle` and `CommentsSlice` from `@superdoc/super-editor`, but the super-editor `src/index.ts` UI types block didn't include them, so TypeScript consumers got "no exported member" on import. Added both to the barrel. P2-2 (clear comments cache on list refresh errors) was already addressed in 12155be from the previous review round. Tests: regression for own-mutation refresh (createFromSelection / resolve / delete each produce a fresh snapshot synchronously) and for empty-activeIds reference stability across snapshots. 29 UI tests; super-editor 11916 / 13 skipped / 0 fail.
caio-pizzol
added a commit
that referenced
this pull request
Apr 29, 2026
β¦D-2791) (#2983) * feat(superdoc/ui): review domain (comments + tracked changes feed) (SD-2791) Adds `ui.review` to `createSuperDocUI`: a single subscription + actions surface for Word/Google-Docs-style review sidebars that merge comments and tracked changes into one chronological feed. ```ts import { createSuperDocUI } from 'superdoc/ui'; const ui = createSuperDocUI({ superdoc }); // Snapshot β items + openCount + activeId ui.review.subscribe(({ snapshot }) => { // snapshot.items: ReviewItem[] (discriminated by `kind: 'comment' | 'change'`) // snapshot.openCount: number (open comments + every tracked change) // snapshot.activeId: string|null (selection-driven, plus next/previous) }); // Decide actions (route through editor.doc.trackChanges.decide) ui.review.accept(changeId); ui.review.reject(changeId); ui.review.acceptAll(); // decide({ scope: 'all' }) ui.review.rejectAll(); // Navigation (UI-only, advances activeId in document order, wraps) ui.review.next(); // null active β first item; last β wraps to first ui.review.previous(); // null active β last item; first β wraps to last ui.review.scrollTo(id); // ranges.scrollIntoView; sets activeId // Recording (temporary documentMode flip until SD-2667/S4) ui.review.setRecording(true); // 'suggesting' (recording on) ui.review.setRecording(false); // 'editing' ``` Architecture mirrors SD-2790's comments domain: - Tracked-changes list cached at controller level alongside the comments cache; refreshes on the same `commentsUpdate` / `commentsLoaded` events the existing wrappers fire (track-changes events ride that channel today). `list({ in: 'all' })` is requested so non-body stories (header / footer / footnote / endnote) are included in the merged feed. - All decide mutations route through `editor.doc.trackChanges.decide` (the Document API contract); `ui.review` is a UI-facing adapter, not a parallel mutation contract. - Refresh + notify after every own mutation β same posture as ui.comments after the SD-2790 review fix. - Empty-cache fallback on list() throw (cross-document leak prevention β same posture as ui.comments). Document-order ranking note: cross-list interleaving between comments and tracked changes is *not* fully resolved because public `TrackChangeInfo` lacks a positional `target` field today (separate ticket from SD-2667). The initial implementation interleaves comments first (in `comments.list()` order) then tracked changes (in `list()` order); migration-guide consumers get a stable iteration order and dense `documentOrder` ranks for next/previous navigation. When `TrackChangeInfo.target` lands, the merge sort gets refined transparently. `ui.review.setRecording(enabled)` is the temporary path until SD-2667/S4 splits recording from view mode; today it flips `superdoc.config.documentMode` between 'suggesting' and 'editing' via `superdoc.setDocumentMode?.()`. Once an independent `trackChanges.setRecording` primitive ships, the implementation swaps internally without API churn. Stacks on PR #2982 (SD-2790 comments domain). Independent of PR #2980 (SD-2796 toolbar) and PR #2981 (SD-2792 active ids). Verified: - 44 unit tests (15 substrate + 14 comments + 15 review covering merged-feed shape, openCount, selection-driven activeId, decide routing for all four kinds, next/previous wrap-around behavior, empty-feed null returns, scrollTo entity-type routing, recording flip) - super-editor: 11931 / 13 skipped / 0 fail - `pnpm --filter superdoc build` clean β `dist/superdoc/src/ui.d.ts` re-exports the new `ReviewHandle`, `ReviewItem`, `ReviewSlice` types. Out of scope (separate tickets): - True document-order merge (depends on `TrackChangeInfo.target?: TextTarget`) - Address fidelity for non-body tracked-change scrollTo (SD-2750) - Independent `trackChanges.setRecording` primitive (SD-2667/S4) - Refactor `examples/headless/dropin-assessment` SuperDocAdapter to consume `ui.review` instead of the manual merge β separate PR after the stack lands * fix(superdoc/ui): review domain edge cases (PR #2983 review) Four fixes addressing review feedback: 1. Preserve explicit navigation over current selection. Tracks lastSelectionDrivenId across computeState calls so that when the user calls next()/previous() while the cursor is still on the formerly-active item, the next recompute does not snap activeReviewId back to the unchanged selection-driven id. 2. Refresh tracked-change cache on external updates. Renames COMMENTS_REFRESH_EVENTS to LIST_REFRESH_EVENTS and includes trackedChangesUpdate, so a collaborator's accept/reject (or any external mutation) refreshes trackChangesListCache without waiting for an unrelated comments event. 3. Include story when deciding non-body changes. accept(id)/reject(id) look up the change in the cached feed and forward address.story to trackChanges.decide so header/footer/footnote targets resolve correctly instead of failing target-not-found under the body-default lookup. 4. Reuse review items between unchanged snapshots. Memoizes the merged feed by source-cache references + activeReviewId so the review slice keeps identity stability across typing/selection bursts. Subscribers stop re-firing on the editing hot path. Also restores the JSDoc opener on SuperDocUI.toolbar that was clipped during a prior rebase, and drops an unused CommentsSlice type import. * fix(superdoc/ui): use comments.list discovery id (PR #2983 bot P1) `comments.list()` returns `DiscoveryItem<CommentDomain>` whose canonical identifier lives on `id` (set by the adapter from the underlying commentId). The legacy `commentId` field only exists on `CommentInfo` (`comments.get`). Reading `comment.commentId` here emitted `undefined` for every comment row in the merged review feed, breaking active-id matching and `next/previous/scrollTo` whenever comments were present. Updates the review-domain stub to match the production discovery shape (no `commentId` on items) and pins the regression with a test that asserts non-empty string ids and end-to-end navigation.
caio-pizzol
added a commit
that referenced
this pull request
Apr 29, 2026
β¦-2794) (#2979) * feat(superdoc/ui): createSuperDocUI skeleton + selector substrate (SD-2794) Foundational ticket for the `superdoc/ui` package. Sibling tickets layer domain namespaces (toolbar, commands, comments, review, viewport, selection) on top of `ui.select`. The architectural counterpart to the Document API: - `editor.doc.*`: stateless contract, server + client, request/response - `createSuperDocUI({ superdoc })`: browser-only state controller Why this exists: - Subscriptions, viewport geometry, and per-command observables don't fit on a request/response contract. The closed PR #2978 surfaced this by forcing a `META_MEMBER_PATHS` exemption on the doc-api. - `superdoc/headless-toolbar` already shipped a working controller pattern; this package is the same shape but extends to comments, tracked changes, viewport, and selection. API: ```ts import { createSuperDocUI, shallowEqual } from 'superdoc/ui'; const ui = createSuperDocUI({ superdoc }); const sub = ui.select( (state) => ({ mode: state.documentMode, empty: state.selection.empty }), shallowEqual, ); const off = sub.subscribe((slice) => render(slice)); ui.destroy(); ``` Selector substrate is the canonical observation primitive everything else is built on. Domain namespaces, per-command observables, and React/ Vue adapters are wrappers over `ui.select`. Default equality is `Object.is`; `shallowEqual` is exported for object slices. Internals: - One unified state model (selection slice + documentMode for now). Sibling tickets extend the shape via TypeScript module augmentation. - Source events normalized: `transaction`, `selectionUpdate`, `commentsUpdate`, `commentsLoaded`, `comment-positions`, `trackedChangesUpdate` from the editor; `editorCreate`, `document-mode-change`, `zoomChange` from SuperDoc. Consumers never see editor-internal vocabulary. - Bursts coalesced to one snapshot rebuild per microtask so multi-step transactions and DOCX reload don't storm subscribers. - Listener errors are caught so one buggy subscriber can't wedge the editor's event loop. - `editorCreate` re-attaches editor listeners when activeEditor swaps. - `destroy()` tears down all source listeners. Package layout mirrors `superdoc/headless-toolbar`: - Source: `packages/super-editor/src/ui/` - Public sub-entry: `superdoc/ui` via re-export shim at `packages/superdoc/src/ui.js` - Sub-paths reserved for `superdoc/ui/react` and `superdoc/ui/vue` (filed separately). Verified: - 11 unit tests cover initial-emit, dedup-by-equality, microtask coalescing, multi-subscriber lifecycle, destroy teardown, editor swap, listener-error isolation - super-editor: 11898/13 skipped/0 fail - superdoc package builds: `dist/ui.es.js` + `dist/superdoc/src/ui.d.ts` emit; `import { createSuperDocUI } from 'superdoc/ui'` resolves at runtime and type-check Out of scope (sibling tickets under SD-2667): - ui.toolbar / ui.commands.* (SD-2796) - ui.comments (SD-2790) - ui.review (SD-2791) - ui.viewport (SD-2793) - React + Vue adapters - Refactoring SuperToolbar.vue / CommentsLayer to consume superdoc/ui - Migrating selection.onChange off doc-api (SD-2795) * fix(superdoc/ui): route selection through PresentationEditor + expose public types Addresses two review findings on PR #2979: 1. **Selection followed `superdoc.activeEditor` instead of the routed editor.** When the user is editing a header / footer / footnote / endnote inside a paginated SuperDoc, `superdoc.activeEditor` stays on the body editor while `PresentationEditor.getActiveEditor()` routes to the story editor. Reading selection from `superdoc.activeEditor.doc` left `state.selection` on the body selection, and the controller never subscribed to the routed editor's events β so any UI built on `ui.select((s) => s.selection, ...)` was wrong outside the body. Fix: reuse the existing `resolveToolbarSources` helper from `headless-toolbar` so the UI controller, the toolbar registry, and any future domain agree on which editor is active. Read selection through that routed editor in `computeState()`. Subscribe to the PresentationEditor's `headerFooterEditingContext`, `headerFooterUpdate`, `headerFooterTransaction`, `activeSurfaceChange`, and `historyStateChange` events; on `activeSurfaceChange` re-attach editor listeners to the new routed editor and notify subscribers. Falls back cleanly to `superdoc.activeEditor` when no PresentationEditor is mounted (e.g. server-side stubs in tests). 2. **`superdoc/ui` public sub-entry didn't re-export types.** The shim at `packages/superdoc/src/ui.js` only re-exported values, so the generated `dist/superdoc/src/ui.d.ts` didn't carry the `SuperDocUI`, `SuperDocUIState`, `SuperDocUIOptions`, etc. types. `import type { SuperDocUIState } from 'superdoc/ui'` failed for consumers building typed wrappers. Fix: add a sibling `packages/superdoc/src/ui.d.ts` that re-exports types alongside values, mirroring the existing `headless-toolbar.d.ts` pattern. Test stub also got the snapshot-on-iterate fix (mirrors the SD-2796 PR's stub fix): when handlers re-attach to the same Set during iteration (now possible via presentation re-routing), the test bus must iterate a frozen list to avoid recursion. Verified: 12 unit tests (added presentation-routing test). Full super-editor suite: 11899 / 13 skipped / 0 fail. `pnpm --filter superdoc build` clean β `dist/superdoc/src/ui.d.ts` now re-exports the type set, `import type { SuperDocUIState } from 'superdoc/ui'` resolves. * fix(superdoc/ui): refcount selector listener so unsubscribed selectors don't leak Addresses Codex P1 finding on PR #2979. Before: `ui.select(selector).subscribe(cb)` permanently registered an `onStateChange` closure in the controller's `stateChangeListeners`, even after the consumer's unsubscribe ran. Long-lived sessions with React/Vue components that mount and unmount accumulated dead closures that still recomputed on every editor event β unbounded memory growth and O(N) extra work per event. Fix: refcount the controller-level listener. - First `subscribe()` on a slice attaches `onStateChange` to `stateChangeListeners` and refreshes `last` so the initial emit isn't stale (state may have evolved between `select()` and `subscribe()`). - Last unsubscribe detaches `onStateChange`. Subsequent editor events do not invoke this slice's selector. - `get()` recomputes when no subscribers are attached so untracked snapshots stay accurate. Regression tests: - 100 select+subscribe+unsubscribe cycles followed by one editor event must invoke the selector zero times (no stale closures). - A slice with two subscribers; first unsubscribe keeps the listener active, second detaches it. - `get()` returns fresh state after upstream changes when no subscribers are attached. * feat(superdoc/ui): toolbar domain + per-command observables (SD-2796) (#2980) Adds `ui.toolbar` (aggregate) and `ui.commands.<id>` (per-command) on top of the SD-2794 selector substrate. The toolbar surface is the first domain to land on `superdoc/ui`; it makes "bring your own toolbar" a single mental model that consumers wire to React, Vue, or vanilla DOM. ```ts import { createSuperDocUI } from 'superdoc/ui'; const ui = createSuperDocUI({ superdoc }); // Aggregate (HeadlessToolbarController-shaped) ui.toolbar.subscribe(({ snapshot }) => render(snapshot)); ui.toolbar.execute('bold'); // Per-command observable (CKEditor-style fine-grained binding) ui.commands.bold.observe(({ active, disabled }) => boldBtn.set(active)); ui.commands.bold.execute(); ui.commands['font-size'].observe(({ value }) => fontInput.set(value)); ui.commands['font-size'].execute('14pt'); ``` Implementation: - Internal `createHeadlessToolbar({ superdoc })` is instantiated once per `createSuperDocUI` call and feeds `state.toolbar`. `ui.toolbar` delegates `getSnapshot` / `execute` to that controller and routes `subscribe` through the substrate so subscribers ride the same microtask-coalesced burst pattern as `ui.select` consumers. - `ui.commands` is a Proxy returning per-id handles, cached by id so reference identity is stable (matters for React `useMemo` deps and for consumers comparing handles). Each handle's `observe` is internally `ui.select((s) => s.toolbar.commands[id], shallowEqual)`, so a button bound to one command does not re-render when an unrelated command's state changes. - Unknown command ids fall back to `{ active: false, disabled: true, value: undefined }` rather than throwing, so consumer code paths remain forgiving while custom-command registration (FRICTION S3) is filed as follow-up scope. - Built-in `SuperToolbar.vue` and `superdoc/headless-toolbar` are unchanged here; both will migrate to consume `ui.toolbar` in follow-up tickets so this PR stays small and review-friendly. Why these three shapes coexist (per SD-2667 research): - Aggregate (`ui.toolbar.subscribe`): matches today's `HeadlessToolbarController` shape; lets external consumers using `superdoc/headless-toolbar` migrate without rewriting render code. - Per-command (`ui.commands.<id>.observe`): CKEditor 5 pattern. A 50- button toolbar with per-command observables only re-renders the button whose state changed, not the whole bar. - Selector substrate (`ui.select`): the underlying primitive. Custom toolbars with cross-command derived state (e.g. "is any heading active?") consume that. Stub-bug found in the SD-2794 test fixture: `fireEditor` iterated a live Set while the internal headless-toolbar rebound listeners on every change, picking up the new handler mid-loop and recursing. Fix snapshots the handler list before iterating, matching how real editor event buses behave. Verified: 19 unit tests (11 selector substrate + 8 toolbar/commands). Full super-editor suite: 11906/13 skipped/0 fail. `pnpm --filter superdoc build` clean. Out of scope (filed under SD-2796 as follow-ups inside SD-2667): - `ui.commands.register({ id, execute, getState })` (closes FRICTION S3, the closed toolbar registry). - Refactor `superdoc/headless-toolbar` into a shim around `createSuperDocUI({ superdoc }).toolbar`. - Refactor `SuperToolbar.vue` to consume `ui.toolbar` (visual / behavior parity validation). * feat(superdoc/ui): comments domain (subscribe + actions) (SD-2790) (#2982) * feat(superdoc/ui): comments domain (subscribe + actions) (SD-2790) Adds `ui.comments` to `createSuperDocUI`: a single subscription + actions surface for custom comments sidebars built on top of SuperDoc. ```ts import { createSuperDocUI } from 'superdoc/ui'; const ui = createSuperDocUI({ superdoc }); // Snapshot ui.comments.subscribe(({ snapshot }) => { // snapshot.items: CommentInfo[] // snapshot.activeIds: string[] (mirrors selection.current().activeCommentIds) // snapshot.total: number }); // Actions β every mutation routes through editor.doc.* (Document API) ui.comments.createFromSelection({ text }); ui.comments.resolve(commentId); ui.comments.reopen(commentId); // routes to comments.patch({ status: 'active' }) ui.comments.delete(commentId); ui.comments.scrollTo(commentId); // ranges.scrollIntoView({ kind: 'entity', ... }) ``` Architecture: - Subscribe runs through the SD-2794 selector substrate (`shallowEqual`-deduped). Comments-list is cached at controller level and refreshed on `commentsUpdate` / `commentsLoaded` editor events ahead of `scheduleNotify` so `state.comments.items` is fresh when subscribers see the next snapshot. `activeIds` reads from `selection.current()` per `computeState()` call (cheap; one walk the resolver already does for `activeMarks`). - `activeIds` falls back to `[]` when `selection.current()` predates SD-2792 (no `activeCommentIds` field). When SD-2792 lands, the fallback becomes never-used; nothing in this PR breaks if SD-2792 is reverted. - All execution paths route through `editor.doc.comments.*` and `editor.doc.ranges.scrollIntoView`. `ui.comments` is a UI-facing adapter, NOT a parallel mutation contract β `ui.comments.resolve(id)` and `editor.doc.comments.patch({ id, status: 'resolved' })` produce the same document mutation by construction. - `ui.comments.reopen(id)` routes to `comments.patch({ status: 'active' })`. Today doc-API validation rejects 'active' until SD-2789 ships the lifecycle inverse β that surfaces an INVALID_INPUT receipt rather than a silent no-op, which is the correct visible behavior for a not-yet-shipped operation. Stacks on PR #2979 (SD-2794 skeleton). Unrelated to PR #2980 (SD-2796 toolbar) and PR #2981 (SD-2792 active ids); rebases when those merge. Verified: 26 unit tests (15 substrate + 11 comments). Full super-editor: 11913 / 13 skipped / 0 fail. `pnpm --filter superdoc build` clean β `dist/superdoc/src/ui.d.ts` re-exports the new `CommentsHandle` and `CommentsSlice` types. Out of scope (separate tickets): - Threaded reply UX - `ui.comments.reopen` body β depends on SD-2789 (today routes to a doc-API path that throws INVALID_INPUT) - Refactoring `dropin-assessment` SuperDocAdapter to consume `ui.comments` instead of `superdoc.on('commentsUpdate')` β separate PR once stack lands * fix(superdoc/ui): clear comments cache when list() refresh fails Addresses PR #2982 review (P1). When `editor.doc.comments.list()` threw mid-refresh, the previous code preserved the prior cache so a "transient" failure wouldn't blank the UI. Real failure mode that matters more: during a document / editor swap, the new editor can throw transiently while initializing, and keeping the prior value silently leaks the old document's comments into the new editor's snapshot until the next successful refresh. Reset to `EMPTY_COMMENTS_LIST` on failure. Briefly rendering an empty list is a much better failure mode than rendering the wrong document's comments. Regression test: a `list()` that throws on `commentsUpdate` clears the cache so the next snapshot reports `total: 0` / `items: []`. * fix(superdoc/ui): refresh on own mutations + stable activeIds + barrel exports Addresses three review findings on PR #2982: P1: own-mutation refresh. Each ui.comments.* mutation now calls refreshAndNotify() after the doc-API call returns. The underlying wrappers don't emit a single canonical event for every mutation (some go through transaction only, some emit commentsUpdate ahead of the entity-store finishing) β relying on those events alone left subscribers seeing stale items/total until some later event fired. Refreshing here makes the post-mutation state visible synchronously to the next snapshot. P2-3: activeIds reference stability. Pre-SD-2792 selection results have no activeCommentIds field; the previous fallback `?? []` allocated a fresh array per computeState() call, defeating shallowEqual on the comments snapshot β every selection event re-fired ui.comments.subscribe even when the slice was unchanged. Use a frozen module-scope sentinel (`EMPTY_ACTIVE_IDS`) so the array reference is stable. P2-1: barrel exports. The public `superdoc/ui` shim re-exports `CommentsHandle` and `CommentsSlice` from `@superdoc/super-editor`, but the super-editor `src/index.ts` UI types block didn't include them, so TypeScript consumers got "no exported member" on import. Added both to the barrel. P2-2 (clear comments cache on list refresh errors) was already addressed in 12155be from the previous review round. Tests: regression for own-mutation refresh (createFromSelection / resolve / delete each produce a fresh snapshot synchronously) and for empty-activeIds reference stability across snapshots. 29 UI tests; super-editor 11916 / 13 skipped / 0 fail. * feat(superdoc/ui): review domain (comments + tracked changes feed) (SD-2791) (#2983) * feat(superdoc/ui): review domain (comments + tracked changes feed) (SD-2791) Adds `ui.review` to `createSuperDocUI`: a single subscription + actions surface for Word/Google-Docs-style review sidebars that merge comments and tracked changes into one chronological feed. ```ts import { createSuperDocUI } from 'superdoc/ui'; const ui = createSuperDocUI({ superdoc }); // Snapshot β items + openCount + activeId ui.review.subscribe(({ snapshot }) => { // snapshot.items: ReviewItem[] (discriminated by `kind: 'comment' | 'change'`) // snapshot.openCount: number (open comments + every tracked change) // snapshot.activeId: string|null (selection-driven, plus next/previous) }); // Decide actions (route through editor.doc.trackChanges.decide) ui.review.accept(changeId); ui.review.reject(changeId); ui.review.acceptAll(); // decide({ scope: 'all' }) ui.review.rejectAll(); // Navigation (UI-only, advances activeId in document order, wraps) ui.review.next(); // null active β first item; last β wraps to first ui.review.previous(); // null active β last item; first β wraps to last ui.review.scrollTo(id); // ranges.scrollIntoView; sets activeId // Recording (temporary documentMode flip until SD-2667/S4) ui.review.setRecording(true); // 'suggesting' (recording on) ui.review.setRecording(false); // 'editing' ``` Architecture mirrors SD-2790's comments domain: - Tracked-changes list cached at controller level alongside the comments cache; refreshes on the same `commentsUpdate` / `commentsLoaded` events the existing wrappers fire (track-changes events ride that channel today). `list({ in: 'all' })` is requested so non-body stories (header / footer / footnote / endnote) are included in the merged feed. - All decide mutations route through `editor.doc.trackChanges.decide` (the Document API contract); `ui.review` is a UI-facing adapter, not a parallel mutation contract. - Refresh + notify after every own mutation β same posture as ui.comments after the SD-2790 review fix. - Empty-cache fallback on list() throw (cross-document leak prevention β same posture as ui.comments). Document-order ranking note: cross-list interleaving between comments and tracked changes is *not* fully resolved because public `TrackChangeInfo` lacks a positional `target` field today (separate ticket from SD-2667). The initial implementation interleaves comments first (in `comments.list()` order) then tracked changes (in `list()` order); migration-guide consumers get a stable iteration order and dense `documentOrder` ranks for next/previous navigation. When `TrackChangeInfo.target` lands, the merge sort gets refined transparently. `ui.review.setRecording(enabled)` is the temporary path until SD-2667/S4 splits recording from view mode; today it flips `superdoc.config.documentMode` between 'suggesting' and 'editing' via `superdoc.setDocumentMode?.()`. Once an independent `trackChanges.setRecording` primitive ships, the implementation swaps internally without API churn. Stacks on PR #2982 (SD-2790 comments domain). Independent of PR #2980 (SD-2796 toolbar) and PR #2981 (SD-2792 active ids). Verified: - 44 unit tests (15 substrate + 14 comments + 15 review covering merged-feed shape, openCount, selection-driven activeId, decide routing for all four kinds, next/previous wrap-around behavior, empty-feed null returns, scrollTo entity-type routing, recording flip) - super-editor: 11931 / 13 skipped / 0 fail - `pnpm --filter superdoc build` clean β `dist/superdoc/src/ui.d.ts` re-exports the new `ReviewHandle`, `ReviewItem`, `ReviewSlice` types. Out of scope (separate tickets): - True document-order merge (depends on `TrackChangeInfo.target?: TextTarget`) - Address fidelity for non-body tracked-change scrollTo (SD-2750) - Independent `trackChanges.setRecording` primitive (SD-2667/S4) - Refactor `examples/headless/dropin-assessment` SuperDocAdapter to consume `ui.review` instead of the manual merge β separate PR after the stack lands * fix(superdoc/ui): review domain edge cases (PR #2983 review) Four fixes addressing review feedback: 1. Preserve explicit navigation over current selection. Tracks lastSelectionDrivenId across computeState calls so that when the user calls next()/previous() while the cursor is still on the formerly-active item, the next recompute does not snap activeReviewId back to the unchanged selection-driven id. 2. Refresh tracked-change cache on external updates. Renames COMMENTS_REFRESH_EVENTS to LIST_REFRESH_EVENTS and includes trackedChangesUpdate, so a collaborator's accept/reject (or any external mutation) refreshes trackChangesListCache without waiting for an unrelated comments event. 3. Include story when deciding non-body changes. accept(id)/reject(id) look up the change in the cached feed and forward address.story to trackChanges.decide so header/footer/footnote targets resolve correctly instead of failing target-not-found under the body-default lookup. 4. Reuse review items between unchanged snapshots. Memoizes the merged feed by source-cache references + activeReviewId so the review slice keeps identity stability across typing/selection bursts. Subscribers stop re-firing on the editing hot path. Also restores the JSDoc opener on SuperDocUI.toolbar that was clipped during a prior rebase, and drops an unused CommentsSlice type import. * fix(superdoc/ui): use comments.list discovery id (PR #2983 bot P1) `comments.list()` returns `DiscoveryItem<CommentDomain>` whose canonical identifier lives on `id` (set by the adapter from the underlying commentId). The legacy `commentId` field only exists on `CommentInfo` (`comments.get`). Reading `comment.commentId` here emitted `undefined` for every comment row in the merged review feed, breaking active-id matching and `next/previous/scrollTo` whenever comments were present. Updates the review-domain stub to match the production discovery shape (no `commentId` on items) and pins the regression with a test that asserts non-empty string ids and end-to-end navigation. * feat(superdoc/ui): viewport domain (rect + scrollIntoView) (SD-2793) (#2984) * feat(superdoc/ui): viewport domain (rect + scrollIntoView) (SD-2793) Adds `ui.viewport.getRect({ target })` for sticky-card / floating- toolbar geometry and `ui.viewport.scrollIntoView(input)` as the UI-friendly companion. Browser-only by definition; no Document API contract changes. Surface ui.viewport.getRect({ target: EntityAddress | TextAddress | TextTarget }) -> { success: true, rect, rects, pageIndex } | { success: false, reason: 'not-ready' | 'invalid-target' | 'unresolved' | 'not-mounted' } ui.viewport.scrollIntoView(input) -> Promise<ScrollIntoViewOutput> `rect` is the primary anchor (first painted occurrence) so consumers can place a card without reasoning about multi-line / multi-page layouts; `rects` carries the full set in document order for underline / highlight overlays. Boundary The DOM lookup lives in `PresentationEditor.getEntityRects` (new public method) backed by a pure helper module `presentation-editor/dom/EntityRectFinder.ts`. The UI controller only sees plain value `ViewportRect`s. No DOM elements, PM positions, or painter selectors leak through `superdoc/ui`. Comment lookup parses `data-comment-ids="c1,c2,c3"` by splitting on comma and matching tokens by exact equality. CSS attribute selectors (`[...~="c1"]`) split on whitespace, not comma, and substring selectors (`[...*="c1"]`) would partial-match `c12`. Tracked-change lookup reuses the existing private helper which escapes ids for selector interpolation. Reasons - not-ready: editor / presentation editor not mounted yet - invalid-target: caller-shape error (missing kind, empty id, etc.) - unresolved: reserved for the text-anchored paths - not-mounted: valid target, currently virtualized / offscreen (caller can scrollIntoView first then retry) Deferred - TextAddress / TextTarget paths land in a follow-up. The type union accepts them today so call sites are forward-compatible, but those branches return `invalid-target` until the story-aware text resolver lands. Driven by the same concern as the existing `editor.doc.ranges.scrollIntoView` text path: must route through the active routed editor (header / footer / note vs body), not silently read body coords. - examples/headless/dropin-assessment refactor lands separately. Tests - EntityRectFinder unit tests: comma-overlap (c1 must not match c12), whitespace tolerance, story-key filtering (body / non-body), missing-host / empty-id guards, NaN-rect rejection, pageIndex resolution from `.superdoc-page` wrapper. - viewport.test: success/failure shapes, story passthrough, not-mounted vs not-ready vs invalid-target, scrollIntoView pass-through, JSON-serializable rect output. * fix(superdoc/ui): strict story filter + entity-type validation (PR #2984 review) Two review fixes for `ui.viewport.getRect`: 1. Strict story filter for tracked-change rects. The navigation helper `#findRenderedTrackedChangeElements` falls back to all same-id matches when an exact story match doesn't satisfy a heuristic β correct for "scroll to this change", but wrong for the viewport read path: a sticky card asked to anchor a header/footer change must not silently anchor to a body copy of the same id. Adds `findRenderedTrackedChangeElementsStrict` in the EntityRectFinder helper module and routes `PresentationEditor.getEntityRects` through it. Empty result surfaces as `not-mounted` so the consumer can pre-mount via `viewport.scrollIntoView` and retry. 2. Reject unsupported entity types up front in `ui.viewport.getRect`. A typo or unsupported address (`bookmark`, `field`, etc.) used to fall through to `getEntityRects`, return `[]`, and surface as `not-mounted` β misleading consumers into retry / scroll loops for shapes the controller doesn't handle. Now returns `invalid-target` immediately, matching the `ViewportRectResult` contract. Tests - `findRenderedTrackedChangeElementsStrict`: exact-story match, no cross-story fallback when requested story is empty, no-storyKey returns all copies, CSS-special id escape. - `viewport.getRect`: bogus `entityType` returns `invalid-target` and never consults the engine. * chore(document-api): remove UI-shaped surfaces (SD-2795) (#2988) * chore(document-api): remove UI-shaped surfaces (SD-2795) `editor.doc.*` is the request/response Document API contract. UI-shaped surfaces (subscriptions, viewport scroll, geometry, focus) belong on the browser-only `superdoc/ui` controller. This drops the two members that were already on the contract-parity exemption list: - `editor.doc.selection.onChange` β push subscription. Replacement is `createSuperDocUI({ superdoc }).select(s => s.selection, ...)` on the controller's selector substrate. - `editor.doc.ranges.scrollIntoView` β viewport scroll, browser-only. Replacement is `ui.viewport.scrollIntoView(input)` (also called by `ui.comments.scrollTo` and `ui.review.scrollTo`). Both shipped only in `v1.30.0-next.*` / `v1.28.0-next.*` pre-release tags β no stable release contains them β so the cut is clean and needs no deprecation cycle. Engine wiring - `SelectionAdapter` is now `current()` only. - `SelectionApi` is now `current()` only. - `RangesApi` / `RangesAdapter` are `resolve()` only. - `executeScrollIntoView`, `RangeScrollAdapter`, and `SelectionChangeListener` are deleted. - `assembleDocumentApiAdapters` no longer wires `selection.onChange` or `ranges.scrollIntoView`. - `subscribeToSelection` removed from `selection-info-resolver`; the pure read path `resolveCurrentSelectionInfo` stays. Relocation - Scroll resolution moves to `packages/super-editor/src/ui/scroll-into-view.ts`. `ui.viewport.scrollIntoView`, `ui.comments.scrollTo`, and `ui.review.scrollTo` call it directly through the routed presentation editor. - `ScrollIntoViewInput` / `ScrollIntoViewOutput` value types stay in doc-api β they describe the request shape `superdoc/ui` consumers marshal across all three handles. Parity check `META_MEMBER_PATHS` in `check-contract-parity.ts` now contains only the dispatcher (`invoke`) and reference aliases. Verified locally: "contract parity check passed (389 operations, 389 API members)". Tests - 11954 super-editor pass; 70 ui-domain pass. - 1381 doc-api pass. - Stubs in `comments.test.ts`, `review.test.ts`, `viewport.test.ts` updated to wire `presentationEditor.navigateTo` instead of the removed `editor.doc.ranges.scrollIntoView`. - `consumer-typecheck` smoke test for scroll moved to the `ViewportHandle` shape. Generated artifacts Regenerated via `pnpm run generate:all`. SDK tool catalogs / contract manifest pick up the smaller surface automatically β no operations added or removed (neither member was an OPERATION_DEFINITIONS entry). * fix(superdoc/ui): narrow scroll target via discriminated kind (PR #2988 review) `scroll-into-view.ts` checked `kind === 'entity'` through a cast, which left `input.target` widened as `TextAddress | TextTarget | EntityAddress` inside the entity branch β `presentation.navigateTo` expects `NavigableAddress`, so `tsc` flagged TS2345. Switch to a regular discriminated-union check on `target.kind`. All three target shapes carry a `kind` field, so the equality check narrows directly: `EntityAddress` inside the block, `TextAddress | TextTarget` after the early return. Drops the now-unused `TextAddress` / `TextTarget` named imports. * feat(superdoc/ui): rich state.selection slice + activeCommentIds/activeChangeIds resolver (SD-2801, SD-2792) (#2990) * feat(document-api): selection.current() exposes activeCommentIds and activeChangeIds (SD-2792) Adds two read-only id arrays to `SelectionInfo`: - `activeCommentIds: string[]` β `commentMark.commentId`s overlapping the selection (or under the caret when empty) - `activeChangeIds: string[]` β `trackInsert` / `trackDelete` / `trackFormat` mark ids overlapping the selection Union semantics (NOT intersection): an id is included when *any* character in the range carries the mark. `activeMarks` keeps its intersection semantics; the two answer different questions and have different defaults. Why: Custom sidebars need to answer "is there a comment / tracked change under the cursor?" to render a floating "comment here" hint, highlight the active sidebar card as the user moves the caret, disable a "new comment" button when the selection already covers an existing comment, and drive next/previous review navigation. Today consumers either: - Call `comments.list()` and overlap-filter on every keystroke (full read per cursor move), or - Reach into the rendered DOM via `document.querySelectorAll( '[data-comment-id]')` (the dropin-assessment lab does this β a DOM-shape coupling no migration guide should teach). Both are escape hatches. The selection resolver (SD-2668) already walks the selection range; collecting these ids is one extra pass on the same nodes. Implementation: - `collectActiveEntityIds` walks the selection in the same shape as `collectActiveMarks` (caret-only branch for empty selections plus stored marks; nodesBetween for ranges). Bounded allocation, runs in O(text nodes) for both branches. - Mark name constants are local to the resolver rather than imported from the comment / track-changes extensions. The resolver lives one package up the dependency graph; pulling in the PM plugins would bloat the import path for no gain. The constant set is small and changes when the schema does. - `selectionInfoKey` (transaction dedupe cache) now includes the new fields so a comment/change id change between transactions still emits to subscribers. Schema + generated reference docs updated; both fields are required and serialize as empty arrays when no entity marks are active. Verified: 23 resolver tests (5 new for entity-id collection covering union semantics, both kinds, mixed marks, JSON round-trip). Document- API: 1395 / 0 fail. Super-editor: 11893 / 13 skipped / 0 fail. Contract parity: 389/389. `pnpm run generate:all` clean. * feat(superdoc/ui): rich state.selection slice (SD-2801) Widen `state.selection` from `{ empty, quotedText }` to mirror the full `SelectionInfo` returned by `editor.doc.selection.current()`: adds `target`, `activeMarks`, `activeCommentIds`, `activeChangeIds`. A single `ui.select(s => s.selection, shallowEqual).subscribe(cb)` now gives consumers everything a floating bubble menu / format toolbar / mention popover / "comment here" hint needs, instead of having to call `editor.doc.selection.current()` synchronously inside every callback. Memoization Slice identity stays stable across recomputes when the projected shape hasn't changed. The memo key folds in empty + target (deep) + activeMarks + activeCommentIds + activeChangeIds + quotedText so a typing-only transaction (which leaves the projection unchanged but allocates fresh arrays inside the resolver) keeps the slice identity stable and lets `shallowEqual` short-circuit subscribers on the editing hot path. Same posture as the comments / review slice memos. Resilience Falls back to safe defaults (`target: null`, `[]` for the id arrays, `''` for `quotedText`) when `selection.current` is missing fields β keeps backwards-compat with legacy / partial resolvers. Tests - Slice mirrors full SelectionInfo when resolver populates every field. - Slice identity stable across two transactions that don't change the projection (memoization on hot path). - Slice identity changes when activeMarks change (caret enters bold). - Falls back to safe defaults for legacy resolvers. Generated artifacts refreshed via `pnpm run generate:all`. * fix(superdoc/ui): tracked-changes event + host routing + story scroll (PR #2979 review) Four review fixes against the stacked superdoc/ui surface: 1. tracked-changes-changed (P1). The cache listener was wired to `trackedChangesUpdate`, an event no source actually emits β the tracked-change index broadcasts `tracked-changes-changed`. Wire that name in `EDITOR_EVENTS` and `LIST_REFRESH_EVENTS` so normal editing / collaboration / external accept/reject mutations refresh `ui.review` instead of leaving subscribers stale until one of the controller's own action methods fires. 2. Story-aware scrollTo (P2). `ui.review.scrollTo(id)` and `ui.comments.scrollTo(commentId)` looked up the entity but dropped `address.story` from the EntityAddress they passed to `presentation.navigateTo`. Without it, navigateTo defaults to body and either fails with target-not-found or anchors to a same-id body change. Added `lookupItemStory(id)` paralleling `buildChangeDecideTarget` and forwarded story when present. 3. Host-routed review decisions (P2). `ui.review.accept` / `reject` / `acceptAll` / `rejectAll` resolved the editor through `resolveRoutedEditor`, which returns the routed header / footer / note editor when focus is in a child story. Decisions are document-wide; routing through a child editor scopes them to that child story instead. Added `resolveHostEditor` (always `superdoc.activeEditor`) and routed `requireDocTrackChanges` and `runScrollIntoView` through it. The change's own `address.story` in the decide / scroll target tells the adapter which story to operate against. 4. Discriminate text targets by segments-array content, not just shape. `'segments' in target` would mis-classify a hybrid payload carrying an empty `segments[]`. Switch to `Array.isArray(target.segments) && target.segments.length > 0`, matching the fix in PR #2941 (commit af50f50). Tests - Updated the regression for cache refresh to fire the correct `tracked-changes-changed` event. - Added `scrollTo` regressions: story preserved for header changes, omitted for body changes. - Added host-routing regressions: `accept(id)` and `acceptAll()` go through the host editor's `decide` even when toolbar routing returns a child story editor. - Updated review test fixtures to plant a child editor stub for the routing tests. * fix(superdoc/ui): resolver covers inline atoms + legacy comment ids (PR #2990 review) * fix(superdoc/ui): tsc -b clean + codecov d.ts ignore + consumer-typecheck smoke (PR #2979 review) (#2994) * test(behavior): superdoc/ui activeCommentIds + viewport.getRect specs (PR #2979) Two end-to-end Playwright specs covering the new browser-only `superdoc/ui` surface introduced in this stack. Cross-checked against chromium, firefox, and webkit (18/18). selection-active-comment-ids.spec.ts (SD-2792) - Caret inside a comment span surfaces the commentId in `selection.current().activeCommentIds`. - Caret outside any comment span returns an empty array. - Moving the caret between two distinct comment spans switches the active id (proves the resolver re-walks marks per transaction, not just initial selection). viewport-get-rect.spec.ts (SD-2793) - `ui.viewport.getRect({ target: EntityAddress })` returns rects that match the painted comment-highlight element's `getBoundingClientRect()` within Β±1px. Catches drift between the layout-engine output and the rect resolver, which jsdom unit tests can't reach. - Unknown / unmounted entity id returns `{ success: false, reason: 'not-mounted' }`. - Unsupported `entityType` returns `{ success: false, reason: 'invalid-target' }`. Harness Extended `tests/behavior/harness/main.ts` to expose the `superdoc/ui` controller via `window.__bootSuperDocUI()`. Tests call it lazily so the controller is only constructed when needed β no edge effects on tests that don't exercise `createSuperDocUI`. Helpers Added `reopenComment(page, { commentId })` to `tests/behavior/helpers/document-api.ts` as a sibling of `resolveComment`. The reopen behavior spec itself lands alongside SD-2789 (PR #2987) β that lifecycle isn't on this stack base yet. * fix(superdoc/ui): viewport.getRect routes through host PresentationEditor (PR #2979 review) * feat(superdoc/ui): expose toolbar/commands types via superdoc/ui (PR #2979 review) * feat(superdoc/ui): ui.selection handle + narrow viewport target type (PR #2979 review) * feat(superdoc/ui): drop ui.review.setRecording β name lied about behavior (PR #2979 review) * chore(super-editor): add test:ui / test:ui:watch scripts (PR #2979 review)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
First domain to land on the new
superdoc/uicontroller. Addsui.toolbar(aggregate,HeadlessToolbarController-shaped) andui.commands.<id>(per-command observables, CKEditor 5 pattern) on top of the SD-2794 selector substrate.The three shapes (
ui.selectsubstrate /ui.toolbaraggregate /ui.commands.<id>per-command) are all backed by the same internalcreateHeadlessToolbarinstance feedingstate.toolbar. Each per-commandobserveis internallyui.select((s) => s.toolbar.commands[id], shallowEqual)β a 50-button toolbar with per-command observables only re-renders the button whose state changed.Per-command handles are cached so reference identity is stable (matters for React
useMemodeps and for consumers comparing handles). Unknown ids fall back to{ active: false, disabled: true, value: undefined }rather than throwing β leaves room for theui.commands.register({ id, execute, getState })follow-up (FRICTION S3) without breaking forgiving consumers.Stub-bug fix in
create-super-doc-ui.test.ts:fireEditoriterated a liveSetwhile the internal headless-toolbar rebound listeners on every change, picking up the new handler mid-loop and recursing β OOM. Snapshot the handler list before iterating, matching how real editor event buses behave.Verified:
pnpm --filter superdoc buildclean βdist/ui.es.js+dist/superdoc/src/ui.d.tsemit;import { createSuperDocUI } from 'superdoc/ui'exposesui.toolbarandui.commands.<id>at runtime and type-checkOut of scope (filed under SD-2796 as follow-ups):
ui.commands.register({ id, execute, getState })β closes FRICTION S3 (closed toolbar registry)superdoc/headless-toolbarinto a shim aroundcreateSuperDocUI({ superdoc }).toolbarSuperToolbar.vueto consumeui.toolbar(visual / behavior parity validation)