Skip to content

Commit e1a3594

Browse files
authored
refactor(ui): drop ui.review, add ui.trackChanges (SD-2667) (#3029)
`ui.review` shipped a merged comments + tracked-changes feed with `accept` / `reject` verbs, but the merged-feed concept was speculative. No customer asked for it, and consumers building review sidebars can compose `ui.comments.items` and `ui.trackChanges.items` themselves (roughly 30 LOC, demonstrated in the BYO-UI demo's ActivitySidebar). Mirroring `editor.doc.trackChanges` keeps the controller surface tight and the layering predictable: each `ui.<domain>` namespace tracks one doc-API namespace. - `TrackChangesItem` carries only tracked changes (no `kind` discriminator) - `TrackChangesSlice` exposes `items` / `total` / `activeId` - `TrackChangesHandle` keeps `accept` / `reject` / `acceptAll` / `rejectAll` / `next` / `previous` / `scrollTo` - `useSuperDocReview` becomes `useSuperDocTrackChanges` - Demo's ActivitySidebar merges the two slices locally
1 parent c6af392 commit e1a3594

16 files changed

Lines changed: 355 additions & 456 deletions

File tree

demos/build-your-own-ui/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ Open http://localhost:5189.
3030
SuperDocUIProvider one controller per app
3131
└── EditorMount <SuperDocEditor> + onReady
3232
├── Toolbar ui.commands + setDocumentMode
33-
└── ActivitySidebar ui.review + ui.selection
33+
└── ActivitySidebar ui.comments + ui.trackChanges + ui.selection
3434
└── CommentComposer ui.selection.capture()
3535
```
3636

demos/build-your-own-ui/src/components/ActivitySidebar.tsx

Lines changed: 79 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,26 @@
11
import { useEffect, useMemo, useRef, useState } from 'react';
22
import type { SuperDoc } from 'superdoc';
3-
import type { ReviewItem, ReviewSlice } from 'superdoc/ui';
4-
import { useSuperDocHost, useSuperDocReview, useSuperDocSelection, useSuperDocUI } from 'superdoc/ui/react';
3+
import type { CommentsListResult, TrackChangeInfo } from 'superdoc/ui';
4+
import {
5+
useSuperDocComments,
6+
useSuperDocHost,
7+
useSuperDocSelection,
8+
useSuperDocTrackChanges,
9+
useSuperDocUI,
10+
} from 'superdoc/ui/react';
511
import { CommentComposer } from './CommentComposer';
612

7-
type ReviewCommentItem = Extract<ReviewItem, { kind: 'comment' }>;
8-
type ReviewChangeItem = Extract<ReviewItem, { kind: 'change' }>;
9-
type ReviewComment = ReviewCommentItem['comment'];
10-
type ReviewChange = ReviewChangeItem['change'];
13+
type CommentItem = CommentsListResult['items'][number];
14+
15+
/**
16+
* Local merged-feed item. The controller exposes comments and tracked
17+
* changes as separate slices (`ui.comments` / `ui.trackChanges`) so
18+
* each consumer can decide whether to merge them. This panel wants the
19+
* Google-Docs-style single feed, so we compose the two locally.
20+
*/
21+
type ActivityItem =
22+
| { kind: 'comment'; id: string; comment: CommentItem }
23+
| { kind: 'change'; id: string; change: TrackChangeInfo };
1124

1225
interface Props {
1326
/** When true, render the inline composer at the top of the panel. */
@@ -16,19 +29,6 @@ interface Props {
1629
onCloseComposer(): void;
1730
}
1831

19-
/**
20-
* Single Activity feed merging comments + tracked changes in document
21-
* order. Replaces the earlier dual Comments/Review tab split — that
22-
* was an internal-tooling convention; consumers want one panel showing
23-
* everything that needs attention.
24-
*
25-
* Active-card highlight is driven by the document selection: clicking
26-
* a comment or tracked change in the editor surfaces the matching id
27-
* via `ui.selection.activeCommentIds` / `activeChangeIds`, and the
28-
* panel highlights that card and scrolls it into view. No separate
29-
* event needed — SD-2792 already exposed the active ids on the
30-
* selection slice.
31-
*/
3232
interface DecidedChange {
3333
id: string;
3434
decision: 'accepted' | 'rejected';
@@ -37,67 +37,87 @@ interface DecidedChange {
3737
snapshot: { type?: string; author?: string; authorEmail?: string; excerpt?: string };
3838
}
3939

40+
/**
41+
* Single Activity feed merging comments + tracked changes in document
42+
* order. Composes `ui.comments.items` and `ui.trackChanges.items` so
43+
* the panel renders one card per row regardless of source.
44+
*
45+
* Active-card highlight is driven by the document selection: clicking
46+
* a comment or tracked change in the editor surfaces the matching id
47+
* via `ui.selection.activeCommentIds` / `activeChangeIds`, and the
48+
* panel highlights that card and scrolls it into view.
49+
*/
4050
export function ActivitySidebar({ composeOpen, onCloseComposer }: Props) {
4151
const ui = useSuperDocUI();
42-
const review = useSuperDocReview();
52+
const comments = useSuperDocComments();
53+
const trackChanges = useSuperDocTrackChanges();
4354
const selection = useSuperDocSelection();
4455

4556
// Track tracked-changes that the user has accepted/rejected. Once
46-
// decided, the change leaves the live `ui.review` feed (the
57+
// decided, the change leaves the live `ui.trackChanges` feed (the
4758
// tracked-change row in the document is gone — accepted means
4859
// applied, rejected means discarded). To mimic the Google Docs
49-
// experience the user asked for, we capture the change snapshot
50-
// before calling accept/reject and render it in the Resolved
51-
// section as an audit row. State is component-local: refresh wipes
52-
// it, which is fine for a demo.
60+
// experience, we capture the change snapshot before calling
61+
// accept/reject and render it in the Resolved section as an audit
62+
// row. State is component-local: refresh wipes it, which is fine
63+
// for a demo.
5364
const [decidedChanges, setDecidedChanges] = useState<Map<string, DecidedChange>>(() => new Map());
5465

5566
// Track which entity (if any) is currently under the editor cursor.
56-
// Multiple ids can be active when marks overlap; the example picks
57-
// the first for highlight purposes.
5867
const activeEntityId = useMemo<string | null>(() => {
5968
if (selection.activeCommentIds.length > 0) return selection.activeCommentIds[0]!;
6069
if (selection.activeChangeIds.length > 0) return selection.activeChangeIds[0]!;
6170
return null;
6271
}, [selection.activeCommentIds, selection.activeChangeIds]);
6372

64-
// Partition the live feed into active vs resolved-comment buckets,
65-
// and fold reply comments under their parent. Word/Google Docs thread
66-
// a comment by `parentCommentId` (DOCX persists this in
73+
// Merge the two slices into a single local feed. Comments are
74+
// emitted in `comments.list()` order, then tracked changes in
75+
// `trackChanges.list()` order. When `TrackChangeInfo.target` lands
76+
// (separate ticket), we'll be able to interleave by document
77+
// position; until then this stable two-bucket ordering matches what
78+
// the controller used to do internally.
79+
const feed = useMemo<ActivityItem[]>(() => {
80+
const items: ActivityItem[] = [];
81+
for (const c of comments.items) items.push({ kind: 'comment', id: c.id, comment: c });
82+
for (const tc of trackChanges.items) items.push({ kind: 'change', id: tc.id, change: tc.change });
83+
return items;
84+
}, [comments.items, trackChanges.items]);
85+
86+
// Partition the feed into active vs resolved-comment buckets, and
87+
// fold reply comments under their parent. Word/Google Docs thread a
88+
// comment by `parentCommentId` (DOCX persists this in
6789
// commentsExtended.xml as `paraIdParent`). The doc-api surfaces
6890
// `parentCommentId` on each item; we group it here so the sidebar
6991
// renders one card per thread root with its replies stacked under
7092
// it. Replies whose parent is missing (resolved or pruned) fall
7193
// back to top-level so we don't lose them.
7294
const { active, resolvedComments } = useMemo(() => {
73-
const a: ReviewSlice['items'] = [];
74-
const r: ReviewSlice['items'] = [];
95+
const a: ActivityItem[] = [];
96+
const r: ActivityItem[] = [];
7597
const commentRoots = new Set<string>();
76-
for (const item of review.items) {
98+
for (const item of feed) {
7799
if (item.kind === 'comment') {
78100
const c = item.comment as { parentCommentId?: string };
79101
if (!c.parentCommentId) commentRoots.add(item.id);
80102
}
81103
}
82-
for (const item of review.items) {
104+
for (const item of feed) {
83105
const isResolvedComment =
84106
item.kind === 'comment' && (item.comment as { status?: string }).status === 'resolved';
85107
if (item.kind === 'comment') {
86108
const c = item.comment as { parentCommentId?: string };
87-
// Reply rows are rendered inline inside the parent card —
88-
// skip them at the top level if the parent is also visible.
89109
if (c.parentCommentId && commentRoots.has(c.parentCommentId)) continue;
90110
}
91111
if (isResolvedComment) r.push(item);
92112
else a.push(item);
93113
}
94114
return { active: a, resolvedComments: r };
95-
}, [review.items]);
115+
}, [feed]);
96116

97117
// Replies indexed by parent id. Built once per snapshot.
98118
const repliesByParent = useMemo(() => {
99-
const map = new Map<string, ReviewSlice['items']>();
100-
for (const item of review.items) {
119+
const map = new Map<string, ActivityItem[]>();
120+
for (const item of feed) {
101121
if (item.kind !== 'comment') continue;
102122
const c = item.comment as { parentCommentId?: string };
103123
if (!c.parentCommentId) continue;
@@ -106,19 +126,16 @@ export function ActivitySidebar({ composeOpen, onCloseComposer }: Props) {
106126
map.set(c.parentCommentId, list);
107127
}
108128
return map;
109-
}, [review.items]);
129+
}, [feed]);
110130

111131
const decideChange = (id: string, decision: 'accepted' | 'rejected') => {
112132
if (!ui) return;
113133
// Capture a snapshot from the live feed BEFORE we mutate, since
114134
// accept/reject removes the tracked-change row entirely.
115-
const liveItem = review.items.find((it) => it.id === id);
116-
const change =
117-
liveItem?.kind === 'change'
118-
? (liveItem.change as DecidedChange['snapshot'])
119-
: null;
120-
if (decision === 'accepted') ui.review.accept(id);
121-
else ui.review.reject(id);
135+
const liveItem = trackChanges.items.find((it) => it.id === id);
136+
const change = (liveItem?.change ?? null) as DecidedChange['snapshot'] | null;
137+
if (decision === 'accepted') ui.trackChanges.accept(id);
138+
else ui.trackChanges.reject(id);
122139
if (change) {
123140
setDecidedChanges((prev) => {
124141
const next = new Map(prev);
@@ -128,19 +145,15 @@ export function ActivitySidebar({ composeOpen, onCloseComposer }: Props) {
128145
}
129146
};
130147

131-
// Reconcile `decidedChanges` against the live review feed: when a
132-
// tracked change we previously decided reappears in `review.items`
133-
// (undo of the accept/reject, collaborator restore, etc.), drop it
134-
// from the local decided roll-up. Without this prune, the same
135-
// change renders in both the Active and Resolved sections with a
136-
// stale "accepted" / "rejected" label.
148+
// Reconcile `decidedChanges` against the live track-changes feed:
149+
// when a tracked change we previously decided reappears in
150+
// `trackChanges.items` (undo of the accept/reject, collaborator
151+
// restore, etc.), drop it from the local decided roll-up.
137152
useEffect(() => {
138153
setDecidedChanges((prev) => {
139154
if (prev.size === 0) return prev;
140155
const liveChangeIds = new Set<string>();
141-
for (const item of review.items) {
142-
if (item.kind === 'change') liveChangeIds.add(item.id);
143-
}
156+
for (const item of trackChanges.items) liveChangeIds.add(item.id);
144157
let mutated = false;
145158
const next = new Map(prev);
146159
for (const id of prev.keys()) {
@@ -151,7 +164,7 @@ export function ActivitySidebar({ composeOpen, onCloseComposer }: Props) {
151164
}
152165
return mutated ? next : prev;
153166
});
154-
}, [review.items]);
167+
}, [trackChanges.items]);
155168

156169
// Auto-scroll the matching card into view when the active entity changes.
157170
const containerRef = useRef<HTMLDivElement | null>(null);
@@ -165,9 +178,6 @@ export function ActivitySidebar({ composeOpen, onCloseComposer }: Props) {
165178
return <div className="card">Loading editor…</div>;
166179
}
167180

168-
// Resolved roll-up: comments resolved in-document + tracked changes
169-
// we've decided locally. Sorted by most recently resolved first so
170-
// the latest action floats to the top of the resolved section.
171181
const decidedList = [...decidedChanges.values()].sort((a, b) => b.decidedAt - a.decidedAt);
172182
const resolvedCount = resolvedComments.length + decidedList.length;
173183
const empty = active.length === 0 && resolvedCount === 0 && !composeOpen;
@@ -196,7 +206,7 @@ export function ActivitySidebar({ composeOpen, onCloseComposer }: Props) {
196206
onDecideChange={decideChange}
197207
onClick={() => {
198208
if (item.kind === 'comment') ui.comments.scrollTo(item.id);
199-
else ui.review.scrollTo(item.id);
209+
else ui.trackChanges.scrollTo(item.id);
200210
}}
201211
/>
202212
))}
@@ -227,10 +237,10 @@ export function ActivitySidebar({ composeOpen, onCloseComposer }: Props) {
227237
}
228238

229239
interface CardProps {
230-
item: ReviewSlice['items'][number];
240+
item: ActivityItem;
231241
active: boolean;
232242
resolved: boolean;
233-
replies?: ReviewSlice['items'];
243+
replies?: ActivityItem[];
234244
onClick(): void;
235245
onDecideChange(id: string, decision: 'accepted' | 'rejected'): void;
236246
}
@@ -256,9 +266,9 @@ function CommentBody({
256266
replies,
257267
ui,
258268
}: {
259-
comment: ReviewComment;
269+
comment: CommentItem;
260270
resolved: boolean;
261-
replies?: ReviewSlice['items'];
271+
replies?: ActivityItem[];
262272
ui: NonNullable<ReturnType<typeof useSuperDocUI>>;
263273
}) {
264274
const host = useSuperDocHost() as SuperDoc | null;
@@ -391,7 +401,7 @@ function ChangeBody({
391401
change,
392402
onDecide,
393403
}: {
394-
change: ReviewChange;
404+
change: TrackChangeInfo;
395405
onDecide: (decision: 'accepted' | 'rejected') => void;
396406
}) {
397407
const kind = change.type === 'insert' ? 'insertion' : change.type === 'delete' ? 'deletion' : 'format';
@@ -413,9 +423,9 @@ function ChangeBody({
413423

414424
/**
415425
* Resolved-section row for a tracked change the user already
416-
* accepted/rejected. The live `ui.review` feed drops decided changes
417-
* (the row is gone from the document either way), so this row is
418-
* rendered from the local snapshot we captured before deciding —
426+
* accepted/rejected. The live `ui.trackChanges` feed drops decided
427+
* changes (the row is gone from the document either way), so this row
428+
* is rendered from the local snapshot we captured before deciding —
419429
* mimicking the Google Docs "Suggestion accepted" trail.
420430
*/
421431
function DecidedChangeCard({ entry }: { entry: DecidedChange }) {

packages/super-editor/src/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -175,9 +175,6 @@ export type {
175175
CommentsHandle,
176176
CommentsSlice,
177177
EqualityFn,
178-
ReviewHandle,
179-
ReviewItem,
180-
ReviewSlice,
181178
SelectorFn,
182179
SelectionSlice,
183180
Subscribable,
@@ -186,6 +183,9 @@ export type {
186183
SuperDocUI,
187184
SuperDocUIOptions,
188185
SuperDocUIState,
186+
TrackChangesHandle,
187+
TrackChangesItem,
188+
TrackChangesSlice,
189189
ViewportGetRectInput,
190190
ViewportHandle,
191191
ViewportRect,

0 commit comments

Comments
 (0)