@dxos/react-ui-editor 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/dist/lib/index.mjs +1477 -0
  2. package/dist/lib/index.mjs.map +1 -0
  3. package/dist/lib/translations.mjs +32 -0
  4. package/dist/lib/translations.mjs.map +1 -0
  5. package/dist/types/src/components/Editor/Editor.d.ts +22 -17
  6. package/dist/types/src/components/Editor/Editor.d.ts.map +1 -1
  7. package/dist/types/src/components/Editor/EditorContext.d.ts +20 -0
  8. package/dist/types/src/components/Editor/EditorContext.d.ts.map +1 -0
  9. package/dist/types/src/components/Editor/EditorView.d.ts +1 -2
  10. package/dist/types/src/components/Editor/EditorView.d.ts.map +1 -1
  11. package/dist/types/src/components/Editor/index.d.ts +2 -0
  12. package/dist/types/src/components/Editor/index.d.ts.map +1 -1
  13. package/dist/types/src/components/EditorMenuProvider/EditorMenuProvider.d.ts +7 -1
  14. package/dist/types/src/components/EditorMenuProvider/EditorMenuProvider.d.ts.map +1 -1
  15. package/dist/types/src/components/EditorMenuProvider/menu.d.ts +4 -2
  16. package/dist/types/src/components/EditorMenuProvider/menu.d.ts.map +1 -1
  17. package/dist/types/src/components/EditorMenuProvider/useEditorMenu.d.ts +7 -2
  18. package/dist/types/src/components/EditorMenuProvider/useEditorMenu.d.ts.map +1 -1
  19. package/dist/types/src/components/EditorPreviewProvider/EditorPreviewContext.d.ts +18 -0
  20. package/dist/types/src/components/EditorPreviewProvider/EditorPreviewContext.d.ts.map +1 -0
  21. package/dist/types/src/components/EditorPreviewProvider/EditorPreviewProvider.d.ts +0 -6
  22. package/dist/types/src/components/EditorPreviewProvider/EditorPreviewProvider.d.ts.map +1 -1
  23. package/dist/types/src/components/EditorPreviewProvider/index.d.ts +1 -0
  24. package/dist/types/src/components/EditorPreviewProvider/index.d.ts.map +1 -1
  25. package/dist/types/src/components/EditorToolbar/EditorToolbar.d.ts +4 -1
  26. package/dist/types/src/components/EditorToolbar/EditorToolbar.d.ts.map +1 -1
  27. package/dist/types/src/components/EditorToolbar/index.d.ts +1 -0
  28. package/dist/types/src/components/EditorToolbar/index.d.ts.map +1 -1
  29. package/dist/types/src/components/EditorToolbar/lists.d.ts.map +1 -1
  30. package/dist/types/src/components/EditorToolbar/view-mode.d.ts +27 -2
  31. package/dist/types/src/components/EditorToolbar/view-mode.d.ts.map +1 -1
  32. package/dist/types/src/components/index.d.ts +1 -0
  33. package/dist/types/src/components/index.d.ts.map +1 -1
  34. package/dist/types/src/index.d.ts +0 -1
  35. package/dist/types/src/index.d.ts.map +1 -1
  36. package/dist/types/src/stories/Assistant.stories.d.ts.map +1 -0
  37. package/dist/types/src/stories/Automerge.stories.d.ts +16 -39
  38. package/dist/types/src/stories/Automerge.stories.d.ts.map +1 -1
  39. package/dist/types/src/stories/Blocks.stories.d.ts +10 -0
  40. package/dist/types/src/stories/Blocks.stories.d.ts.map +1 -0
  41. package/dist/types/src/stories/Comments.stories.d.ts +53 -14
  42. package/dist/types/src/stories/Comments.stories.d.ts.map +1 -1
  43. package/dist/types/src/stories/Diff.stories.d.ts +32 -0
  44. package/dist/types/src/stories/Diff.stories.d.ts.map +1 -0
  45. package/dist/types/src/stories/Experimental.stories.d.ts +1 -0
  46. package/dist/types/src/stories/Experimental.stories.d.ts.map +1 -1
  47. package/dist/types/src/stories/Folding.stories.d.ts +18 -0
  48. package/dist/types/src/stories/Folding.stories.d.ts.map +1 -0
  49. package/dist/types/src/stories/Outliner.stories.d.ts +4 -4
  50. package/dist/types/src/stories/Outliner.stories.d.ts.map +1 -1
  51. package/dist/types/src/stories/Popover.stories.d.ts +3 -3
  52. package/dist/types/src/stories/Popover.stories.d.ts.map +1 -1
  53. package/dist/types/src/stories/Suggest.stories.d.ts +27 -0
  54. package/dist/types/src/stories/Suggest.stories.d.ts.map +1 -0
  55. package/dist/types/src/stories/TrackChanges.stories.d.ts +44 -0
  56. package/dist/types/src/stories/TrackChanges.stories.d.ts.map +1 -0
  57. package/dist/types/src/stories/Widgets.stories.d.ts +12 -0
  58. package/dist/types/src/stories/Widgets.stories.d.ts.map +1 -1
  59. package/dist/types/src/stories/components/EditorStory.d.ts.map +1 -1
  60. package/dist/types/src/stories/components/util.d.ts +16 -0
  61. package/dist/types/src/stories/components/util.d.ts.map +1 -1
  62. package/dist/types/tsconfig.tsbuildinfo +1 -1
  63. package/package.json +40 -83
  64. package/src/components/Editor/Editor.tsx +142 -35
  65. package/src/components/Editor/EditorContext.ts +34 -0
  66. package/src/components/Editor/EditorView.tsx +9 -6
  67. package/src/components/Editor/index.ts +3 -0
  68. package/src/components/EditorMenuProvider/EditorMenuProvider.tsx +98 -8
  69. package/src/components/EditorMenuProvider/menu.ts +4 -2
  70. package/src/components/EditorMenuProvider/useEditorMenu.ts +114 -29
  71. package/src/components/EditorPreviewProvider/EditorPreviewContext.ts +21 -0
  72. package/src/components/EditorPreviewProvider/EditorPreviewProvider.tsx +1 -10
  73. package/src/components/EditorPreviewProvider/index.ts +2 -0
  74. package/src/components/EditorToolbar/EditorToolbar.tsx +6 -3
  75. package/src/components/EditorToolbar/index.ts +1 -0
  76. package/src/components/EditorToolbar/lists.ts +2 -6
  77. package/src/components/EditorToolbar/view-mode.ts +41 -12
  78. package/src/components/index.ts +1 -0
  79. package/src/index.ts +0 -1
  80. package/src/{extensions → stories}/Assistant.stories.tsx +7 -2
  81. package/src/stories/Automerge.stories.tsx +99 -78
  82. package/src/stories/Blocks.stories.tsx +83 -0
  83. package/src/stories/Comments.stories.tsx +273 -74
  84. package/src/stories/Diff.stories.tsx +48 -0
  85. package/src/stories/EditorToolbar.stories.tsx +2 -2
  86. package/src/stories/Experimental.stories.tsx +13 -2
  87. package/src/stories/Folding.stories.tsx +120 -0
  88. package/src/stories/Markdown.stories.tsx +1 -1
  89. package/src/stories/Outliner.stories.tsx +18 -7
  90. package/src/stories/Popover.stories.tsx +27 -5
  91. package/src/stories/Suggest.stories.tsx +199 -0
  92. package/src/stories/TrackChanges.stories.tsx +226 -0
  93. package/src/stories/Widgets.stories.tsx +162 -10
  94. package/src/stories/components/EditorStory.tsx +15 -6
  95. package/src/stories/components/util.tsx +40 -2
  96. package/dist/lib/browser/index.mjs +0 -1676
  97. package/dist/lib/browser/index.mjs.map +0 -7
  98. package/dist/lib/browser/meta.json +0 -1
  99. package/dist/lib/browser/translations.mjs +0 -39
  100. package/dist/lib/browser/translations.mjs.map +0 -7
  101. package/dist/lib/node-esm/index.mjs +0 -1678
  102. package/dist/lib/node-esm/index.mjs.map +0 -7
  103. package/dist/lib/node-esm/meta.json +0 -1
  104. package/dist/lib/node-esm/translations.mjs +0 -41
  105. package/dist/lib/node-esm/translations.mjs.map +0 -7
  106. package/dist/types/src/extensions/Assistant.stories.d.ts.map +0 -1
  107. package/dist/types/src/extensions/assistant-extension.d.ts +0 -24
  108. package/dist/types/src/extensions/assistant-extension.d.ts.map +0 -1
  109. package/dist/types/src/extensions/index.d.ts +0 -2
  110. package/dist/types/src/extensions/index.d.ts.map +0 -1
  111. package/src/extensions/assistant-extension.tsx +0 -223
  112. package/src/extensions/index.ts +0 -5
  113. /package/dist/types/src/{extensions → stories}/Assistant.stories.d.ts +0 -0
@@ -18,6 +18,7 @@ import {
18
18
  type EditorMenuGroup,
19
19
  type EditorMenuItem,
20
20
  EditorMenuProvider,
21
+ type EditorMenuProviderProps,
21
22
  type UseEditorMenuProps,
22
23
  createMenuGroup,
23
24
  filterMenuGroups,
@@ -43,15 +44,21 @@ const placeholder = (trigger: string[]) => {
43
44
  return Domino.of('div').append(pressEl, ...triggerEls, forCommandsEl).root;
44
45
  };
45
46
 
46
- type StoryArgs = Omit<UseEditorMenuProps, 'viewRef'> & { text: string };
47
+ type StoryArgs = Omit<UseEditorMenuProps, 'viewRef'> &
48
+ Pick<EditorMenuProviderProps, 'searchPlaceholder'> & { text: string };
47
49
 
48
- const DefaultStory = ({ text, ...props }: StoryArgs) => {
50
+ const DefaultStory = ({ text, searchPlaceholder, ...props }: StoryArgs) => {
49
51
  const [controller, setController] = useState<EditorController | null>(null);
50
52
  const { groupsRef, extension, ...menuProps } = useEditorMenu(props);
51
53
  const getView = useCallback(() => controller?.view ?? null, [controller]);
52
54
 
53
55
  return (
54
- <EditorMenuProvider getView={getView} groups={groupsRef.current} {...menuProps}>
56
+ <EditorMenuProvider
57
+ getView={getView}
58
+ groups={groupsRef.current}
59
+ searchPlaceholder={searchPlaceholder}
60
+ {...menuProps}
61
+ >
55
62
  <EditorStory ref={setController} text={text} extensions={extension} />
56
63
  </EditorMenuProvider>
57
64
  );
@@ -76,6 +83,7 @@ const LinkStory = (args: StoryArgs) => {
76
83
  const result = await space?.db.query(Query.type(TestSchema.Person)).run();
77
84
  const items = result
78
85
  .filter((object) => object.name.toLowerCase().includes(name))
86
+ .sort((a, b) => a.name.localeCompare(b.name))
79
87
  .map(
80
88
  (object): EditorMenuItem => ({
81
89
  id: object.id,
@@ -92,7 +100,18 @@ const LinkStory = (args: StoryArgs) => {
92
100
  }),
93
101
  );
94
102
 
95
- return [{ id: 'test', items }];
103
+ // Mirrors the plugin's picker, where this opens the app's create-object dialog.
104
+ const createItem: EditorMenuItem = {
105
+ id: 'create-object',
106
+ label: 'Add object',
107
+ icon: 'ph--plus--regular',
108
+ onSelect: ({ view, head }) => insertAtCursor(view, head, `[${name || 'New object'}](dxn:echo:@:new) `),
109
+ };
110
+
111
+ return [
112
+ { id: 'create', items: [createItem] },
113
+ { id: 'test', items },
114
+ ];
96
115
  },
97
116
  [space],
98
117
  );
@@ -143,7 +162,7 @@ export const Link: Story = {
143
162
  },
144
163
  onCreateSpace: async ({ space }) => {
145
164
  const createObjects = createObjectFactory(space.db, generator);
146
- await createObjects([{ type: TestSchema.Person, count: 10 }]);
165
+ await createObjects([{ type: TestSchema.Person, count: 50 }]);
147
166
  await space.db.flush({ indexes: true });
148
167
  },
149
168
  }),
@@ -151,6 +170,9 @@ export const Link: Story = {
151
170
  args: {
152
171
  text: join('# Links', '', ''),
153
172
  trigger: ['/', '@'],
173
+ // The "@" picker is a combobox: the query is typed into the popover, not the document.
174
+ searchTriggers: ['@'],
175
+ searchPlaceholder: 'Search or create…',
154
176
  placeholder: {
155
177
  content: () => placeholder(['/', '@']),
156
178
  },
@@ -0,0 +1,199 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+ import React, { useMemo } from 'react';
7
+ import { expect, userEvent, waitFor } from 'storybook/test';
8
+
9
+ import { createObject } from '@dxos/echo-client';
10
+ import { Doc } from '@dxos/echo-doc';
11
+ import { useThemeContext } from '@dxos/react-ui';
12
+ import { withAttention } from '@dxos/react-ui-attention/testing';
13
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
14
+ import { Text } from '@dxos/schema';
15
+ import {
16
+ automerge,
17
+ createBasicExtensions,
18
+ createMarkdownExtensions,
19
+ createThemeExtensions,
20
+ suggestChanges,
21
+ suggestions,
22
+ } from '@dxos/ui-editor';
23
+
24
+ import { Editor, type EditorViewProps } from '../components';
25
+
26
+ // The parent (main) content and a branch proposal that rewrites three words — three independent,
27
+ // reviewable changes (like a branch's edits against a style guide).
28
+ const ORIGINAL = 'The quick brown fox jumps over the lazy dog.';
29
+ const PROPOSAL = 'The fast brown fox leaps over the sleepy dog.';
30
+
31
+ /**
32
+ * Simulate hovering a change to surface its tooltip-layer controls. CodeMirror's `hoverTooltip` reads
33
+ * pointer coordinates from a bubbling `mousemove`, which `userEvent.hover` does not reliably provide, so
34
+ * dispatch one at the element's centre.
35
+ */
36
+ const hoverChange = (element: HTMLElement): void => {
37
+ const rect = element.getBoundingClientRect();
38
+ element.dispatchEvent(
39
+ new MouseEvent('mousemove', {
40
+ bubbles: true,
41
+ clientX: rect.left + rect.width / 2,
42
+ clientY: rect.top + rect.height / 2,
43
+ }),
44
+ );
45
+ };
46
+
47
+ /** The document text (struck-through deletions included), excluding the proposal preview widgets. */
48
+ const documentText = (canvasElement: HTMLElement): string => {
49
+ const content = canvasElement.querySelector('.cm-content');
50
+ if (!content) {
51
+ return '';
52
+ }
53
+ const clone = content.cloneNode(true) as HTMLElement;
54
+ clone.querySelectorAll('.cm-suggest-actions').forEach((node) => node.remove());
55
+ return clone.textContent ?? '';
56
+ };
57
+
58
+ const Render = (args: EditorViewProps) => {
59
+ const { themeMode } = useThemeContext();
60
+ const extensions = useMemo(
61
+ () => [
62
+ createBasicExtensions(),
63
+ createThemeExtensions({ themeMode }),
64
+ createMarkdownExtensions(),
65
+ // The editor is bound to the parent (ORIGINAL); the branch is the proposal.
66
+ automerge(Doc.createAccessor(createObject(Text.make({ content: ORIGINAL })), ['content'])),
67
+ suggestChanges({ proposal: PROPOSAL }),
68
+ ],
69
+ [themeMode],
70
+ );
71
+
72
+ return (
73
+ <Editor.Root>
74
+ <Editor.View {...args} extensions={extensions} />
75
+ </Editor.Root>
76
+ );
77
+ };
78
+
79
+ // Two reviewers proposing over the same base: Alice and Bob both rewrite "quick" (an overlap), and
80
+ // each has one further, non-overlapping change — a multi-author suggestion overlay.
81
+ const ALICE = 'The fast brown fox jumps over the sleepy dog.';
82
+ const BOB = 'The swift brown fox leaps over the lazy dog.';
83
+
84
+ const MultiAuthorRender = (args: EditorViewProps) => {
85
+ const { themeMode } = useThemeContext();
86
+ const extensions = useMemo(
87
+ () => [
88
+ createBasicExtensions(),
89
+ createThemeExtensions({ themeMode }),
90
+ createMarkdownExtensions(),
91
+ automerge(Doc.createAccessor(createObject(Text.make({ content: ORIGINAL })), ['content'])),
92
+ // Author colours come from the shared hue palette (the same `--color-<hue>-text` tokens used
93
+ // for a user's avatar/tag), so a suggestion reads with its author's consistent colour.
94
+ suggestions({
95
+ sources: [
96
+ { author: 'did:alice', colour: 'var(--color-lime-text)', content: ALICE },
97
+ { author: 'did:bob', colour: 'var(--color-violet-text)', content: BOB },
98
+ ],
99
+ }),
100
+ ],
101
+ [themeMode],
102
+ );
103
+
104
+ return (
105
+ <Editor.Root>
106
+ <Editor.View {...args} extensions={extensions} />
107
+ </Editor.Root>
108
+ );
109
+ };
110
+
111
+ const meta = {
112
+ title: 'ui/react-ui-editor/Suggest',
113
+ render: Render,
114
+ decorators: [withTheme(), withLayout({ layout: 'column' }), withAttention()],
115
+ parameters: {
116
+ layout: 'fullscreen',
117
+ },
118
+ } satisfies Meta<typeof Render>;
119
+
120
+ export default meta;
121
+
122
+ type Story = StoryObj<typeof meta>;
123
+
124
+ /** The branch's changes rendered over the parent as accept/reject suggestions. */
125
+ export const Default: Story = {};
126
+
127
+ /**
128
+ * Accept applies a change to the document (merging it into the parent); Reject hides a change without
129
+ * altering the document. Deterministic — no AI, so it runs in CI.
130
+ */
131
+ export const AcceptReject: Story = {
132
+ play: async ({ canvasElement }) => {
133
+ // Each change carries a strikethrough of the replaced word; its accept/reject controls live in the
134
+ // (non-clipped) tooltip layer and surface on hover.
135
+ const deletes = () => Array.from(canvasElement.querySelectorAll<HTMLElement>('.cm-suggest-delete'));
136
+ const inserts = () => canvasElement.querySelectorAll<HTMLElement>('.cm-suggest-insert');
137
+ const acceptButton = () => canvasElement.querySelector<HTMLElement>('.cm-tooltip .cm-suggest-accept');
138
+ const rejectButton = () => canvasElement.querySelector<HTMLElement>('.cm-tooltip .cm-suggest-reject');
139
+
140
+ // Three suggestions (quick→fast, jumps→leaps, lazy→sleepy) over the original document.
141
+ await waitFor(() => expect(documentText(canvasElement)).toContain('quick'), { timeout: 15_000 });
142
+ await waitFor(() => expect(inserts()).toHaveLength(3));
143
+
144
+ // Hover the first change: its controls mount in the tooltip layer (a `.cm-tooltip`, not clipped by
145
+ // the scroller). Accept applies that change to the document.
146
+ hoverChange(deletes()[0]);
147
+ await waitFor(() => expect(acceptButton()).not.toBeNull());
148
+ await userEvent.click(acceptButton()!);
149
+ await waitFor(() => expect(documentText(canvasElement)).toContain('fast'));
150
+ await waitFor(() => expect(documentText(canvasElement)).not.toContain('quick'));
151
+ await waitFor(() => expect(inserts()).toHaveLength(2));
152
+
153
+ // Reject the next change: the suggestion disappears, but the document keeps the original.
154
+ const before = documentText(canvasElement);
155
+ hoverChange(deletes()[0]);
156
+ await waitFor(() => expect(rejectButton()).not.toBeNull());
157
+ await userEvent.click(rejectButton()!);
158
+ await waitFor(() => expect(inserts()).toHaveLength(1));
159
+ await waitFor(() => expect(documentText(canvasElement)).toBe(before));
160
+ },
161
+ };
162
+
163
+ /**
164
+ * Two authors reviewing the same base, overlapping on one word. Each change is independently
165
+ * attributable and accepting one re-diffs the rest (the overlapping author's change persists).
166
+ * Deterministic — no AI, so it runs in CI.
167
+ */
168
+ export const MultipleAuthors: Story = {
169
+ render: MultiAuthorRender,
170
+ play: async ({ canvasElement }) => {
171
+ const deletes = () => Array.from(canvasElement.querySelectorAll<HTMLElement>('.cm-suggest-delete'));
172
+ const acceptButton = () => canvasElement.querySelector<HTMLElement>('.cm-tooltip .cm-suggest-accept');
173
+ const insertText = () =>
174
+ Array.from(canvasElement.querySelectorAll<HTMLElement>('.cm-suggest-insert')).map((node) => node.textContent);
175
+
176
+ const insertColours = () =>
177
+ Array.from(canvasElement.querySelectorAll<HTMLElement>('.cm-suggest-insert')).map((node) => node.style.color);
178
+
179
+ // Four suggestions total: Alice{fast, sleepy} + Bob{swift, leaps}; "fast"/"swift" overlap on "quick".
180
+ await waitFor(() => expect(documentText(canvasElement)).toContain('quick'), { timeout: 15_000 });
181
+ await waitFor(() => expect(insertText()).toEqual(expect.arrayContaining(['fast', 'swift', 'sleepy', 'leaps'])));
182
+
183
+ // Each author's inline markers carry that author's palette colour (Alice lime, Bob violet).
184
+ await waitFor(() => expect(insertColours().some((colour) => colour.includes('lime'))).toBe(true));
185
+ await waitFor(() => expect(insertColours().some((colour) => colour.includes('violet'))).toBe(true));
186
+
187
+ // Hover the overlapping "quick" change; offset-then-author order surfaces Alice's "quick"→"fast"
188
+ // first. Accepting it makes the base take "fast"; Bob's "quick"→"swift" re-diffs and remains.
189
+ const quick = deletes().find((node) => node.textContent?.includes('quick'));
190
+ void expect(quick).toBeDefined();
191
+ hoverChange(quick!);
192
+ await waitFor(() => expect(acceptButton()).not.toBeNull());
193
+ await userEvent.click(acceptButton()!);
194
+ await waitFor(() => expect(documentText(canvasElement)).toContain('fast'));
195
+ await waitFor(() => expect(documentText(canvasElement)).not.toContain('quick'));
196
+ await waitFor(() => expect(insertText()).toEqual(expect.arrayContaining(['swift'])));
197
+ await waitFor(() => expect(insertText()).toHaveLength(3));
198
+ },
199
+ };
@@ -0,0 +1,226 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+ import React, { useMemo } from 'react';
7
+ import { expect, userEvent, waitFor } from 'storybook/test';
8
+
9
+ import { useThemeContext } from '@dxos/react-ui';
10
+ import { withAttention } from '@dxos/react-ui-attention/testing';
11
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
12
+ import {
13
+ createBasicExtensions,
14
+ createMarkdownExtensions,
15
+ createThemeExtensions,
16
+ documentSlots,
17
+ suggestions,
18
+ trackChanges,
19
+ } from '@dxos/ui-editor';
20
+
21
+ import { Editor, type EditorViewProps } from '../components';
22
+
23
+ // The accepted base (main). The editor is bound to a *branch* whose live edits are tracked against it.
24
+ const MAIN = 'The quick brown fox jumps over the lazy dog.';
25
+
26
+ // A second author's proposal against main ("lazy" -> "sleepy"). Milestone B decouples the diff base
27
+ // from the editor document, so this overlay is diffed vs MAIN and rebased into the (diverged) branch's
28
+ // coordinates — it no longer strikes the tester's own new text (the collision the old overlay had).
29
+ const BOB = 'The quick brown fox jumps over the sleepy dog.';
30
+
31
+ const SELF_COLOUR = 'var(--color-cyan-text)';
32
+ const BOB_COLOUR = 'var(--color-violet-text)';
33
+
34
+ /** The document text with phantom deletions folded in, excluding the foreign overlay's controls. */
35
+ const documentText = (canvasElement: HTMLElement): string => {
36
+ const content = canvasElement.querySelector('.cm-content');
37
+ if (!content) {
38
+ return '';
39
+ }
40
+ const clone = content.cloneNode(true) as HTMLElement;
41
+ clone.querySelectorAll('.cm-suggest-actions').forEach((node) => node.remove());
42
+ return clone.textContent ?? '';
43
+ };
44
+
45
+ type RenderProps = EditorViewProps & { branch: string };
46
+
47
+ const Render = ({ branch, ...args }: RenderProps) => {
48
+ const { themeMode } = useThemeContext();
49
+ const extensions = useMemo(
50
+ () => [
51
+ createBasicExtensions(),
52
+ createThemeExtensions({ themeMode, slots: documentSlots }),
53
+ createMarkdownExtensions(),
54
+ // The branch's own edits, diffed against MAIN and shown as tracked changes (self only).
55
+ trackChanges({ main: MAIN, colour: SELF_COLOUR }),
56
+ ],
57
+ [themeMode],
58
+ );
59
+
60
+ return (
61
+ <Editor.Root>
62
+ <Editor.View {...args} value={branch} extensions={extensions} />
63
+ </Editor.Root>
64
+ );
65
+ };
66
+
67
+ /**
68
+ * The tester's own live edits (self, via {@link trackChanges}) composed with a foreign author's
69
+ * proposal (via {@link suggestions} with an explicit `base`). Because the foreign overlay is diffed
70
+ * against MAIN — not the editor document — the tester's own typing is never mistaken for text the
71
+ * foreign author would remove.
72
+ */
73
+ const ForeignAuthorRender = ({ branch, ...args }: RenderProps) => {
74
+ const { themeMode } = useThemeContext();
75
+ const extensions = useMemo(
76
+ () => [
77
+ createBasicExtensions(),
78
+ createThemeExtensions({ themeMode, slots: documentSlots }),
79
+ createMarkdownExtensions(),
80
+ trackChanges({ main: MAIN, colour: SELF_COLOUR }),
81
+ // The foreign author is diffed against MAIN and rebased into the branch's coordinates.
82
+ suggestions({ base: MAIN, sources: [{ author: 'did:bob', colour: BOB_COLOUR, content: BOB }] }),
83
+ ],
84
+ [themeMode],
85
+ );
86
+
87
+ return (
88
+ <Editor.Root>
89
+ <Editor.View {...args} value={branch} extensions={extensions} />
90
+ </Editor.Root>
91
+ );
92
+ };
93
+
94
+ const meta = {
95
+ title: 'ui/react-ui-editor/TrackChanges',
96
+ render: Render,
97
+ decorators: [withTheme(), withLayout({ layout: 'column', classNames: 'py-8' }), withAttention()],
98
+ parameters: {
99
+ layout: 'fullscreen',
100
+ },
101
+ } satisfies Meta<typeof Render>;
102
+
103
+ export default meta;
104
+
105
+ type Story = StoryObj<typeof meta>;
106
+
107
+ /**
108
+ * Interactive "Suggesting mode" (bind-to-branch): the editor is bound to a branch that starts equal to
109
+ * MAIN, so it opens clean. Type to feel the tracked-change ergonomics:
110
+ *
111
+ * - Type — the added characters appear as an underlined, cyan insertion (character-level, so editing
112
+ * inside a word marks only the changed characters, not the whole word).
113
+ * - Select text and delete it — the removed characters stay visible as a struck-through phantom
114
+ * (`~~old~~`), because they are absent from the branch but present in main.
115
+ * - Replace text — you get both at once, `~~old~~new`, at character granularity.
116
+ * - Move the caret across a phantom with the arrow keys — it is stepped over as one unit.
117
+ * - Press Backspace with the caret just after a phantom — the real branch text behind it is protected
118
+ * (the keystroke is swallowed rather than eating the hidden character).
119
+ */
120
+ export const Default: Story = {
121
+ args: { branch: MAIN },
122
+ };
123
+
124
+ /**
125
+ * A branch that has already diverged from main, so tracked changes render on mount without typing:
126
+ * `swiftly ` inserted after "jumps" and `lazy ` deleted — both clean whole-word edits so the
127
+ * character-level diff yields contiguous hunks. Deterministic — used for the render smoke test.
128
+ */
129
+ export const Divergent: Story = {
130
+ args: { branch: 'The quick brown fox jumps swiftly over the dog.' },
131
+ play: async ({ canvasElement }) => {
132
+ await waitFor(() => expect(canvasElement.querySelector('.cm-content')).not.toBeNull(), { timeout: 15_000 });
133
+
134
+ // Insertion mark: the branch's added text.
135
+ const inserts = () => Array.from(canvasElement.querySelectorAll<HTMLElement>('.cm-track-insert'));
136
+ await waitFor(() =>
137
+ expect(
138
+ inserts()
139
+ .map((node) => node.textContent)
140
+ .join(''),
141
+ ).toContain('swiftly'),
142
+ );
143
+
144
+ // Phantom deletion: base text absent from the branch, still visible struck through.
145
+ const deletes = () => Array.from(canvasElement.querySelectorAll<HTMLElement>('.cm-track-delete'));
146
+ await waitFor(() => expect(deletes().length).toBeGreaterThan(0));
147
+ const deletedText = () =>
148
+ deletes()
149
+ .map((node) => node.textContent)
150
+ .join('');
151
+ await waitFor(() => expect(deletedText()).toContain('lazy'));
152
+
153
+ // The phantom text is not part of the document (it is a widget, not doc content).
154
+ await waitFor(() => expect(documentText(canvasElement)).toContain('swiftly'));
155
+ },
156
+ };
157
+
158
+ /**
159
+ * A foreign author's proposal (Bob: "lazy"→"sleepy") layered over the tester's own live edits. The
160
+ * overlay is diffed against MAIN (not the branch), so after the tester types, their new text is tracked
161
+ * as a self-insertion and is NOT struck by Bob's overlay, while Bob's change still renders. This is the
162
+ * multi-author case the old (diff-vs-document) overlay could not support. Deterministic — runs in CI.
163
+ */
164
+ export const WithForeignAuthor: Story = {
165
+ render: ForeignAuthorRender,
166
+ args: { branch: MAIN },
167
+ play: async ({ canvasElement }) => {
168
+ const content = await waitFor(
169
+ () => {
170
+ const node = canvasElement.querySelector<HTMLElement>('.cm-content');
171
+ void expect(node).not.toBeNull();
172
+ return node!;
173
+ },
174
+ { timeout: 15_000 },
175
+ );
176
+
177
+ // Bob's proposal renders on mount: "lazy" struck through, "sleepy" previewed.
178
+ const suggestDeletes = () => Array.from(canvasElement.querySelectorAll<HTMLElement>('.cm-suggest-delete'));
179
+ const suggestInserts = () => Array.from(canvasElement.querySelectorAll<HTMLElement>('.cm-suggest-insert'));
180
+ await waitFor(() =>
181
+ expect(
182
+ suggestDeletes()
183
+ .map((node) => node.textContent)
184
+ .join(' '),
185
+ ).toContain('lazy'),
186
+ );
187
+ await waitFor(() =>
188
+ expect(
189
+ suggestInserts()
190
+ .map((node) => node.textContent)
191
+ .join(' '),
192
+ ).toContain('sleepy'),
193
+ );
194
+
195
+ // The tester types at the start of the branch (a region Bob did not touch).
196
+ content.focus();
197
+ await userEvent.keyboard('really ');
198
+
199
+ // The typed text is tracked as the tester's own insertion (self overlay), not attributed to Bob.
200
+ const trackInserts = () => Array.from(canvasElement.querySelectorAll<HTMLElement>('.cm-track-insert'));
201
+ await waitFor(() =>
202
+ expect(
203
+ trackInserts()
204
+ .map((node) => node.textContent)
205
+ .join(''),
206
+ ).toContain('really'),
207
+ );
208
+
209
+ // Critically, Bob's overlay does NOT strike the tester's new text — only "lazy" stays struck.
210
+ await waitFor(() => {
211
+ const struck = suggestDeletes()
212
+ .map((node) => node.textContent)
213
+ .join(' ');
214
+ void expect(struck).toContain('lazy');
215
+ void expect(struck).not.toContain('really');
216
+ });
217
+ // And Bob's proposal still renders after the divergence.
218
+ await waitFor(() =>
219
+ expect(
220
+ suggestInserts()
221
+ .map((node) => node.textContent)
222
+ .join(' '),
223
+ ).toContain('sleepy'),
224
+ );
225
+ },
226
+ };