Skip to content

Commit 0d99725

Browse files
authored
Merge pull request #4945 from udecode/codex/simplify-discussion-ui
[codex] Fix discussion and suggestion regressions
2 parents 5f6a324 + 59e0883 commit 0d99725

83 files changed

Lines changed: 4598 additions & 704 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.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@platejs/ai": patch
3+
---
4+
5+
Clear block streaming state when `aiChat.stop()` stops generation
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@platejs/link": patch
3+
---
4+
5+
Fix empty link normalization when suggestion acceptance removes the last link character
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@platejs/suggestion": patch
3+
---
4+
5+
Fix inline-void delete and replace suggestions around mentions and paragraph boundaries
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@platejs/utils": patch
3+
---
4+
5+
Add a trailing-block insert hook for normalization-driven insert behavior
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/** @jsx jsxt */
2+
3+
import {
4+
acceptSuggestion,
5+
BaseSuggestionPlugin,
6+
getSuggestionKey,
7+
rejectSuggestion,
8+
} from '@platejs/suggestion';
9+
import { jsxt } from '@platejs/test-utils';
10+
import type { SlateEditor } from 'platejs';
11+
import { createSlateEditor } from 'platejs';
12+
13+
import { BaseEditorKit } from '@/registry/components/editor/editor-base-kit';
14+
15+
jsxt;
16+
17+
const createEditor = (input: SlateEditor) =>
18+
createSlateEditor({
19+
plugins: BaseEditorKit,
20+
selection: input.selection,
21+
value: input.children,
22+
} as any);
23+
24+
describe('suggestion link integration', () => {
25+
it('marks only the previous link character when deleting backward after a link', () => {
26+
const input = (
27+
<editor>
28+
<hp>
29+
<htext>before </htext>
30+
<ha url="https://example.com">link</ha>
31+
<htext>
32+
<cursor />
33+
{' after'}
34+
</htext>
35+
</hp>
36+
</editor>
37+
) as any as SlateEditor;
38+
39+
const output = (
40+
<editor>
41+
<hp>
42+
<htext>before </htext>
43+
<ha url="https://example.com">
44+
<htext>lin</htext>
45+
<htext
46+
suggestion
47+
suggestion_1={{
48+
id: 'placeholder',
49+
createdAt: 0,
50+
type: 'remove',
51+
userId: 'alice',
52+
}}
53+
>
54+
<cursor />k
55+
</htext>
56+
</ha>
57+
<htext>{' after'}</htext>
58+
</hp>
59+
</editor>
60+
) as any as SlateEditor;
61+
62+
const editor = createEditor(input);
63+
editor.setOption(BaseSuggestionPlugin, 'isSuggesting', true);
64+
65+
editor.tf.deleteBackward();
66+
67+
const outputLinkNode = output.children[0].children[1] as any;
68+
const linkNode = editor.children[0].children[1] as any;
69+
const suggestionLeaf = linkNode.children[1] as any;
70+
const suggestionData = editor
71+
.getApi(BaseSuggestionPlugin)
72+
.suggestion.suggestionData(suggestionLeaf) as any;
73+
74+
expect(editor.children[0].children[0]).toEqual(
75+
output.children[0].children[0]
76+
);
77+
expect(linkNode.children[0]).toEqual(outputLinkNode.children[0]);
78+
expect(suggestionLeaf.text).toBe(outputLinkNode.children[1].text);
79+
expect(suggestionData?.type).toBe('remove');
80+
expect(suggestionData?.userId).toBe('alice');
81+
expect(linkNode.suggestion).toBeUndefined();
82+
expect(
83+
Object.keys(linkNode).filter((key) => key.startsWith('suggestion_'))
84+
).toHaveLength(0);
85+
expect(editor.children[0].children[2]).toEqual(
86+
output.children[0].children[2]
87+
);
88+
expect(editor.selection).toEqual(output.selection);
89+
});
90+
91+
it('removes an empty link after accepting the last removed character', () => {
92+
const removeData = {
93+
id: '1',
94+
createdAt: Date.now(),
95+
type: 'remove',
96+
userId: 'alice',
97+
};
98+
99+
const input = (
100+
<editor>
101+
<hp>
102+
before{' '}
103+
<ha url="https://reactjs.org">
104+
<htext suggestion_1={removeData} suggestion>
105+
t
106+
</htext>
107+
</ha>
108+
</hp>
109+
</editor>
110+
) as any as SlateEditor;
111+
112+
const output = (
113+
<editor>
114+
<hp>before </hp>
115+
</editor>
116+
) as any as SlateEditor;
117+
118+
const editor = createEditor(input);
119+
editor.selection = {
120+
anchor: { offset: 1, path: [0, 1, 0] },
121+
focus: { offset: 1, path: [0, 1, 0] },
122+
};
123+
124+
acceptSuggestion(editor, {
125+
keyId: getSuggestionKey('1'),
126+
suggestionId: '1',
127+
} as any);
128+
129+
expect(editor.children).toEqual(output.children);
130+
});
131+
132+
it('rejects remove suggestion on inline link elements', () => {
133+
const removeData = {
134+
id: '1',
135+
createdAt: Date.now(),
136+
type: 'remove',
137+
userId: 'alice',
138+
};
139+
140+
const input = (
141+
<editor>
142+
<hp>
143+
before{' '}
144+
<ha suggestion suggestion_1={removeData} url="https://example.com">
145+
link
146+
</ha>{' '}
147+
after
148+
</hp>
149+
</editor>
150+
) as any as SlateEditor;
151+
152+
const output = (
153+
<editor>
154+
<hp>
155+
before <ha url="https://example.com">link</ha> after
156+
</hp>
157+
</editor>
158+
) as any as SlateEditor;
159+
160+
const editor = createEditor(input);
161+
162+
rejectSuggestion(editor, {
163+
keyId: 'suggestion_1',
164+
suggestionId: '1',
165+
} as any);
166+
167+
expect(editor.children).toEqual(output.children);
168+
});
169+
});

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

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,7 @@ export const aiChatPlugin = AIChatPlugin.extend({
9494
}
9595
},
9696
onFinish: () => {
97-
editor.setOption(AIChatPlugin, 'streaming', false);
98-
editor.setOption(AIChatPlugin, '_blockChunks', '');
99-
editor.setOption(AIChatPlugin, '_blockPath', null);
100-
editor.setOption(AIChatPlugin, '_mdxName', null);
97+
editor.getApi(AIChatPlugin).aiChat.stop();
10198
},
10299
});
103100
},

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

Lines changed: 13 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -7,63 +7,45 @@ import {
77
BaseCommentPlugin,
88
getDraftCommentKey,
99
} from '@platejs/comment';
10-
import { isSlateString } from 'platejs';
1110
import { toTPlatePlugin } from 'platejs/react';
1211

1312
import { CommentLeaf } from '@/registry/ui/comment-node';
13+
import { getDiscussionClickTarget } from './discussion-kit';
1414

1515
type CommentConfig = ExtendConfig<
1616
BaseCommentConfig,
1717
{
1818
activeId: string | null;
1919
commentingBlock: Path | null;
2020
hoverId: string | null;
21-
uniquePathMap: Map<string, Path>;
2221
}
2322
>;
2423

2524
export const commentPlugin = toTPlatePlugin<CommentConfig>(BaseCommentPlugin, {
2625
handlers: {
2726
onClick: ({ api, event, setOption, type }) => {
28-
let leaf = event.target as HTMLElement;
29-
let isSet = false;
27+
const activeTarget = getDiscussionClickTarget({
28+
selector: `.slate-${type}`,
29+
target: event.target,
30+
});
3031

31-
const unsetActiveSuggestion = () => {
32+
if (!activeTarget) {
3233
setOption('activeId', null);
33-
isSet = true;
34-
};
35-
36-
if (!isSlateString(leaf)) unsetActiveSuggestion();
37-
38-
while (leaf.parentElement) {
39-
if (leaf.classList.contains(`slate-${type}`)) {
40-
const commentsEntry = api.comment!.node();
41-
42-
if (!commentsEntry) {
43-
unsetActiveSuggestion();
44-
45-
break;
46-
}
47-
48-
const id = api.comment!.nodeId(commentsEntry[0]);
49-
50-
setOption('activeId', id ?? null);
51-
isSet = true;
52-
53-
break;
54-
}
55-
56-
leaf = leaf.parentElement;
34+
return;
5735
}
5836

59-
if (!isSet) unsetActiveSuggestion();
37+
const commentEntry = api.comment?.node();
38+
39+
setOption(
40+
'activeId',
41+
commentEntry ? (api.comment?.nodeId(commentEntry[0]) ?? null) : null
42+
);
6043
},
6144
},
6245
options: {
6346
activeId: null,
6447
commentingBlock: null,
6548
hoverId: null,
66-
uniquePathMap: new Map(),
6749
},
6850
})
6951
.extendTransforms(

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,41 @@ export type TDiscussion = {
1515
documentContent?: string;
1616
};
1717

18+
const BLOCK_SUGGESTION_SELECTOR = '[data-block-suggestion="true"]';
19+
20+
const getTargetElement = (target: EventTarget | null) => {
21+
if (target instanceof HTMLElement) return target;
22+
if (target instanceof Node) return target.parentElement;
23+
24+
return null;
25+
};
26+
27+
export const getDiscussionClickTarget = ({
28+
selector,
29+
target,
30+
}: {
31+
selector: string;
32+
target: EventTarget | null;
33+
}) => {
34+
const element = getTargetElement(target);
35+
36+
if (!element) return null;
37+
38+
return element.closest(selector) as HTMLElement | null;
39+
};
40+
41+
export const getDiscussionBlockClickTarget = ({
42+
selector = BLOCK_SUGGESTION_SELECTOR,
43+
target,
44+
}: {
45+
selector?: string;
46+
target: EventTarget | null;
47+
}) =>
48+
getDiscussionClickTarget({
49+
selector,
50+
target,
51+
});
52+
1853
const discussionsData: TDiscussion[] = [
1954
{
2055
id: 'discussion1',
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { afterAll, describe, expect, it, mock } from 'bun:test';
2+
3+
mock.module('@platejs/suggestion', () => ({
4+
BaseSuggestionPlugin: {
5+
configure: (config: any) => config,
6+
},
7+
}));
8+
9+
mock.module('platejs', () => ({
10+
KEYS: {
11+
date: 'date',
12+
inlineEquation: 'inline_equation',
13+
link: 'link',
14+
mention: 'mention',
15+
},
16+
TextApi: {
17+
isText: (node: any) => typeof node?.text === 'string',
18+
},
19+
}));
20+
21+
mock.module('@/registry/ui/suggestion-node-static', () => ({
22+
SuggestionLeafStatic: () => null,
23+
VoidRemoveSuggestionOverlayStatic: () => null,
24+
}));
25+
26+
describe('BaseSuggestionKit', () => {
27+
afterAll(() => {
28+
mock.restore();
29+
});
30+
31+
it('injects inline suggestion type for static inline element rendering', async () => {
32+
const { BaseSuggestionKit } = await import(
33+
`./suggestion-base-kit?test=${Math.random().toString(36).slice(2)}`
34+
);
35+
36+
const transformProps = (BaseSuggestionKit[0] as any).inject.nodeProps
37+
.transformProps;
38+
const editor = {
39+
getApi: () => ({
40+
suggestion: {
41+
dataList: (node: any) =>
42+
Object.keys(node)
43+
.filter((key) => key.startsWith('suggestion_'))
44+
.map((key) => node[key]),
45+
suggestionData: (element: any) => element.suggestion,
46+
},
47+
}),
48+
};
49+
50+
expect(
51+
transformProps({
52+
editor,
53+
element: {
54+
children: [
55+
{
56+
suggestion_1: {
57+
createdAt: 0,
58+
id: 'suggestion-1',
59+
type: 'remove',
60+
userId: 'alice',
61+
},
62+
text: '',
63+
},
64+
],
65+
type: 'date',
66+
},
67+
props: {},
68+
})
69+
).toEqual({
70+
'data-inline-suggestion': 'remove',
71+
});
72+
});
73+
});

0 commit comments

Comments
 (0)