@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.
- package/dist/lib/index.mjs +1477 -0
- package/dist/lib/index.mjs.map +1 -0
- package/dist/lib/translations.mjs +32 -0
- package/dist/lib/translations.mjs.map +1 -0
- package/dist/types/src/components/Editor/Editor.d.ts +22 -17
- package/dist/types/src/components/Editor/Editor.d.ts.map +1 -1
- package/dist/types/src/components/Editor/EditorContext.d.ts +20 -0
- package/dist/types/src/components/Editor/EditorContext.d.ts.map +1 -0
- package/dist/types/src/components/Editor/EditorView.d.ts +1 -2
- package/dist/types/src/components/Editor/EditorView.d.ts.map +1 -1
- package/dist/types/src/components/Editor/index.d.ts +2 -0
- package/dist/types/src/components/Editor/index.d.ts.map +1 -1
- package/dist/types/src/components/EditorMenuProvider/EditorMenuProvider.d.ts +7 -1
- package/dist/types/src/components/EditorMenuProvider/EditorMenuProvider.d.ts.map +1 -1
- package/dist/types/src/components/EditorMenuProvider/menu.d.ts +4 -2
- package/dist/types/src/components/EditorMenuProvider/menu.d.ts.map +1 -1
- package/dist/types/src/components/EditorMenuProvider/useEditorMenu.d.ts +7 -2
- package/dist/types/src/components/EditorMenuProvider/useEditorMenu.d.ts.map +1 -1
- package/dist/types/src/components/EditorPreviewProvider/EditorPreviewContext.d.ts +18 -0
- package/dist/types/src/components/EditorPreviewProvider/EditorPreviewContext.d.ts.map +1 -0
- package/dist/types/src/components/EditorPreviewProvider/EditorPreviewProvider.d.ts +0 -6
- package/dist/types/src/components/EditorPreviewProvider/EditorPreviewProvider.d.ts.map +1 -1
- package/dist/types/src/components/EditorPreviewProvider/index.d.ts +1 -0
- package/dist/types/src/components/EditorPreviewProvider/index.d.ts.map +1 -1
- package/dist/types/src/components/EditorToolbar/EditorToolbar.d.ts +4 -1
- package/dist/types/src/components/EditorToolbar/EditorToolbar.d.ts.map +1 -1
- package/dist/types/src/components/EditorToolbar/index.d.ts +1 -0
- package/dist/types/src/components/EditorToolbar/index.d.ts.map +1 -1
- package/dist/types/src/components/EditorToolbar/lists.d.ts.map +1 -1
- package/dist/types/src/components/EditorToolbar/view-mode.d.ts +27 -2
- package/dist/types/src/components/EditorToolbar/view-mode.d.ts.map +1 -1
- package/dist/types/src/components/index.d.ts +1 -0
- package/dist/types/src/components/index.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +0 -1
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/stories/Assistant.stories.d.ts.map +1 -0
- package/dist/types/src/stories/Automerge.stories.d.ts +16 -39
- package/dist/types/src/stories/Automerge.stories.d.ts.map +1 -1
- package/dist/types/src/stories/Blocks.stories.d.ts +10 -0
- package/dist/types/src/stories/Blocks.stories.d.ts.map +1 -0
- package/dist/types/src/stories/Comments.stories.d.ts +53 -14
- package/dist/types/src/stories/Comments.stories.d.ts.map +1 -1
- package/dist/types/src/stories/Diff.stories.d.ts +32 -0
- package/dist/types/src/stories/Diff.stories.d.ts.map +1 -0
- package/dist/types/src/stories/Experimental.stories.d.ts +1 -0
- package/dist/types/src/stories/Experimental.stories.d.ts.map +1 -1
- package/dist/types/src/stories/Folding.stories.d.ts +18 -0
- package/dist/types/src/stories/Folding.stories.d.ts.map +1 -0
- package/dist/types/src/stories/Outliner.stories.d.ts +4 -4
- package/dist/types/src/stories/Outliner.stories.d.ts.map +1 -1
- package/dist/types/src/stories/Popover.stories.d.ts +3 -3
- package/dist/types/src/stories/Popover.stories.d.ts.map +1 -1
- package/dist/types/src/stories/Suggest.stories.d.ts +27 -0
- package/dist/types/src/stories/Suggest.stories.d.ts.map +1 -0
- package/dist/types/src/stories/TrackChanges.stories.d.ts +44 -0
- package/dist/types/src/stories/TrackChanges.stories.d.ts.map +1 -0
- package/dist/types/src/stories/Widgets.stories.d.ts +12 -0
- package/dist/types/src/stories/Widgets.stories.d.ts.map +1 -1
- package/dist/types/src/stories/components/EditorStory.d.ts.map +1 -1
- package/dist/types/src/stories/components/util.d.ts +16 -0
- package/dist/types/src/stories/components/util.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +40 -83
- package/src/components/Editor/Editor.tsx +142 -35
- package/src/components/Editor/EditorContext.ts +34 -0
- package/src/components/Editor/EditorView.tsx +9 -6
- package/src/components/Editor/index.ts +3 -0
- package/src/components/EditorMenuProvider/EditorMenuProvider.tsx +98 -8
- package/src/components/EditorMenuProvider/menu.ts +4 -2
- package/src/components/EditorMenuProvider/useEditorMenu.ts +114 -29
- package/src/components/EditorPreviewProvider/EditorPreviewContext.ts +21 -0
- package/src/components/EditorPreviewProvider/EditorPreviewProvider.tsx +1 -10
- package/src/components/EditorPreviewProvider/index.ts +2 -0
- package/src/components/EditorToolbar/EditorToolbar.tsx +6 -3
- package/src/components/EditorToolbar/index.ts +1 -0
- package/src/components/EditorToolbar/lists.ts +2 -6
- package/src/components/EditorToolbar/view-mode.ts +41 -12
- package/src/components/index.ts +1 -0
- package/src/index.ts +0 -1
- package/src/{extensions → stories}/Assistant.stories.tsx +7 -2
- package/src/stories/Automerge.stories.tsx +99 -78
- package/src/stories/Blocks.stories.tsx +83 -0
- package/src/stories/Comments.stories.tsx +273 -74
- package/src/stories/Diff.stories.tsx +48 -0
- package/src/stories/EditorToolbar.stories.tsx +2 -2
- package/src/stories/Experimental.stories.tsx +13 -2
- package/src/stories/Folding.stories.tsx +120 -0
- package/src/stories/Markdown.stories.tsx +1 -1
- package/src/stories/Outliner.stories.tsx +18 -7
- package/src/stories/Popover.stories.tsx +27 -5
- package/src/stories/Suggest.stories.tsx +199 -0
- package/src/stories/TrackChanges.stories.tsx +226 -0
- package/src/stories/Widgets.stories.tsx +162 -10
- package/src/stories/components/EditorStory.tsx +15 -6
- package/src/stories/components/util.tsx +40 -2
- package/dist/lib/browser/index.mjs +0 -1676
- package/dist/lib/browser/index.mjs.map +0 -7
- package/dist/lib/browser/meta.json +0 -1
- package/dist/lib/browser/translations.mjs +0 -39
- package/dist/lib/browser/translations.mjs.map +0 -7
- package/dist/lib/node-esm/index.mjs +0 -1678
- package/dist/lib/node-esm/index.mjs.map +0 -7
- package/dist/lib/node-esm/meta.json +0 -1
- package/dist/lib/node-esm/translations.mjs +0 -41
- package/dist/lib/node-esm/translations.mjs.map +0 -7
- package/dist/types/src/extensions/Assistant.stories.d.ts.map +0 -1
- package/dist/types/src/extensions/assistant-extension.d.ts +0 -24
- package/dist/types/src/extensions/assistant-extension.d.ts.map +0 -1
- package/dist/types/src/extensions/index.d.ts +0 -2
- package/dist/types/src/extensions/index.d.ts.map +0 -1
- package/src/extensions/assistant-extension.tsx +0 -223
- package/src/extensions/index.ts +0 -5
- /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'> &
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
+
};
|