@lobehub/lobehub 2.0.0-next.289 → 2.0.0-next.290
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/CHANGELOG.md +25 -0
- package/changelog/v1.json +9 -0
- package/package.json +1 -1
- package/src/features/EditorCanvas/InternalEditor.test.tsx +630 -0
- package/src/features/EditorCanvas/InternalEditor.tsx +35 -2
- package/src/store/document/slices/editor/action.test.ts +4 -2
- package/src/store/document/slices/editor/action.ts +8 -6
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
# Changelog
|
|
4
4
|
|
|
5
|
+
## [Version 2.0.0-next.290](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.289...v2.0.0-next.290)
|
|
6
|
+
|
|
7
|
+
<sup>Released on **2026-01-15**</sup>
|
|
8
|
+
|
|
9
|
+
#### 🐛 Bug Fixes
|
|
10
|
+
|
|
11
|
+
- **misc**: Fix internal editor onTextChange issue and add test case.
|
|
12
|
+
|
|
13
|
+
<br/>
|
|
14
|
+
|
|
15
|
+
<details>
|
|
16
|
+
<summary><kbd>Improvements and Fixes</kbd></summary>
|
|
17
|
+
|
|
18
|
+
#### What's fixed
|
|
19
|
+
|
|
20
|
+
- **misc**: Fix internal editor onTextChange issue and add test case, closes [#11509](https://github.com/lobehub/lobe-chat/issues/11509) ([e5eb03e](https://github.com/lobehub/lobe-chat/commit/e5eb03e))
|
|
21
|
+
|
|
22
|
+
</details>
|
|
23
|
+
|
|
24
|
+
<div align="right">
|
|
25
|
+
|
|
26
|
+
[](#readme-top)
|
|
27
|
+
|
|
28
|
+
</div>
|
|
29
|
+
|
|
5
30
|
## [Version 2.0.0-next.289](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.288...v2.0.0-next.289)
|
|
6
31
|
|
|
7
32
|
<sup>Released on **2026-01-15**</sup>
|
package/changelog/v1.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lobehub/lobehub",
|
|
3
|
-
"version": "2.0.0-next.
|
|
3
|
+
"version": "2.0.0-next.290",
|
|
4
4
|
"description": "LobeHub - an open-source,comprehensive AI Agent framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"framework",
|
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment happy-dom
|
|
3
|
+
*/
|
|
4
|
+
import { type IEditor, moment } from '@lobehub/editor';
|
|
5
|
+
import { useEditor } from '@lobehub/editor/react';
|
|
6
|
+
import { act, cleanup, render, waitFor } from '@testing-library/react';
|
|
7
|
+
import { memo, useEffect, useRef } from 'react';
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
9
|
+
|
|
10
|
+
import InternalEditor, { type InternalEditorProps } from './InternalEditor';
|
|
11
|
+
|
|
12
|
+
// Suppress console.warn for expected errors in tests
|
|
13
|
+
const originalWarn = console.warn;
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
console.warn = vi.fn();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
console.warn = originalWarn;
|
|
20
|
+
cleanup();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Test wrapper component that creates a real editor using useEditor hook
|
|
25
|
+
* This ensures all plugins and services are properly initialized
|
|
26
|
+
*/
|
|
27
|
+
interface TestWrapperProps extends Omit<InternalEditorProps, 'editor'> {
|
|
28
|
+
onEditorReady?: (editor: IEditor) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const TestWrapper = memo<TestWrapperProps>(({ onEditorReady, ...props }) => {
|
|
32
|
+
const editor = useEditor();
|
|
33
|
+
const readyRef = useRef(false);
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (editor && !readyRef.current) {
|
|
37
|
+
readyRef.current = true;
|
|
38
|
+
onEditorReady?.(editor);
|
|
39
|
+
}
|
|
40
|
+
}, [editor, onEditorReady]);
|
|
41
|
+
|
|
42
|
+
if (!editor) return null;
|
|
43
|
+
return <InternalEditor editor={editor} {...props} />;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
TestWrapper.displayName = 'TestWrapper';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Test wrapper for tests that need custom plugins (no toolbar dependencies)
|
|
50
|
+
*/
|
|
51
|
+
const MinimalTestWrapper = memo<TestWrapperProps>(({ onEditorReady, plugins, ...props }) => {
|
|
52
|
+
const editor = useEditor();
|
|
53
|
+
const readyRef = useRef(false);
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (editor && !readyRef.current) {
|
|
57
|
+
readyRef.current = true;
|
|
58
|
+
onEditorReady?.(editor);
|
|
59
|
+
}
|
|
60
|
+
}, [editor, onEditorReady]);
|
|
61
|
+
|
|
62
|
+
if (!editor) return null;
|
|
63
|
+
|
|
64
|
+
// Use minimal plugins that don't require toolbar services
|
|
65
|
+
const minimalPlugins = plugins || [];
|
|
66
|
+
|
|
67
|
+
return <InternalEditor editor={editor} plugins={minimalPlugins} {...props} />;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
MinimalTestWrapper.displayName = 'MinimalTestWrapper';
|
|
71
|
+
|
|
72
|
+
describe('InternalEditor', () => {
|
|
73
|
+
describe('rendering', () => {
|
|
74
|
+
it('should render editor with real editor instance', async () => {
|
|
75
|
+
const { container } = render(<MinimalTestWrapper />);
|
|
76
|
+
|
|
77
|
+
await act(async () => {
|
|
78
|
+
await moment();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Editor should be rendered
|
|
82
|
+
expect(container.querySelector('[data-lexical-editor]')).not.toBeNull();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should render with custom placeholder', async () => {
|
|
86
|
+
const placeholder = 'Start typing here...';
|
|
87
|
+
const { container } = render(<MinimalTestWrapper placeholder={placeholder} />);
|
|
88
|
+
|
|
89
|
+
await act(async () => {
|
|
90
|
+
await moment();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(container.textContent).toContain(placeholder);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should apply custom styles', async () => {
|
|
97
|
+
const customStyle = { backgroundColor: 'red', paddingTop: 100 };
|
|
98
|
+
const { container } = render(<MinimalTestWrapper style={customStyle} />);
|
|
99
|
+
|
|
100
|
+
await act(async () => {
|
|
101
|
+
await moment();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Find the Editor component's container
|
|
105
|
+
const editorContainer = container.querySelector('[data-lexical-editor]')?.closest('div');
|
|
106
|
+
// The style should include paddingBottom: 64 (default) merged with custom styles
|
|
107
|
+
expect(editorContainer).toBeTruthy();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('onInit callback', () => {
|
|
112
|
+
it('should call onInit when editor initializes', async () => {
|
|
113
|
+
const onInit = vi.fn();
|
|
114
|
+
|
|
115
|
+
render(<MinimalTestWrapper onInit={onInit} />);
|
|
116
|
+
|
|
117
|
+
await act(async () => {
|
|
118
|
+
await moment();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
await waitFor(() => {
|
|
122
|
+
expect(onInit).toHaveBeenCalled();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should pass editor instance to onInit', async () => {
|
|
127
|
+
const onInit = vi.fn();
|
|
128
|
+
|
|
129
|
+
render(<MinimalTestWrapper onInit={onInit} />);
|
|
130
|
+
|
|
131
|
+
await act(async () => {
|
|
132
|
+
await moment();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
await waitFor(() => {
|
|
136
|
+
expect(onInit).toHaveBeenCalledWith(
|
|
137
|
+
expect.objectContaining({ getDocument: expect.any(Function) }),
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should not throw error when initialized with empty content', async () => {
|
|
143
|
+
const onInit = vi.fn();
|
|
144
|
+
let editorInstance: IEditor | undefined;
|
|
145
|
+
|
|
146
|
+
// This test ensures the fix for "setEditorState: the editor state is empty" error
|
|
147
|
+
// When editor initializes with empty/undefined content, it should not throw
|
|
148
|
+
const { container } = render(
|
|
149
|
+
<MinimalTestWrapper
|
|
150
|
+
onEditorReady={(e) => {
|
|
151
|
+
editorInstance = e;
|
|
152
|
+
}}
|
|
153
|
+
onInit={onInit}
|
|
154
|
+
/>,
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
await act(async () => {
|
|
158
|
+
await moment();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Editor should initialize without error
|
|
162
|
+
await waitFor(() => {
|
|
163
|
+
expect(onInit).toHaveBeenCalled();
|
|
164
|
+
expect(editorInstance).toBeDefined();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Editor should be rendered
|
|
168
|
+
expect(container.querySelector('[data-lexical-editor]')).not.toBeNull();
|
|
169
|
+
|
|
170
|
+
// Getting document should work (returns empty content)
|
|
171
|
+
const text = editorInstance!.getDocument('text') as unknown as string;
|
|
172
|
+
expect(text).toBeDefined();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('onContentChange callback', () => {
|
|
177
|
+
it('should call onContentChange when content changes via setDocument', async () => {
|
|
178
|
+
const onContentChange = vi.fn();
|
|
179
|
+
let editorInstance: IEditor | undefined;
|
|
180
|
+
|
|
181
|
+
render(
|
|
182
|
+
<MinimalTestWrapper
|
|
183
|
+
onContentChange={onContentChange}
|
|
184
|
+
onEditorReady={(e) => {
|
|
185
|
+
editorInstance = e;
|
|
186
|
+
}}
|
|
187
|
+
/>,
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
await act(async () => {
|
|
191
|
+
await moment();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// Wait for editor to be ready
|
|
195
|
+
await waitFor(() => {
|
|
196
|
+
expect(editorInstance).toBeDefined();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// Change content using editor API
|
|
200
|
+
await act(async () => {
|
|
201
|
+
editorInstance!.setDocument('text', 'Hello World');
|
|
202
|
+
await moment();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
await waitFor(
|
|
206
|
+
() => {
|
|
207
|
+
expect(onContentChange).toHaveBeenCalled();
|
|
208
|
+
},
|
|
209
|
+
{ timeout: 2000 },
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should call onContentChange when markdown content is set', async () => {
|
|
214
|
+
const onContentChange = vi.fn();
|
|
215
|
+
let editorInstance: IEditor | undefined;
|
|
216
|
+
|
|
217
|
+
render(
|
|
218
|
+
<MinimalTestWrapper
|
|
219
|
+
onContentChange={onContentChange}
|
|
220
|
+
onEditorReady={(e) => {
|
|
221
|
+
editorInstance = e;
|
|
222
|
+
}}
|
|
223
|
+
/>,
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
await act(async () => {
|
|
227
|
+
await moment();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
await waitFor(() => {
|
|
231
|
+
expect(editorInstance).toBeDefined();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// Change content using markdown
|
|
235
|
+
await act(async () => {
|
|
236
|
+
editorInstance!.setDocument('markdown', '# Hello\n\nThis is a paragraph.');
|
|
237
|
+
await moment();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
await waitFor(
|
|
241
|
+
() => {
|
|
242
|
+
expect(onContentChange).toHaveBeenCalled();
|
|
243
|
+
},
|
|
244
|
+
{ timeout: 2000 },
|
|
245
|
+
);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('should track multiple content changes', async () => {
|
|
249
|
+
const onContentChange = vi.fn();
|
|
250
|
+
let editorInstance: IEditor | undefined;
|
|
251
|
+
|
|
252
|
+
render(
|
|
253
|
+
<MinimalTestWrapper
|
|
254
|
+
onContentChange={onContentChange}
|
|
255
|
+
onEditorReady={(e) => {
|
|
256
|
+
editorInstance = e;
|
|
257
|
+
}}
|
|
258
|
+
/>,
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
await act(async () => {
|
|
262
|
+
await moment();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
await waitFor(() => {
|
|
266
|
+
expect(editorInstance).toBeDefined();
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// First change
|
|
270
|
+
await act(async () => {
|
|
271
|
+
editorInstance!.setDocument('text', 'First content');
|
|
272
|
+
await moment();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// Second change
|
|
276
|
+
await act(async () => {
|
|
277
|
+
editorInstance!.setDocument('text', 'Second content');
|
|
278
|
+
await moment();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// Third change
|
|
282
|
+
await act(async () => {
|
|
283
|
+
editorInstance!.setDocument('text', 'Third content');
|
|
284
|
+
await moment();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
await waitFor(
|
|
288
|
+
() => {
|
|
289
|
+
// Should have multiple calls for different content changes
|
|
290
|
+
expect(onContentChange.mock.calls.length).toBeGreaterThanOrEqual(2);
|
|
291
|
+
},
|
|
292
|
+
{ timeout: 2000 },
|
|
293
|
+
);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
describe('editor content methods', () => {
|
|
298
|
+
it('should allow getting document as markdown', async () => {
|
|
299
|
+
let editorInstance: IEditor | undefined;
|
|
300
|
+
|
|
301
|
+
render(
|
|
302
|
+
<MinimalTestWrapper
|
|
303
|
+
onEditorReady={(e) => {
|
|
304
|
+
editorInstance = e;
|
|
305
|
+
}}
|
|
306
|
+
/>,
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
await act(async () => {
|
|
310
|
+
await moment();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
await waitFor(() => {
|
|
314
|
+
expect(editorInstance).toBeDefined();
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
await act(async () => {
|
|
318
|
+
editorInstance!.setDocument('text', 'Test content');
|
|
319
|
+
await moment();
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const markdown = editorInstance!.getDocument('markdown') as unknown as string;
|
|
323
|
+
expect(markdown).toContain('Test content');
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('should allow getting document as JSON', async () => {
|
|
327
|
+
let editorInstance: IEditor | undefined;
|
|
328
|
+
|
|
329
|
+
render(
|
|
330
|
+
<MinimalTestWrapper
|
|
331
|
+
onEditorReady={(e) => {
|
|
332
|
+
editorInstance = e;
|
|
333
|
+
}}
|
|
334
|
+
/>,
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
await act(async () => {
|
|
338
|
+
await moment();
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
await waitFor(() => {
|
|
342
|
+
expect(editorInstance).toBeDefined();
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
await act(async () => {
|
|
346
|
+
editorInstance!.setDocument('text', 'Test content');
|
|
347
|
+
await moment();
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
const json = editorInstance!.getDocument('json');
|
|
351
|
+
expect(json).toBeDefined();
|
|
352
|
+
expect(typeof json).toBe('object');
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('should allow getting document as text', async () => {
|
|
356
|
+
let editorInstance: IEditor | undefined;
|
|
357
|
+
|
|
358
|
+
render(
|
|
359
|
+
<MinimalTestWrapper
|
|
360
|
+
onEditorReady={(e) => {
|
|
361
|
+
editorInstance = e;
|
|
362
|
+
}}
|
|
363
|
+
/>,
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
await act(async () => {
|
|
367
|
+
await moment();
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
await waitFor(() => {
|
|
371
|
+
expect(editorInstance).toBeDefined();
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
await act(async () => {
|
|
375
|
+
editorInstance!.setDocument('markdown', '# Heading\n\nParagraph');
|
|
376
|
+
await moment();
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
const text = editorInstance!.getDocument('text') as unknown as string;
|
|
380
|
+
expect(text).toContain('Heading');
|
|
381
|
+
expect(text).toContain('Paragraph');
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
describe('lexical editor access', () => {
|
|
386
|
+
it('should expose getLexicalEditor method', async () => {
|
|
387
|
+
let editorInstance: IEditor | undefined;
|
|
388
|
+
|
|
389
|
+
render(
|
|
390
|
+
<MinimalTestWrapper
|
|
391
|
+
onEditorReady={(e) => {
|
|
392
|
+
editorInstance = e;
|
|
393
|
+
}}
|
|
394
|
+
/>,
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
await act(async () => {
|
|
398
|
+
await moment();
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
await waitFor(() => {
|
|
402
|
+
expect(editorInstance).toBeDefined();
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
const lexicalEditor = editorInstance!.getLexicalEditor?.();
|
|
406
|
+
expect(lexicalEditor).toBeDefined();
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('should allow registering custom update listeners', async () => {
|
|
410
|
+
const updateListener = vi.fn();
|
|
411
|
+
let editorInstance: IEditor | undefined;
|
|
412
|
+
|
|
413
|
+
render(
|
|
414
|
+
<MinimalTestWrapper
|
|
415
|
+
onEditorReady={(e) => {
|
|
416
|
+
editorInstance = e;
|
|
417
|
+
}}
|
|
418
|
+
/>,
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
await act(async () => {
|
|
422
|
+
await moment();
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
await waitFor(() => {
|
|
426
|
+
expect(editorInstance).toBeDefined();
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
const lexicalEditor = editorInstance!.getLexicalEditor?.();
|
|
430
|
+
expect(lexicalEditor).toBeDefined();
|
|
431
|
+
|
|
432
|
+
if (lexicalEditor) {
|
|
433
|
+
const unregister = lexicalEditor.registerUpdateListener(updateListener);
|
|
434
|
+
|
|
435
|
+
// Trigger an update
|
|
436
|
+
await act(async () => {
|
|
437
|
+
editorInstance!.setDocument('text', 'Updated content');
|
|
438
|
+
await moment();
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
expect(updateListener).toHaveBeenCalled();
|
|
442
|
+
|
|
443
|
+
// Cleanup
|
|
444
|
+
unregister();
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
describe('custom plugins', () => {
|
|
450
|
+
it('should accept custom plugins array', async () => {
|
|
451
|
+
const CustomPlugin = () => null;
|
|
452
|
+
|
|
453
|
+
const { container } = render(<MinimalTestWrapper plugins={[CustomPlugin]} />);
|
|
454
|
+
|
|
455
|
+
await act(async () => {
|
|
456
|
+
await moment();
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// Should render without error
|
|
460
|
+
expect(container.querySelector('[data-lexical-editor]')).not.toBeNull();
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it('should accept extra plugins prepended to base plugins', async () => {
|
|
464
|
+
const ExtraPlugin = () => null;
|
|
465
|
+
|
|
466
|
+
// Note: extraPlugins requires base plugins which need toolbar services
|
|
467
|
+
// We test this with minimal plugins instead
|
|
468
|
+
const { container } = render(<MinimalTestWrapper plugins={[ExtraPlugin]} />);
|
|
469
|
+
|
|
470
|
+
await act(async () => {
|
|
471
|
+
await moment();
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
// Should render without error
|
|
475
|
+
expect(container.querySelector('[data-lexical-editor]')).not.toBeNull();
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
describe('window.__editor assignment', () => {
|
|
480
|
+
it('should assign editor to window.__editor for debugging', async () => {
|
|
481
|
+
let editorInstance: IEditor | undefined;
|
|
482
|
+
|
|
483
|
+
render(
|
|
484
|
+
<MinimalTestWrapper
|
|
485
|
+
onEditorReady={(e) => {
|
|
486
|
+
editorInstance = e;
|
|
487
|
+
}}
|
|
488
|
+
/>,
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
await act(async () => {
|
|
492
|
+
await moment();
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
await waitFor(() => {
|
|
496
|
+
expect(editorInstance).toBeDefined();
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
expect(window.__editor).toBe(editorInstance);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('should clear window.__editor on unmount', async () => {
|
|
503
|
+
let editorInstance: IEditor | undefined;
|
|
504
|
+
|
|
505
|
+
const { unmount } = render(
|
|
506
|
+
<MinimalTestWrapper
|
|
507
|
+
onEditorReady={(e) => {
|
|
508
|
+
editorInstance = e;
|
|
509
|
+
}}
|
|
510
|
+
/>,
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
await act(async () => {
|
|
514
|
+
await moment();
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
await waitFor(() => {
|
|
518
|
+
expect(editorInstance).toBeDefined();
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
expect(window.__editor).toBe(editorInstance);
|
|
522
|
+
|
|
523
|
+
unmount();
|
|
524
|
+
|
|
525
|
+
expect(window.__editor).toBeUndefined();
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
describe('callback stability', () => {
|
|
530
|
+
it('should maintain stable onContentChange behavior across re-renders', async () => {
|
|
531
|
+
const onContentChange = vi.fn();
|
|
532
|
+
let editorInstance: IEditor | undefined;
|
|
533
|
+
|
|
534
|
+
const { rerender } = render(
|
|
535
|
+
<MinimalTestWrapper
|
|
536
|
+
onContentChange={onContentChange}
|
|
537
|
+
onEditorReady={(e) => {
|
|
538
|
+
editorInstance = e;
|
|
539
|
+
}}
|
|
540
|
+
/>,
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
await act(async () => {
|
|
544
|
+
await moment();
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
await waitFor(() => {
|
|
548
|
+
expect(editorInstance).toBeDefined();
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
// Re-render with same props
|
|
552
|
+
rerender(
|
|
553
|
+
<MinimalTestWrapper
|
|
554
|
+
onContentChange={onContentChange}
|
|
555
|
+
onEditorReady={(e) => {
|
|
556
|
+
editorInstance = e;
|
|
557
|
+
}}
|
|
558
|
+
/>,
|
|
559
|
+
);
|
|
560
|
+
|
|
561
|
+
await act(async () => {
|
|
562
|
+
await moment();
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
// Change content after re-render
|
|
566
|
+
await act(async () => {
|
|
567
|
+
editorInstance!.setDocument('text', 'Content after rerender');
|
|
568
|
+
await moment();
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
await waitFor(
|
|
572
|
+
() => {
|
|
573
|
+
expect(onContentChange).toHaveBeenCalled();
|
|
574
|
+
},
|
|
575
|
+
{ timeout: 2000 },
|
|
576
|
+
);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it('should use updated callback when onContentChange prop changes', async () => {
|
|
580
|
+
const firstCallback = vi.fn();
|
|
581
|
+
const secondCallback = vi.fn();
|
|
582
|
+
let editorInstance: IEditor | undefined;
|
|
583
|
+
|
|
584
|
+
const { rerender } = render(
|
|
585
|
+
<MinimalTestWrapper
|
|
586
|
+
onContentChange={firstCallback}
|
|
587
|
+
onEditorReady={(e) => {
|
|
588
|
+
editorInstance = e;
|
|
589
|
+
}}
|
|
590
|
+
/>,
|
|
591
|
+
);
|
|
592
|
+
|
|
593
|
+
await act(async () => {
|
|
594
|
+
await moment();
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
await waitFor(() => {
|
|
598
|
+
expect(editorInstance).toBeDefined();
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
// Change callback prop
|
|
602
|
+
rerender(
|
|
603
|
+
<MinimalTestWrapper
|
|
604
|
+
onContentChange={secondCallback}
|
|
605
|
+
onEditorReady={(e) => {
|
|
606
|
+
editorInstance = e;
|
|
607
|
+
}}
|
|
608
|
+
/>,
|
|
609
|
+
);
|
|
610
|
+
|
|
611
|
+
await act(async () => {
|
|
612
|
+
await moment();
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
// Trigger content change
|
|
616
|
+
await act(async () => {
|
|
617
|
+
editorInstance!.setDocument('text', 'New content');
|
|
618
|
+
await moment();
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
await waitFor(
|
|
622
|
+
() => {
|
|
623
|
+
// Second callback should be called
|
|
624
|
+
expect(secondCallback).toHaveBeenCalled();
|
|
625
|
+
},
|
|
626
|
+
{ timeout: 2000 },
|
|
627
|
+
);
|
|
628
|
+
});
|
|
629
|
+
});
|
|
630
|
+
});
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
ReactToolbarPlugin,
|
|
15
15
|
} from '@lobehub/editor';
|
|
16
16
|
import { Editor, useEditorState } from '@lobehub/editor/react';
|
|
17
|
-
import { memo, useEffect, useMemo } from 'react';
|
|
17
|
+
import { memo, useEffect, useMemo, useRef } from 'react';
|
|
18
18
|
import { useTranslation } from 'react-i18next';
|
|
19
19
|
|
|
20
20
|
import type { EditorCanvasProps } from './EditorCanvas';
|
|
@@ -102,6 +102,40 @@ const InternalEditor = memo<InternalEditorProps>(
|
|
|
102
102
|
};
|
|
103
103
|
}, [editor]);
|
|
104
104
|
|
|
105
|
+
// Use refs for stable references across re-renders
|
|
106
|
+
const previousContentRef = useRef<string | undefined>(undefined);
|
|
107
|
+
const onContentChangeRef = useRef(onContentChange);
|
|
108
|
+
onContentChangeRef.current = onContentChange;
|
|
109
|
+
|
|
110
|
+
// Listen to Lexical updates directly to trigger content change
|
|
111
|
+
// This bypasses @lobehub/editor's onTextChange which has issues with previousContent reset
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (!editor) return;
|
|
114
|
+
|
|
115
|
+
const lexicalEditor = editor.getLexicalEditor?.();
|
|
116
|
+
if (!lexicalEditor) return;
|
|
117
|
+
|
|
118
|
+
// Initialize previousContent with current content before registering listener
|
|
119
|
+
previousContentRef.current = JSON.stringify(editor.getDocument('text'));
|
|
120
|
+
|
|
121
|
+
const unregister = lexicalEditor.registerUpdateListener(({ dirtyElements, dirtyLeaves }) => {
|
|
122
|
+
// Only process when there are actual content changes
|
|
123
|
+
if (dirtyElements.size === 0 && dirtyLeaves.size === 0) return;
|
|
124
|
+
|
|
125
|
+
const currentContent = JSON.stringify(editor.getDocument('text'));
|
|
126
|
+
|
|
127
|
+
if (currentContent !== previousContentRef.current) {
|
|
128
|
+
// Content actually changed
|
|
129
|
+
previousContentRef.current = currentContent;
|
|
130
|
+
onContentChangeRef.current?.();
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
return () => {
|
|
135
|
+
unregister();
|
|
136
|
+
};
|
|
137
|
+
}, [editor]); // Only depend on editor, use ref for onContentChange
|
|
138
|
+
|
|
105
139
|
return (
|
|
106
140
|
<div
|
|
107
141
|
onClick={(e) => {
|
|
@@ -114,7 +148,6 @@ const InternalEditor = memo<InternalEditorProps>(
|
|
|
114
148
|
editor={editor}
|
|
115
149
|
lineEmptyPlaceholder={finalPlaceholder}
|
|
116
150
|
onInit={onInit}
|
|
117
|
-
onTextChange={onContentChange}
|
|
118
151
|
placeholder={finalPlaceholder}
|
|
119
152
|
plugins={plugins}
|
|
120
153
|
slashOption={slashItems ? { items: slashItems } : undefined}
|
|
@@ -180,7 +180,7 @@ describe('DocumentStore - Editor Actions', () => {
|
|
|
180
180
|
expect(mockEditor.setDocument).toHaveBeenCalledWith('json', JSON.stringify(editorData));
|
|
181
181
|
});
|
|
182
182
|
|
|
183
|
-
it('should
|
|
183
|
+
it('should not call setDocument when content is empty to avoid editor error', () => {
|
|
184
184
|
const { result } = renderHook(() => useDocumentStore());
|
|
185
185
|
const mockEditor = createMockEditor() as any;
|
|
186
186
|
|
|
@@ -196,7 +196,9 @@ describe('DocumentStore - Editor Actions', () => {
|
|
|
196
196
|
result.current.onEditorInit(mockEditor);
|
|
197
197
|
});
|
|
198
198
|
|
|
199
|
-
|
|
199
|
+
// setDocument should NOT be called for empty content
|
|
200
|
+
// This prevents "setEditorState: the editor state is empty" error
|
|
201
|
+
expect(mockEditor.setDocument).not.toHaveBeenCalled();
|
|
200
202
|
});
|
|
201
203
|
});
|
|
202
204
|
|
|
@@ -151,12 +151,14 @@ export const createEditorSlice: StateCreator<
|
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
// Load markdown content
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
154
|
+
// Load markdown content if available
|
|
155
|
+
// Skip setDocument for empty content - let editor use its default empty state
|
|
156
|
+
if (doc.content?.trim()) {
|
|
157
|
+
try {
|
|
158
|
+
editor.setDocument('markdown', doc.content);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
console.error('[DocumentStore] Failed to load markdown content:', err);
|
|
161
|
+
}
|
|
160
162
|
}
|
|
161
163
|
|
|
162
164
|
set({ editor });
|