Skip to content

Commit 04d04d8

Browse files
committed
feat(docx-io): add tracked changes and comments import/export
Add full round-trip support for DOCX tracked changes (suggestions) and comments with threading. This enables import/export of Word documents while preserving revision history and comment discussions. Import pipeline: - Custom mammoth.js fork emits [[DOCX_*:...]] tokens for ins/del/comments - importTrackChanges.ts parses tokens, applies suggestion marks - importComments.ts parses comment tokens, creates discussion structures - importDocx.ts orchestrates full pipeline with token cleanup - searchRange.ts provides text-based range finding for token placement Export pipeline: - exportTrackChanges.ts injects tracking tokens into serialized HTML - exportComments.ts handles comment-specific OOXML generation - html-to-docx enhanced with comments.xml, commentsExtended.xml, commentsIds.xml, commentsExtensible.xml, and people.xml generation - Proper paraId threading for reply chains App integration: - import-toolbar-button.tsx: full DOCX import with user registration - export-toolbar-button.tsx: export with discussions and suggestions - suggestion-node-docx.tsx: DOCX-safe suggestion rendering (no ins/del) - discussion-kit.tsx: paraId fields for round-trip fidelity - docx-export-kit.tsx: SuggestionLeafDocx override for clean export Key features: - Suggestion authorship preserved through round-trip - Comment threading via paraId/parentParaId linking - UTC date handling for cross-timezone compatibility - Overlapping comment ranges supported - ImportedUser type for user store registration
1 parent d99adbf commit 04d04d8

105 files changed

Lines changed: 40984 additions & 385 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/www/src/registry/components/editor/plugins/discussion-kit.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ export type TDiscussion = {
1313
isResolved: boolean;
1414
userId: string;
1515
documentContent?: string;
16+
/** Direct author name from DOCX import (bypasses user lookup) */
17+
authorName?: string;
18+
/** Author initials from DOCX import */
19+
authorInitials?: string;
20+
/** OOXML paraId for round-trip DOCX threading fidelity */
21+
paraId?: string;
1622
};
1723

1824
const discussionsData: TDiscussion[] = [

apps/www/src/registry/components/editor/plugins/docx-export-kit.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
EquationElementDocx,
2727
InlineEquationElementDocx,
2828
} from '@/registry/ui/equation-node-static';
29+
import { SuggestionLeafDocx } from '@/registry/ui/suggestion-node-docx';
2930
import { TocElementDocx } from '@/registry/ui/toc-node-static';
3031
import { DocxExportPlugin } from '@platejs/docx-io';
3132
import { KEYS } from 'platejs';
@@ -40,6 +41,7 @@ import { KEYS } from 'platejs';
4041
* - Equations (inline font instead of KaTeX)
4142
* - Callouts (table layout for icon placement)
4243
* - TOC (anchor links with paragraph breaks)
44+
* - Suggestions (<span> instead of <ins>/<del> to avoid unwanted formatting)
4345
*
4446
* Tables use base version with juice CSS inlining.
4547
*/
@@ -56,6 +58,7 @@ export const DocxExportKit = [
5658
[KEYS.inlineEquation]: InlineEquationElementDocx,
5759
[KEYS.callout]: CalloutElementDocx,
5860
[KEYS.toc]: TocElementDocx,
61+
[KEYS.suggestion]: SuggestionLeafDocx,
5962
},
6063
},
6164
}),
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { describe, expect, it } from 'bun:test';
2+
3+
import { getDiscussionCounterSeed } from './discussion-ids';
4+
5+
describe('getDiscussionCounterSeed', () => {
6+
it('returns 0 for empty list', () => {
7+
expect(getDiscussionCounterSeed([])).toBe(0);
8+
});
9+
10+
it('returns max suffix for contiguous ids', () => {
11+
const discussions = [{ id: 'discussion1' }, { id: 'discussion2' }];
12+
expect(getDiscussionCounterSeed(discussions)).toBe(2);
13+
});
14+
15+
it('returns max suffix for non-contiguous ids', () => {
16+
const discussions = [{ id: 'discussion1' }, { id: 'discussion3' }];
17+
expect(getDiscussionCounterSeed(discussions)).toBe(3);
18+
});
19+
20+
it('ignores non-matching ids', () => {
21+
const discussions = [
22+
{ id: 'alpha1' },
23+
{ id: 'discussion' },
24+
{ id: 'discussion10x' },
25+
];
26+
expect(getDiscussionCounterSeed(discussions)).toBe(0);
27+
});
28+
29+
it('handles mixed ids and returns highest match', () => {
30+
const discussions = [
31+
{ id: 'discussion2' },
32+
{ id: 'alpha1' },
33+
{ id: 'discussion12' },
34+
];
35+
expect(getDiscussionCounterSeed(discussions)).toBe(12);
36+
});
37+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
export type DiscussionIdLike = {
2+
id: string;
3+
};
4+
5+
const discussionIdPattern = /^discussion(\d+)$/;
6+
7+
export const getDiscussionCounterSeed = (
8+
discussions: DiscussionIdLike[]
9+
): number =>
10+
discussions.reduce((max, discussion) => {
11+
const match = discussionIdPattern.exec(discussion.id);
12+
if (!match) return max;
13+
14+
const value = Number(match[1]);
15+
if (Number.isNaN(value)) return max;
16+
17+
return Math.max(max, value);
18+
}, 0);

apps/www/src/registry/ui/block-discussion.tsx

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import * as React from 'react';
44

55
import type { PlateElementProps, RenderNodeWrapper } from 'platejs/react';
66

7-
import { getDraftCommentKey } from '@platejs/comment';
7+
import {
8+
getCommentKeyId,
9+
getCommentKeys,
10+
getDraftCommentKey,
11+
} from '@platejs/comment';
812
import { CommentPlugin } from '@platejs/comment/react';
913
import { getTransientSuggestionKey } from '@platejs/suggestion';
1014
import { SuggestionPlugin } from '@platejs/suggestion/react';
@@ -316,31 +320,45 @@ const useResolvedDiscussion = (
316320

317321
const discussions = usePluginOption(discussionPlugin, 'discussions');
318322

323+
const getLeafCommentIds = (leaf: TCommentText) =>
324+
getCommentKeys(leaf)
325+
.map(getCommentKeyId)
326+
.filter((id): id is string => Boolean(id) && id !== 'draft');
327+
328+
const map = getOption('uniquePathMap');
329+
const nextMap = new Map(map);
330+
let mapChanged = false;
331+
319332
commentNodes.forEach(([node]) => {
320-
const id = api.comment.nodeId(node);
321-
const map = getOption('uniquePathMap');
333+
const ids = getLeafCommentIds(node);
334+
if (ids.length === 0) return;
322335

323-
if (!id) return;
336+
ids.forEach((id) => {
337+
const previousPath = nextMap.get(id);
324338

325-
const previousPath = map.get(id);
339+
// If there are no comment nodes in the corresponding path in the map, then update it.
340+
if (PathApi.isPath(previousPath)) {
341+
const nodes = api.comment.node({ id, at: previousPath });
326342

327-
// If there are no comment nodes in the corresponding path in the map, then update it.
328-
if (PathApi.isPath(previousPath)) {
329-
const nodes = api.comment.node({ id, at: previousPath });
343+
if (!nodes) {
344+
nextMap.set(id, blockPath);
345+
mapChanged = true;
346+
}
330347

331-
if (!nodes) {
332-
setOption('uniquePathMap', new Map(map).set(id, blockPath));
333348
return;
334349
}
335-
336-
return;
337-
}
338-
// TODO: fix throw error
339-
setOption('uniquePathMap', new Map(map).set(id, blockPath));
350+
// TODO: fix throw error
351+
nextMap.set(id, blockPath);
352+
mapChanged = true;
353+
});
340354
});
341355

356+
if (mapChanged) {
357+
setOption('uniquePathMap', nextMap);
358+
}
359+
342360
const commentsIds = new Set(
343-
commentNodes.map(([node]) => api.comment.nodeId(node)).filter(Boolean)
361+
commentNodes.flatMap(([node]) => getLeafCommentIds(node))
344362
);
345363

346364
const resolvedDiscussions = discussions

apps/www/src/registry/ui/comment.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,14 @@ export type TComment = {
5454
discussionId: string;
5555
isEdited: boolean;
5656
userId: string;
57+
/** Direct author name from DOCX import (bypasses user lookup) */
58+
authorName?: string;
59+
/** Author initials from DOCX import */
60+
authorInitials?: string;
61+
/** OOXML paraId for round-trip DOCX threading fidelity */
62+
paraId?: string;
63+
/** OOXML parentParaId for round-trip DOCX reply threading */
64+
parentParaId?: string;
5765
};
5866

5967
export function Comment(props: {
@@ -180,12 +188,19 @@ export function Comment(props: {
180188
>
181189
<div className="relative flex items-center">
182190
<Avatar className="size-5">
183-
<AvatarImage alt={userInfo?.name} src={userInfo?.avatarUrl} />
184-
<AvatarFallback>{userInfo?.name?.[0]}</AvatarFallback>
191+
<AvatarImage
192+
alt={comment.authorName ?? userInfo?.name}
193+
src={userInfo?.avatarUrl}
194+
/>
195+
<AvatarFallback>
196+
{comment.authorInitials ??
197+
comment.authorName?.[0] ??
198+
userInfo?.name?.[0]}
199+
</AvatarFallback>
185200
</Avatar>
186201
<h4 className="mx-2 font-semibold text-sm leading-none">
187-
{/* Replace to your own backend or refer to potion */}
188-
{userInfo?.name}
202+
{/* Use direct author name from DOCX or fall back to user lookup */}
203+
{comment.authorName ?? userInfo?.name}
189204
</h4>
190205

191206
<div className="text-muted-foreground/80 text-xs leading-none">

apps/www/src/registry/ui/export-toolbar-button.tsx

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import * as React from 'react';
44

55
import type { DropdownMenuProps } from '@radix-ui/react-dropdown-menu';
66

7-
import { exportToDocx } from '@platejs/docx-io';
7+
import { exportToDocx, type DocxExportDiscussion } from '@platejs/docx-io';
88
import { MarkdownPlugin } from '@platejs/markdown';
99
import { ArrowDownToLineIcon } from 'lucide-react';
1010
import type { SlatePlugin } from 'platejs';
@@ -20,6 +20,7 @@ import {
2020
DropdownMenuTrigger,
2121
} from '@/components/ui/dropdown-menu';
2222
import { BaseEditorKit } from '@/registry/components/editor/editor-base-kit';
23+
import { discussionPlugin } from '@/registry/components/editor/plugins/discussion-kit';
2324

2425
import { EditorStatic } from './editor-static';
2526
import { ToolbarButton } from './toolbar';
@@ -151,8 +152,43 @@ export function ExportToolbarButton(props: DropdownMenuProps) {
151152
};
152153

153154
const exportToWord = async () => {
155+
// Get discussions and users from the discussion plugin for comment export
156+
const discussions = editor.getOption(discussionPlugin, 'discussions') ?? [];
157+
const users = editor.getOption(discussionPlugin, 'users') ?? {};
158+
159+
// Resolve display name: prefer authorName (from DOCX import), fall back to users lookup
160+
const resolveUser = (
161+
userId: string,
162+
authorName?: string
163+
): { id: string; name: string } | undefined => {
164+
const name = authorName ?? users[userId]?.name;
165+
return name ? { id: userId, name } : undefined;
166+
};
167+
168+
// Convert discussions to export format
169+
const exportDiscussions: DocxExportDiscussion[] = discussions.map((d) => ({
170+
id: d.id,
171+
comments: d.comments?.map((c) => ({
172+
contentRich: c.contentRich,
173+
createdAt: c.createdAt,
174+
id: c.id,
175+
paraId: c.paraId,
176+
parentParaId: c.parentParaId,
177+
userId: c.userId,
178+
user: resolveUser(c.userId, c.authorName),
179+
})),
180+
createdAt: d.createdAt,
181+
documentContent: d.documentContent,
182+
paraId: d.paraId,
183+
userId: d.userId,
184+
user: resolveUser(d.userId, d.authorName),
185+
}));
186+
154187
const blob = await exportToDocx(editor.children, {
155188
editorPlugins: [...BaseEditorKit, ...DocxExportKit] as SlatePlugin[],
189+
tracking: {
190+
discussions: exportDiscussions,
191+
},
156192
});
157193

158194
const url = URL.createObjectURL(blob);

apps/www/src/registry/ui/import-toolbar-button.tsx

Lines changed: 99 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@ import * as React from 'react';
44

55
import type { DropdownMenuProps } from '@radix-ui/react-dropdown-menu';
66

7-
import { importDocx } from '@platejs/docx-io';
7+
import { getCommentKey } from '@platejs/comment';
8+
import { importDocxWithTracking } from '@platejs/docx-io';
89
import { MarkdownPlugin } from '@platejs/markdown';
10+
import { getSuggestionKey } from '@platejs/suggestion';
911
import { ArrowUpToLineIcon } from 'lucide-react';
10-
import { getEditorDOMFromHtmlString } from 'platejs/static';
12+
import { KEYS, TextApi } from 'platejs';
1113
import { useEditorRef } from 'platejs/react';
14+
import { getEditorDOMFromHtmlString } from 'platejs/static';
1215
import { useFilePicker } from 'use-file-picker';
1316

1417
import {
@@ -19,10 +22,18 @@ import {
1922
DropdownMenuTrigger,
2023
} from '@/components/ui/dropdown-menu';
2124

25+
import { commentPlugin } from '@/registry/components/editor/plugins/comment-kit';
26+
import {
27+
discussionPlugin,
28+
type TDiscussion,
29+
} from '@/registry/components/editor/plugins/discussion-kit';
30+
import { getDiscussionCounterSeed } from '../lib/discussion-ids';
2231
import { ToolbarButton } from './toolbar';
2332

2433
type ImportType = 'html' | 'markdown';
2534

35+
const WHITESPACE_REGEX = /\s+/;
36+
2637
export function ImportToolbarButton(props: DropdownMenuProps) {
2738
const editor = useEditorRef();
2839
const [open, setOpen] = React.useState(false);
@@ -73,9 +84,93 @@ export function ImportToolbarButton(props: DropdownMenuProps) {
7384
multiple: false,
7485
onFilesSelected: async ({ plainFiles }) => {
7586
const arrayBuffer = await plainFiles[0].arrayBuffer();
76-
const result = await importDocx(editor, arrayBuffer);
7787

78-
editor.tf.insertNodes(result.nodes as typeof editor.children);
88+
// Compute next discussion number to avoid ID collisions
89+
const existingDiscussions =
90+
editor.getOption(discussionPlugin, 'discussions') ?? [];
91+
let discussionCounter = getDiscussionCounterSeed(existingDiscussions);
92+
93+
// Import with full tracking support (suggestions + comments)
94+
const result = await importDocxWithTracking(editor as any, arrayBuffer, {
95+
suggestionKey: KEYS.suggestion,
96+
getSuggestionKey,
97+
commentKey: KEYS.comment,
98+
getCommentKey,
99+
isText: TextApi.isText,
100+
generateId: () => `discussion${++discussionCounter}`,
101+
});
102+
103+
// Register imported users so suggestion/comment UI can resolve them
104+
if (result.users.length > 0) {
105+
const existingUsers = editor.getOption(discussionPlugin, 'users') ?? {};
106+
const updatedUsers = { ...existingUsers };
107+
108+
for (const user of result.users) {
109+
if (!updatedUsers[user.id]) {
110+
updatedUsers[user.id] = {
111+
id: user.id,
112+
name: user.name,
113+
avatarUrl: `https://api.dicebear.com/9.x/glass/svg?seed=${encodeURIComponent(user.name)}`,
114+
};
115+
}
116+
}
117+
118+
editor.setOption(discussionPlugin, 'users', updatedUsers);
119+
}
120+
121+
// Add imported discussions to the discussion plugin
122+
if (result.discussions.length > 0) {
123+
// Convert imported discussions to TDiscussion format
124+
const newDiscussions: TDiscussion[] = result.discussions.map((d) => ({
125+
id: d.id,
126+
comments: (d.comments ?? []).map((c, index) => ({
127+
id: c.id || `comment${index + 1}`,
128+
contentRich:
129+
c.contentRich as TDiscussion['comments'][number]['contentRich'],
130+
createdAt: c.createdAt ?? new Date(),
131+
discussionId: d.id,
132+
isEdited: false,
133+
userId: c.userId ?? c.user?.id ?? 'imported-unknown',
134+
authorName: c.user?.name,
135+
authorInitials: c.user?.name
136+
? c.user.name
137+
.split(WHITESPACE_REGEX)
138+
.slice(0, 2)
139+
.map((w) => w[0]?.toUpperCase() ?? '')
140+
.join('')
141+
: undefined,
142+
paraId: c.paraId,
143+
parentParaId: c.parentParaId,
144+
})),
145+
createdAt: d.createdAt ?? new Date(),
146+
documentContent: d.documentContent,
147+
isResolved: false,
148+
userId: d.userId ?? d.user?.id ?? 'imported-unknown',
149+
authorName: d.user?.name,
150+
authorInitials: d.user?.name
151+
? d.user.name
152+
.split(WHITESPACE_REGEX)
153+
.slice(0, 2)
154+
.map((w) => w[0]?.toUpperCase() ?? '')
155+
.join('')
156+
: undefined,
157+
paraId: d.paraId,
158+
}));
159+
160+
// Replace all discussions (not append) because importDocxWithTracking
161+
// replaces the entire editor content, making old discussions stale
162+
editor.setOption(discussionPlugin, 'discussions', newDiscussions);
163+
editor.setOption(commentPlugin, 'uniquePathMap', new Map());
164+
}
165+
166+
// Log import results in dev only
167+
if (
168+
result.hasTracking &&
169+
result.errors.length > 0 &&
170+
process.env.NODE_ENV !== 'production'
171+
) {
172+
console.warn('[DOCX Import] Errors:', result.errors);
173+
}
79174
},
80175
});
81176

0 commit comments

Comments
 (0)