@lobehub/lobehub 2.0.0-next.255 → 2.0.0-next.256

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 (140) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/changelog/v1.json +9 -0
  3. package/locales/en-US/plugin.json +1 -0
  4. package/locales/zh-CN/plugin.json +1 -0
  5. package/package.json +1 -1
  6. package/packages/builtin-tool-notebook/src/client/Placeholder/CreateDocument.tsx +6 -6
  7. package/packages/builtin-tool-notebook/src/client/Render/CreateDocument/DocumentCard.tsx +23 -10
  8. package/packages/builtin-tool-notebook/src/client/Streaming/CreateDocument/index.tsx +1 -1
  9. package/packages/database/src/models/__tests__/agent.test.ts +91 -4
  10. package/packages/database/src/models/agent.ts +15 -7
  11. package/packages/editor-runtime/src/EditorRuntime.ts +1 -1
  12. package/packages/editor-runtime/src/__tests__/EditorRuntime.real.test.ts +65 -4
  13. package/packages/editor-runtime/src/__tests__/__snapshots__/EditorRuntime.real.test.ts.snap +108 -17
  14. package/packages/editor-runtime/src/__tests__/fixtures/remove-then-add.json +636 -0
  15. package/packages/editor-runtime/src/__tests__/fixtures/remove.json +1 -0
  16. package/packages/types/src/agent/agentConfig.ts +8 -8
  17. package/src/app/[variants]/(main)/chat/features/Portal/_layout/Mobile.tsx +2 -1
  18. package/src/app/[variants]/(main)/group/features/Portal/_layout/Mobile.tsx +2 -1
  19. package/src/app/[variants]/(main)/home/_layout/hooks/useCreateMenuItems.tsx +2 -2
  20. package/src/app/[variants]/(main)/page/{features/PageTitle → PageTitle}/index.tsx +0 -1
  21. package/src/app/[variants]/(main)/page/[id]/index.tsx +43 -1
  22. package/src/app/[variants]/(main)/page/_layout/Body/AllPagesDrawer/Content.tsx +15 -15
  23. package/src/app/[variants]/(main)/page/_layout/Body/List/Item/Editing.tsx +3 -3
  24. package/src/app/[variants]/(main)/page/_layout/Body/List/Item/index.tsx +7 -12
  25. package/src/app/[variants]/(main)/page/_layout/Body/List/Item/useDropdownMenu.tsx +5 -5
  26. package/src/app/[variants]/(main)/page/_layout/Body/List/index.tsx +7 -7
  27. package/src/app/[variants]/(main)/page/_layout/Body/index.tsx +15 -9
  28. package/src/app/[variants]/(main)/page/_layout/Body/useDropdownMenu.tsx +3 -3
  29. package/src/app/[variants]/(main)/page/_layout/DataSync.tsx +15 -0
  30. package/src/app/[variants]/(main)/page/_layout/Header/AddButton.tsx +2 -2
  31. package/src/app/[variants]/(main)/page/_layout/index.tsx +2 -0
  32. package/src/app/[variants]/(main)/page/index.tsx +3 -7
  33. package/src/components/Editor/AutoSaveHint.tsx +1 -1
  34. package/src/features/Conversation/Messages/User/Actions/index.tsx +5 -1
  35. package/src/features/EditorCanvas/AutoSaveHint.tsx +37 -0
  36. package/src/features/{PageEditor → EditorCanvas}/DiffAllToolbar.tsx +57 -16
  37. package/src/features/EditorCanvas/DocumentIdMode.tsx +111 -0
  38. package/src/features/EditorCanvas/EditorCanvas.tsx +148 -0
  39. package/src/features/EditorCanvas/EditorDataMode.tsx +64 -0
  40. package/src/features/EditorCanvas/ErrorBoundary.tsx +66 -0
  41. package/src/features/EditorCanvas/InlineToolbar.tsx +245 -0
  42. package/src/features/EditorCanvas/InternalEditor.tsx +134 -0
  43. package/src/features/{PageEditor/EditorCanvas → EditorCanvas}/actions.ts +10 -8
  44. package/src/features/EditorCanvas/index.ts +9 -0
  45. package/src/features/PageEditor/EditorCanvas/index.tsx +14 -111
  46. package/src/features/PageEditor/EditorCanvas/useAskCopilotItem.tsx +95 -0
  47. package/src/features/PageEditor/EditorCanvas/useSlashItems.tsx +1 -1
  48. package/src/features/PageEditor/Header/Breadcrumb.tsx +2 -2
  49. package/src/features/PageEditor/Header/index.tsx +15 -18
  50. package/src/features/PageEditor/Header/useMenu.tsx +12 -9
  51. package/src/features/PageEditor/PageEditor.tsx +45 -21
  52. package/src/features/PageEditor/PageEditorProvider.tsx +13 -1
  53. package/src/features/PageEditor/PageTitle/index.tsx +2 -2
  54. package/src/features/PageEditor/StoreUpdater.tsx +35 -308
  55. package/src/features/PageEditor/{Body/Title.tsx → TitleSection.tsx} +16 -16
  56. package/src/features/PageEditor/store/action.ts +96 -188
  57. package/src/features/PageEditor/store/initialState.ts +16 -21
  58. package/src/features/PageEditor/store/selectors.ts +3 -4
  59. package/src/features/PageExplorer/PageExplorerPlaceholder.tsx +22 -14
  60. package/src/features/PageExplorer/index.tsx +34 -67
  61. package/src/features/Portal/Artifacts/index.ts +0 -2
  62. package/src/features/Portal/Document/AutoSaveHint.tsx +7 -6
  63. package/src/features/Portal/Document/Body.tsx +1 -3
  64. package/src/features/Portal/Document/EditorCanvas.tsx +7 -50
  65. package/src/features/Portal/Document/Header.tsx +13 -10
  66. package/src/features/Portal/Document/TodoList.tsx +6 -4
  67. package/src/features/Portal/Document/Wrapper.tsx +3 -11
  68. package/src/features/Portal/Document/index.ts +0 -2
  69. package/src/features/Portal/FilePreview/index.ts +0 -2
  70. package/src/features/Portal/GroupThread/index.ts +0 -3
  71. package/src/features/Portal/MessageDetail/index.ts +0 -2
  72. package/src/features/Portal/Notebook/index.ts +0 -2
  73. package/src/features/Portal/Plugins/index.ts +0 -2
  74. package/src/features/Portal/Thread/index.ts +0 -3
  75. package/src/features/Portal/components/Header.tsx +18 -6
  76. package/src/features/Portal/router.tsx +34 -97
  77. package/src/features/Portal/type.ts +0 -2
  78. package/src/locales/default/plugin.ts +1 -0
  79. package/src/store/chat/slices/portal/action.test.ts +218 -15
  80. package/src/store/chat/slices/portal/action.ts +194 -41
  81. package/src/store/chat/slices/portal/initialState.ts +40 -1
  82. package/src/store/chat/slices/portal/selectors/thread.ts +44 -3
  83. package/src/store/chat/slices/portal/selectors.test.ts +119 -17
  84. package/src/store/chat/slices/portal/selectors.ts +117 -36
  85. package/src/store/document/index.ts +17 -5
  86. package/src/store/document/slices/document/action.ts +209 -0
  87. package/src/store/document/slices/document/index.ts +6 -0
  88. package/src/store/document/slices/editor/action.test.ts +340 -0
  89. package/src/store/document/slices/editor/action.ts +133 -149
  90. package/src/store/document/slices/editor/index.ts +9 -2
  91. package/src/store/document/slices/editor/initialState.ts +66 -29
  92. package/src/store/document/slices/editor/reducer.test.ts +217 -0
  93. package/src/store/document/slices/editor/reducer.ts +67 -0
  94. package/src/store/document/slices/editor/selectors.test.ts +395 -0
  95. package/src/store/document/slices/editor/selectors.ts +107 -5
  96. package/src/store/document/store.ts +12 -13
  97. package/src/store/file/slices/document/action.ts +19 -188
  98. package/src/store/file/slices/document/initialState.ts +0 -30
  99. package/src/store/file/slices/document/selectors.ts +25 -59
  100. package/src/store/notebook/index.ts +5 -4
  101. package/src/store/page/index.ts +2 -0
  102. package/src/store/page/initialState.ts +92 -0
  103. package/src/store/page/selectors.ts +5 -0
  104. package/src/store/page/slices/crud/action.ts +477 -0
  105. package/src/store/page/slices/crud/index.ts +2 -0
  106. package/src/store/page/slices/crud/initialState.ts +7 -0
  107. package/src/store/page/slices/internal/action.ts +32 -0
  108. package/src/store/page/slices/internal/index.ts +2 -0
  109. package/src/store/page/slices/internal/reducer.ts +105 -0
  110. package/src/store/page/slices/list/action.ts +206 -0
  111. package/src/store/page/slices/list/index.ts +3 -0
  112. package/src/store/page/slices/list/initialState.ts +29 -0
  113. package/src/store/page/slices/list/selectors.ts +90 -0
  114. package/src/store/page/slices/selection/action.ts +67 -0
  115. package/src/store/page/slices/selection/index.ts +2 -0
  116. package/src/store/page/slices/selection/initialState.ts +11 -0
  117. package/src/store/page/store.ts +29 -0
  118. package/src/store/tool/slices/lobehubSkillStore/selectors.ts +10 -20
  119. package/src/utils/identifier.ts +8 -2
  120. package/src/features/PageEditor/Body/index.tsx +0 -68
  121. package/src/features/PageEditor/EditorCanvas/InlineToolbar.tsx +0 -316
  122. package/src/features/PageEditor/Header/AutoSaveHint.tsx +0 -27
  123. package/src/features/Portal/Artifacts/useEnable.ts +0 -4
  124. package/src/features/Portal/Document/DocumentEditorProvider.tsx +0 -34
  125. package/src/features/Portal/Document/StoreUpdater.tsx +0 -80
  126. package/src/features/Portal/Document/Title.tsx +0 -54
  127. package/src/features/Portal/Document/store/action.ts +0 -114
  128. package/src/features/Portal/Document/store/index.ts +0 -21
  129. package/src/features/Portal/Document/store/initialState.ts +0 -24
  130. package/src/features/Portal/Document/useEnable.ts +0 -8
  131. package/src/features/Portal/FilePreview/useEnable.ts +0 -6
  132. package/src/features/Portal/GroupThread/hook.ts +0 -9
  133. package/src/features/Portal/MessageDetail/useEnable.ts +0 -4
  134. package/src/features/Portal/Notebook/useEnable.ts +0 -6
  135. package/src/features/Portal/Plugins/useEnable.ts +0 -6
  136. package/src/features/Portal/Thread/hook.ts +0 -8
  137. package/src/store/document/slices/notebook/action.ts +0 -119
  138. package/src/store/document/slices/notebook/index.ts +0 -3
  139. package/src/store/document/slices/notebook/initialState.ts +0 -12
  140. package/src/store/document/slices/notebook/selectors.ts +0 -26
@@ -0,0 +1,340 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+
4
+ import { useDocumentStore } from '../../store';
5
+
6
+ // Mock services
7
+ vi.mock('@/services/document', () => ({
8
+ documentService: {
9
+ updateDocument: vi.fn().mockResolvedValue({}),
10
+ },
11
+ }));
12
+
13
+ vi.mock('@/services/notebook', () => ({
14
+ notebookService: {
15
+ updateDocument: vi.fn().mockResolvedValue({}),
16
+ },
17
+ }));
18
+
19
+ // Create mock editor
20
+ const createMockEditor = () => ({
21
+ getDocument: vi.fn((type: string) => {
22
+ if (type === 'markdown') return '# Test';
23
+ if (type === 'json') return { type: 'doc' };
24
+ return null;
25
+ }),
26
+ setDocument: vi.fn(),
27
+ });
28
+
29
+ describe('DocumentStore - Editor Actions', () => {
30
+ beforeEach(() => {
31
+ // Reset store state before each test
32
+ const { result } = renderHook(() => useDocumentStore());
33
+ act(() => {
34
+ // Clear all documents and reset editor
35
+ const state = result.current;
36
+ Object.keys(state.documents).forEach((id) => {
37
+ state.closeDocument(id);
38
+ });
39
+ state.setEditorState(undefined);
40
+ });
41
+ // Reset editor separately (store internal state)
42
+ useDocumentStore.setState({ editor: undefined });
43
+ });
44
+
45
+ describe('initDocumentWithEditor', () => {
46
+ it('should store document state without loading into editor', () => {
47
+ const { result } = renderHook(() => useDocumentStore());
48
+ const mockEditor = createMockEditor() as any;
49
+
50
+ act(() => {
51
+ result.current.initDocumentWithEditor({
52
+ content: '# Hello World',
53
+ documentId: 'doc-1',
54
+ editor: mockEditor,
55
+ sourceType: 'notebook',
56
+ topicId: 'topic-1',
57
+ });
58
+ });
59
+
60
+ // Should store state
61
+ expect(result.current.activeDocumentId).toBe('doc-1');
62
+ expect(result.current.documents['doc-1']).toMatchObject({
63
+ content: '# Hello World',
64
+ isDirty: false,
65
+ sourceType: 'notebook',
66
+ topicId: 'topic-1',
67
+ });
68
+ // Should NOT call setDocument - that happens in onEditorInit
69
+ expect(mockEditor.setDocument).not.toHaveBeenCalled();
70
+ });
71
+
72
+ it('should init a new page document', () => {
73
+ const { result } = renderHook(() => useDocumentStore());
74
+ const mockEditor = createMockEditor() as any;
75
+
76
+ act(() => {
77
+ result.current.initDocumentWithEditor({
78
+ content: 'Page content',
79
+ documentId: 'page-1',
80
+ editor: mockEditor,
81
+ sourceType: 'page',
82
+ });
83
+ });
84
+
85
+ expect(result.current.activeDocumentId).toBe('page-1');
86
+ expect(result.current.documents['page-1']).toMatchObject({
87
+ content: 'Page content',
88
+ sourceType: 'page',
89
+ });
90
+ });
91
+
92
+ it('should update existing document when init with same ID', () => {
93
+ const { result } = renderHook(() => useDocumentStore());
94
+ const mockEditor = createMockEditor() as any;
95
+
96
+ act(() => {
97
+ result.current.initDocumentWithEditor({
98
+ content: 'Original content',
99
+ documentId: 'doc-1',
100
+ editor: mockEditor,
101
+ sourceType: 'notebook',
102
+ topicId: 'topic-1',
103
+ });
104
+ });
105
+
106
+ act(() => {
107
+ result.current.initDocumentWithEditor({
108
+ content: 'Updated content',
109
+ documentId: 'doc-1',
110
+ editor: mockEditor,
111
+ sourceType: 'notebook',
112
+ topicId: 'topic-1',
113
+ });
114
+ });
115
+
116
+ expect(result.current.documents['doc-1'].content).toBe('Updated content');
117
+ });
118
+
119
+ it('should store editorData in state', () => {
120
+ const { result } = renderHook(() => useDocumentStore());
121
+ const mockEditor = createMockEditor() as any;
122
+ const editorData = { type: 'doc', content: [] };
123
+
124
+ act(() => {
125
+ result.current.initDocumentWithEditor({
126
+ documentId: 'doc-1',
127
+ editor: mockEditor,
128
+ editorData,
129
+ sourceType: 'page',
130
+ });
131
+ });
132
+
133
+ expect(result.current.documents['doc-1'].editorData).toEqual(editorData);
134
+ // Should NOT call setDocument - that happens in onEditorInit
135
+ expect(mockEditor.setDocument).not.toHaveBeenCalled();
136
+ });
137
+ });
138
+
139
+ describe('onEditorInit', () => {
140
+ it('should load markdown content into editor', () => {
141
+ const { result } = renderHook(() => useDocumentStore());
142
+ const mockEditor = createMockEditor() as any;
143
+
144
+ // First init document with content
145
+ act(() => {
146
+ result.current.initDocumentWithEditor({
147
+ content: '# Hello World',
148
+ documentId: 'doc-1',
149
+ editor: mockEditor,
150
+ sourceType: 'notebook',
151
+ });
152
+ });
153
+
154
+ // Then call onEditorInit
155
+ act(() => {
156
+ result.current.onEditorInit(mockEditor);
157
+ });
158
+
159
+ expect(mockEditor.setDocument).toHaveBeenCalledWith('markdown', '# Hello World');
160
+ });
161
+
162
+ it('should load editorData as json into editor', () => {
163
+ const { result } = renderHook(() => useDocumentStore());
164
+ const mockEditor = createMockEditor() as any;
165
+ const editorData = { type: 'doc', content: [] };
166
+
167
+ act(() => {
168
+ result.current.initDocumentWithEditor({
169
+ documentId: 'doc-1',
170
+ editor: mockEditor,
171
+ editorData,
172
+ sourceType: 'page',
173
+ });
174
+ });
175
+
176
+ act(() => {
177
+ result.current.onEditorInit(mockEditor);
178
+ });
179
+
180
+ expect(mockEditor.setDocument).toHaveBeenCalledWith('json', JSON.stringify(editorData));
181
+ });
182
+
183
+ it('should set empty placeholder when no content', () => {
184
+ const { result } = renderHook(() => useDocumentStore());
185
+ const mockEditor = createMockEditor() as any;
186
+
187
+ act(() => {
188
+ result.current.initDocumentWithEditor({
189
+ documentId: 'doc-1',
190
+ editor: mockEditor,
191
+ sourceType: 'page',
192
+ });
193
+ });
194
+
195
+ act(() => {
196
+ result.current.onEditorInit(mockEditor);
197
+ });
198
+
199
+ expect(mockEditor.setDocument).toHaveBeenCalledWith('markdown', ' ');
200
+ });
201
+ });
202
+
203
+ describe('closeDocument', () => {
204
+ it('should close a document and remove it from state', () => {
205
+ const { result } = renderHook(() => useDocumentStore());
206
+ const mockEditor = createMockEditor() as any;
207
+
208
+ act(() => {
209
+ result.current.initDocumentWithEditor({
210
+ documentId: 'doc-1',
211
+ editor: mockEditor,
212
+ sourceType: 'notebook',
213
+ topicId: 'topic-1',
214
+ });
215
+ });
216
+
217
+ expect(result.current.documents['doc-1']).toBeDefined();
218
+
219
+ act(() => {
220
+ result.current.closeDocument('doc-1');
221
+ });
222
+
223
+ expect(result.current.documents['doc-1']).toBeUndefined();
224
+ expect(result.current.activeDocumentId).toBeUndefined();
225
+ });
226
+
227
+ it('should not affect other documents when closing one', () => {
228
+ const { result } = renderHook(() => useDocumentStore());
229
+ const mockEditor = createMockEditor() as any;
230
+
231
+ act(() => {
232
+ result.current.initDocumentWithEditor({
233
+ documentId: 'doc-1',
234
+ editor: mockEditor,
235
+ sourceType: 'notebook',
236
+ topicId: 'topic-1',
237
+ });
238
+ result.current.initDocumentWithEditor({
239
+ documentId: 'doc-2',
240
+ editor: mockEditor,
241
+ sourceType: 'notebook',
242
+ topicId: 'topic-2',
243
+ });
244
+ });
245
+
246
+ act(() => {
247
+ result.current.closeDocument('doc-1');
248
+ });
249
+
250
+ expect(result.current.documents['doc-1']).toBeUndefined();
251
+ expect(result.current.documents['doc-2']).toBeDefined();
252
+ });
253
+ });
254
+
255
+ describe('markDirty', () => {
256
+ it('should mark document as dirty', () => {
257
+ const { result } = renderHook(() => useDocumentStore());
258
+ const mockEditor = createMockEditor() as any;
259
+
260
+ act(() => {
261
+ result.current.initDocumentWithEditor({
262
+ documentId: 'doc-1',
263
+ editor: mockEditor,
264
+ sourceType: 'notebook',
265
+ topicId: 'topic-1',
266
+ });
267
+ });
268
+
269
+ expect(result.current.documents['doc-1'].isDirty).toBe(false);
270
+
271
+ act(() => {
272
+ result.current.markDirty('doc-1');
273
+ });
274
+
275
+ expect(result.current.documents['doc-1'].isDirty).toBe(true);
276
+ });
277
+ });
278
+
279
+ describe('setEditorState', () => {
280
+ it('should set editor state', () => {
281
+ const { result } = renderHook(() => useDocumentStore());
282
+ const mockEditorState = { isBold: true } as any;
283
+
284
+ act(() => {
285
+ result.current.setEditorState(mockEditorState);
286
+ });
287
+
288
+ expect(result.current.editorState).toBe(mockEditorState);
289
+ });
290
+ });
291
+
292
+ describe('getEditorContent', () => {
293
+ it('should return null when no editor', () => {
294
+ const { result } = renderHook(() => useDocumentStore());
295
+
296
+ const content = result.current.getEditorContent();
297
+
298
+ expect(content).toBeNull();
299
+ });
300
+
301
+ it('should return content from editor', () => {
302
+ const { result } = renderHook(() => useDocumentStore());
303
+ const mockEditor = {
304
+ getDocument: vi.fn((type: string) => {
305
+ if (type === 'markdown') return '# Test';
306
+ if (type === 'json') return { type: 'doc' };
307
+ return null;
308
+ }),
309
+ setDocument: vi.fn(),
310
+ } as any;
311
+
312
+ act(() => {
313
+ result.current.initDocumentWithEditor({
314
+ documentId: 'doc-1',
315
+ editor: mockEditor,
316
+ sourceType: 'page',
317
+ });
318
+ });
319
+
320
+ const content = result.current.getEditorContent();
321
+
322
+ expect(content).toEqual({
323
+ editorData: { type: 'doc' },
324
+ markdown: '# Test',
325
+ });
326
+ });
327
+ });
328
+
329
+ describe('flushSave', () => {
330
+ it('should not throw when no active document', () => {
331
+ const { result } = renderHook(() => useDocumentStore());
332
+
333
+ expect(() => {
334
+ act(() => {
335
+ result.current.flushSave();
336
+ });
337
+ }).not.toThrow();
338
+ });
339
+ });
340
+ });
@@ -1,230 +1,214 @@
1
- import { EDITOR_DEBOUNCE_TIME, EDITOR_MAX_WAIT } from '@lobechat/const';
2
- import { type IEditor } from '@lobehub/editor';
3
- import { debounce } from 'es-toolkit/compat';
1
+ 'use client';
2
+
3
+ import type { IEditor } from '@lobehub/editor/es/types';
4
+ import { type EditorState as LobehubEditorState } from '@lobehub/editor/react';
5
+ import isEqual from 'fast-deep-equal';
4
6
  import { type StateCreator } from 'zustand/vanilla';
5
7
 
8
+ import { documentService } from '@/services/document';
6
9
  import { setNamespace } from '@/utils/storeDebug';
7
10
 
8
11
  import type { DocumentStore } from '../../store';
12
+ import { type DocumentDispatch, documentReducer } from './reducer';
9
13
 
10
14
  const n = setNamespace('document/editor');
11
15
 
16
+ /**
17
+ * Metadata passed in at save time (not stored in editor state)
18
+ */
19
+ export interface SaveMetadata {
20
+ emoji?: string;
21
+ title?: string;
22
+ }
23
+
12
24
  export interface EditorAction {
13
25
  /**
14
- * Close the current document
15
- */
16
- closeDocument: () => void;
17
- /**
18
- * Flush any pending debounced save
26
+ * Get current content from editor
19
27
  */
20
- flushSave: () => void;
28
+ getEditorContent: () => { editorData: any; markdown: string } | null;
21
29
  /**
22
30
  * Handle content change from editor
23
31
  */
24
32
  handleContentChange: () => void;
25
33
  /**
26
- * Called when editor is initialized
27
- */
28
- onEditorInit: () => void;
29
- /**
30
- * Open a document for editing
34
+ * Dispatch action to update documents map
31
35
  */
32
- openDocument: (params: {
33
- content: string;
34
- documentId: string;
35
- title: string;
36
- topicId: string;
37
- }) => void;
36
+ internal_dispatchDocument: (payload: DocumentDispatch, action?: string) => void;
38
37
  /**
39
- * Perform save operation
38
+ * Mark a document as dirty
40
39
  */
41
- performSave: () => Promise<void>;
40
+ markDirty: (documentId: string) => void;
42
41
  /**
43
- * Set editor instance
44
- */
45
- setEditor: (editor: IEditor | undefined) => void;
46
- /**
47
- * Set editor state
42
+ * Called when editor is initialized
48
43
  */
49
- setEditorState: (editorState: any) => void;
44
+ onEditorInit: (editor: IEditor) => Promise<void>;
50
45
  /**
51
- * Set edit mode
46
+ * Perform save operation for a document
47
+ * @param documentId - Document ID (defaults to active document)
48
+ * @param metadata - Document metadata (title, emoji) passed from caller
52
49
  */
53
- setMode: (mode: 'edit' | 'preview') => void;
50
+ performSave: (documentId?: string, metadata?: SaveMetadata) => Promise<void>;
54
51
  /**
55
- * Update document title
52
+ * Set editor state from useEditorState hook
56
53
  */
57
- setTitle: (title: string) => void;
54
+ setEditorState: (editorState: LobehubEditorState | undefined) => void;
58
55
  }
59
56
 
60
- // Create debounced save outside store
61
- let debouncedSave: ReturnType<typeof debounce> | null = null;
62
-
63
- const createDebouncedSave = (get: () => DocumentStore) => {
64
- if (debouncedSave) return debouncedSave;
65
-
66
- debouncedSave = debounce(
67
- async () => {
68
- try {
69
- await get().performSave();
70
- } catch (error) {
71
- console.error('[DocumentEditor] Failed to auto-save:', error);
72
- }
73
- },
74
- EDITOR_DEBOUNCE_TIME,
75
- { leading: false, maxWait: EDITOR_MAX_WAIT, trailing: true },
76
- );
77
-
78
- return debouncedSave;
79
- };
80
-
81
57
  export const createEditorSlice: StateCreator<
82
58
  DocumentStore,
83
59
  [['zustand/devtools', never]],
84
60
  [],
85
61
  EditorAction
86
62
  > = (set, get) => ({
87
- closeDocument: () => {
88
- // Flush any pending saves before closing
89
- const save = createDebouncedSave(get);
90
- save.flush();
91
-
92
- set(
93
- {
94
- activeContent: '',
95
- activeDocumentId: undefined,
96
- activeTopicId: undefined,
97
- isDirty: false,
98
- lastSavedContent: '',
99
- lastUpdatedTime: null,
100
- mode: 'edit',
101
- saveStatus: 'idle',
102
- title: '',
103
- },
104
- false,
105
- n('closeDocument'),
106
- );
107
- },
63
+ getEditorContent: () => {
64
+ const { editor } = get();
65
+ if (!editor) return null;
108
66
 
109
- flushSave: () => {
110
- const save = createDebouncedSave(get);
111
- save.flush();
67
+ try {
68
+ const markdown = (editor.getDocument('markdown') as unknown as string) || '';
69
+ const editorData = editor.getDocument('json');
70
+ return { editorData, markdown };
71
+ } catch (error) {
72
+ console.error('[DocumentStore] Failed to get editor content:', error);
73
+ return null;
74
+ }
112
75
  },
113
76
 
114
77
  handleContentChange: () => {
115
- const { editor, lastSavedContent } = get();
116
- if (!editor) return;
78
+ const { editor, activeDocumentId, documents, internal_dispatchDocument } = get();
79
+ if (!editor || !activeDocumentId) return;
80
+
81
+ const doc = documents[activeDocumentId];
82
+ if (!doc) return;
117
83
 
118
84
  try {
119
- const markdownContent = (editor.getDocument('markdown') as unknown as string) || '';
85
+ const markdown = (editor.getDocument('markdown') as unknown as string) || '';
86
+ const editorData = editor.getDocument('json');
120
87
 
121
88
  // Check if content actually changed
122
- const contentChanged = markdownContent !== lastSavedContent;
89
+ const contentChanged = markdown !== doc.lastSavedContent;
123
90
 
124
- set(
125
- { activeContent: markdownContent, isDirty: contentChanged },
126
- false,
127
- n('handleContentChange'),
91
+ internal_dispatchDocument(
92
+ {
93
+ id: activeDocumentId,
94
+ type: 'updateDocument',
95
+ value: { content: markdown, editorData, isDirty: contentChanged },
96
+ },
97
+ 'handleContentChange',
128
98
  );
129
99
 
130
- // Only trigger auto-save if content actually changed
131
- if (contentChanged) {
132
- const save = createDebouncedSave(get);
133
- save();
100
+ // Only trigger auto-save if content actually changed AND autoSave is enabled
101
+ if (contentChanged && doc.autoSave !== false) {
102
+ get().triggerDebouncedSave(activeDocumentId);
134
103
  }
135
104
  } catch (error) {
136
- console.error('[DocumentEditor] Failed to update content:', error);
105
+ console.error('[DocumentStore] Failed to update content:', error);
137
106
  }
138
107
  },
139
108
 
140
- onEditorInit: () => {
141
- const { editor, activeContent } = get();
109
+ internal_dispatchDocument: (payload, action) => {
110
+ const { documents } = get();
111
+ const nextDocuments = documentReducer(documents, payload);
142
112
 
143
- if (editor && activeContent) {
144
- // Set initial content when editor is ready
145
- editor.setDocument('markdown', activeContent);
146
- }
147
- },
148
-
149
- openDocument: ({ content, documentId, title, topicId }) => {
150
- const { editor } = get();
113
+ if (isEqual(documents, nextDocuments)) return;
151
114
 
152
115
  set(
153
- {
154
- activeContent: content,
155
- activeDocumentId: documentId,
156
- activeTopicId: topicId,
157
- isDirty: false,
158
- lastSavedContent: content,
159
- mode: 'edit',
160
- saveStatus: 'idle',
161
- title,
162
- },
116
+ { documents: nextDocuments },
163
117
  false,
164
- n('openDocument', { documentId, topicId }),
118
+ action ?? n(`dispatchDocument/${payload.type}`, { id: payload.id }),
165
119
  );
120
+ },
121
+
122
+ markDirty: (documentId) => {
123
+ const { documents, internal_dispatchDocument } = get();
124
+ if (!documents[documentId]) return;
125
+
126
+ internal_dispatchDocument({ id: documentId, type: 'updateDocument', value: { isDirty: true } });
127
+ },
128
+
129
+ onEditorInit: async (editor) => {
130
+ const { activeDocumentId, documents } = get();
131
+ if (!editor || !activeDocumentId) return;
132
+
133
+ const doc = documents[activeDocumentId];
134
+
135
+ if (!doc) return;
136
+
137
+ // Check if editorData is valid and non-empty
138
+ const hasValidEditorData =
139
+ doc.editorData &&
140
+ typeof doc.editorData === 'object' &&
141
+ Object.keys(doc.editorData).length > 0;
142
+
143
+ // Set content from document state
144
+ if (hasValidEditorData) {
145
+ try {
146
+ editor.setDocument('json', JSON.stringify(doc.editorData));
147
+ return;
148
+ } catch {
149
+ // Fallback to markdown if JSON fails
150
+ console.warn('[DocumentStore] Failed to load editorData, falling back to markdown');
151
+ }
152
+ }
166
153
 
167
- // Set editor content if editor exists
168
- if (editor && content) {
169
- editor.setDocument('markdown', content);
154
+ // Load markdown content or set empty placeholder
155
+ const mdContent = doc.content?.trim() ? doc.content : ' ';
156
+ try {
157
+ editor.setDocument('markdown', mdContent);
158
+ } catch (err) {
159
+ console.error('[DocumentStore] Failed to load markdown content:', err);
170
160
  }
161
+
162
+ set({ editor });
171
163
  },
172
164
 
173
- performSave: async () => {
174
- const { editor, activeDocumentId, title, activeTopicId, isDirty, updateDocument } = get();
165
+ performSave: async (documentId, metadata) => {
166
+ const id = documentId || get().activeDocumentId;
175
167
 
176
- if (!editor || !activeDocumentId || !activeTopicId) return;
168
+ if (!id) return;
169
+
170
+ const { editor, documents, internal_dispatchDocument } = get();
171
+ const doc = documents[id];
172
+ if (!doc || !editor) return;
177
173
 
178
174
  // Skip save if no changes
179
- if (!isDirty) return;
175
+ if (!doc.isDirty) return;
180
176
 
181
- set({ saveStatus: 'saving' }, false, n('performSave:start'));
177
+ // Update save status
178
+ internal_dispatchDocument({ id, type: 'updateDocument', value: { saveStatus: 'saving' } });
182
179
 
183
180
  try {
184
181
  const currentContent = (editor.getDocument('markdown') as unknown as string) || '';
182
+ const currentEditorData = editor.getDocument('json');
185
183
 
186
- // Update document via notebook slice
187
- await updateDocument(
188
- {
189
- content: currentContent,
190
- id: activeDocumentId,
191
- title,
192
- },
193
- activeTopicId,
194
- );
184
+ // Save document
185
+ await documentService.updateDocument({
186
+ content: currentContent,
187
+ editorData: JSON.stringify(currentEditorData),
188
+ id,
189
+ metadata: metadata?.emoji ? { emoji: metadata.emoji } : undefined,
190
+ title: metadata?.title,
191
+ });
195
192
 
196
193
  // Mark as clean and update save status
197
- set(
198
- {
194
+ internal_dispatchDocument({
195
+ id,
196
+ type: 'updateDocument',
197
+ value: {
198
+ editorData: structuredClone(currentEditorData),
199
199
  isDirty: false,
200
200
  lastSavedContent: currentContent,
201
201
  lastUpdatedTime: new Date(),
202
202
  saveStatus: 'saved',
203
203
  },
204
- false,
205
- n('performSave:success'),
206
- );
204
+ });
207
205
  } catch (error) {
208
- console.error('[DocumentEditor] Failed to save:', error);
209
- set({ saveStatus: 'idle' }, false, n('performSave:error'));
206
+ console.error('[DocumentStore] Failed to save:', error);
207
+ internal_dispatchDocument({ id, type: 'updateDocument', value: { saveStatus: 'idle' } });
210
208
  }
211
209
  },
212
210
 
213
- setEditor: (editor) => {
214
- set({ editor }, false, n('setEditor'));
215
- },
216
-
217
211
  setEditorState: (editorState) => {
218
212
  set({ editorState }, false, n('setEditorState'));
219
213
  },
220
-
221
- setMode: (mode) => {
222
- set({ mode }, false, n('setMode', { mode }));
223
- },
224
-
225
- setTitle: (title) => {
226
- set({ isDirty: true, title }, false, n('setTitle'));
227
- const save = createDebouncedSave(get);
228
- save();
229
- },
230
214
  });