@lobehub/lobehub 2.0.0-next.255 → 2.0.0-next.257
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 +50 -0
- package/changelog/v1.json +18 -0
- package/locales/en-US/plugin.json +1 -0
- package/locales/zh-CN/plugin.json +1 -0
- package/package.json +1 -1
- package/packages/builtin-tool-notebook/src/client/Placeholder/CreateDocument.tsx +6 -6
- package/packages/builtin-tool-notebook/src/client/Render/CreateDocument/DocumentCard.tsx +23 -10
- package/packages/builtin-tool-notebook/src/client/Streaming/CreateDocument/index.tsx +1 -1
- package/packages/database/src/models/__tests__/agent.test.ts +91 -4
- package/packages/database/src/models/agent.ts +15 -7
- package/packages/editor-runtime/src/EditorRuntime.ts +1 -1
- package/packages/editor-runtime/src/__tests__/EditorRuntime.real.test.ts +65 -4
- package/packages/editor-runtime/src/__tests__/__snapshots__/EditorRuntime.real.test.ts.snap +108 -17
- package/packages/editor-runtime/src/__tests__/fixtures/remove-then-add.json +636 -0
- package/packages/editor-runtime/src/__tests__/fixtures/remove.json +1 -0
- package/packages/types/src/agent/agentConfig.ts +8 -8
- package/src/app/[variants]/(main)/chat/features/Portal/_layout/Mobile.tsx +2 -1
- package/src/app/[variants]/(main)/group/features/Portal/_layout/Mobile.tsx +2 -1
- package/src/app/[variants]/(main)/home/_layout/hooks/useCreateMenuItems.tsx +2 -2
- package/src/app/[variants]/(main)/page/{features/PageTitle → PageTitle}/index.tsx +0 -1
- package/src/app/[variants]/(main)/page/[id]/index.tsx +43 -1
- package/src/app/[variants]/(main)/page/_layout/Body/AllPagesDrawer/Content.tsx +15 -15
- package/src/app/[variants]/(main)/page/_layout/Body/List/Item/Editing.tsx +3 -3
- package/src/app/[variants]/(main)/page/_layout/Body/List/Item/index.tsx +7 -12
- package/src/app/[variants]/(main)/page/_layout/Body/List/Item/useDropdownMenu.tsx +5 -5
- package/src/app/[variants]/(main)/page/_layout/Body/List/index.tsx +7 -7
- package/src/app/[variants]/(main)/page/_layout/Body/index.tsx +15 -9
- package/src/app/[variants]/(main)/page/_layout/Body/useDropdownMenu.tsx +3 -3
- package/src/app/[variants]/(main)/page/_layout/DataSync.tsx +15 -0
- package/src/app/[variants]/(main)/page/_layout/Header/AddButton.tsx +2 -2
- package/src/app/[variants]/(main)/page/_layout/index.tsx +2 -0
- package/src/app/[variants]/(main)/page/index.tsx +3 -7
- package/src/components/Editor/AutoSaveHint.tsx +1 -1
- package/src/features/Conversation/Messages/User/Actions/index.tsx +5 -1
- package/src/features/EditorCanvas/AutoSaveHint.tsx +37 -0
- package/src/features/{PageEditor → EditorCanvas}/DiffAllToolbar.tsx +57 -16
- package/src/features/EditorCanvas/DocumentIdMode.tsx +111 -0
- package/src/features/EditorCanvas/EditorCanvas.tsx +148 -0
- package/src/features/EditorCanvas/EditorDataMode.tsx +64 -0
- package/src/features/EditorCanvas/ErrorBoundary.tsx +66 -0
- package/src/features/EditorCanvas/InlineToolbar.tsx +245 -0
- package/src/features/EditorCanvas/InternalEditor.tsx +134 -0
- package/src/features/{PageEditor/EditorCanvas → EditorCanvas}/actions.ts +10 -8
- package/src/features/EditorCanvas/index.ts +9 -0
- package/src/features/PageEditor/Copilot/index.tsx +16 -1
- package/src/features/PageEditor/EditorCanvas/index.tsx +14 -111
- package/src/features/PageEditor/EditorCanvas/useAskCopilotItem.tsx +95 -0
- package/src/features/PageEditor/EditorCanvas/useSlashItems.tsx +1 -1
- package/src/features/PageEditor/Header/Breadcrumb.tsx +2 -2
- package/src/features/PageEditor/Header/index.tsx +15 -18
- package/src/features/PageEditor/Header/useMenu.tsx +12 -9
- package/src/features/PageEditor/PageEditor.tsx +45 -21
- package/src/features/PageEditor/PageEditorProvider.tsx +13 -1
- package/src/features/PageEditor/PageTitle/index.tsx +2 -2
- package/src/features/PageEditor/StoreUpdater.tsx +35 -308
- package/src/features/PageEditor/{Body/Title.tsx → TitleSection.tsx} +16 -16
- package/src/features/PageEditor/store/action.ts +96 -188
- package/src/features/PageEditor/store/initialState.ts +16 -21
- package/src/features/PageEditor/store/selectors.ts +3 -4
- package/src/features/PageExplorer/PageExplorerPlaceholder.tsx +22 -14
- package/src/features/PageExplorer/index.tsx +34 -67
- package/src/features/Portal/Artifacts/index.ts +0 -2
- package/src/features/Portal/Document/AutoSaveHint.tsx +7 -6
- package/src/features/Portal/Document/Body.tsx +1 -3
- package/src/features/Portal/Document/EditorCanvas.tsx +7 -50
- package/src/features/Portal/Document/Header.tsx +13 -10
- package/src/features/Portal/Document/TodoList.tsx +6 -4
- package/src/features/Portal/Document/Wrapper.tsx +3 -11
- package/src/features/Portal/Document/index.ts +0 -2
- package/src/features/Portal/FilePreview/index.ts +0 -2
- package/src/features/Portal/GroupThread/index.ts +0 -3
- package/src/features/Portal/MessageDetail/index.ts +0 -2
- package/src/features/Portal/Notebook/index.ts +0 -2
- package/src/features/Portal/Plugins/index.ts +0 -2
- package/src/features/Portal/Thread/index.ts +0 -3
- package/src/features/Portal/components/Header.tsx +18 -6
- package/src/features/Portal/router.tsx +34 -97
- package/src/features/Portal/type.ts +0 -2
- package/src/features/RightPanel/index.tsx +11 -2
- package/src/locales/default/plugin.ts +1 -0
- package/src/store/chat/slices/portal/action.test.ts +218 -15
- package/src/store/chat/slices/portal/action.ts +194 -41
- package/src/store/chat/slices/portal/initialState.ts +40 -1
- package/src/store/chat/slices/portal/selectors/thread.ts +44 -3
- package/src/store/chat/slices/portal/selectors.test.ts +119 -17
- package/src/store/chat/slices/portal/selectors.ts +117 -36
- package/src/store/document/index.ts +17 -5
- package/src/store/document/slices/document/action.ts +209 -0
- package/src/store/document/slices/document/index.ts +6 -0
- package/src/store/document/slices/editor/action.test.ts +340 -0
- package/src/store/document/slices/editor/action.ts +133 -149
- package/src/store/document/slices/editor/index.ts +9 -2
- package/src/store/document/slices/editor/initialState.ts +66 -29
- package/src/store/document/slices/editor/reducer.test.ts +217 -0
- package/src/store/document/slices/editor/reducer.ts +67 -0
- package/src/store/document/slices/editor/selectors.test.ts +395 -0
- package/src/store/document/slices/editor/selectors.ts +107 -5
- package/src/store/document/store.ts +12 -13
- package/src/store/file/slices/document/action.ts +19 -188
- package/src/store/file/slices/document/initialState.ts +0 -30
- package/src/store/file/slices/document/selectors.ts +25 -59
- package/src/store/global/initialState.ts +2 -0
- package/src/store/global/selectors/systemStatus.ts +2 -0
- package/src/store/notebook/index.ts +5 -4
- package/src/store/page/index.ts +2 -0
- package/src/store/page/initialState.ts +92 -0
- package/src/store/page/selectors.ts +5 -0
- package/src/store/page/slices/crud/action.ts +477 -0
- package/src/store/page/slices/crud/index.ts +2 -0
- package/src/store/page/slices/crud/initialState.ts +7 -0
- package/src/store/page/slices/internal/action.ts +32 -0
- package/src/store/page/slices/internal/index.ts +2 -0
- package/src/store/page/slices/internal/reducer.ts +105 -0
- package/src/store/page/slices/list/action.ts +206 -0
- package/src/store/page/slices/list/index.ts +3 -0
- package/src/store/page/slices/list/initialState.ts +29 -0
- package/src/store/page/slices/list/selectors.ts +90 -0
- package/src/store/page/slices/selection/action.ts +67 -0
- package/src/store/page/slices/selection/index.ts +2 -0
- package/src/store/page/slices/selection/initialState.ts +11 -0
- package/src/store/page/store.ts +29 -0
- package/src/store/tool/slices/lobehubSkillStore/selectors.ts +10 -20
- package/src/utils/identifier.ts +8 -2
- package/src/features/PageEditor/Body/index.tsx +0 -68
- package/src/features/PageEditor/EditorCanvas/InlineToolbar.tsx +0 -316
- package/src/features/PageEditor/Header/AutoSaveHint.tsx +0 -27
- package/src/features/Portal/Artifacts/useEnable.ts +0 -4
- package/src/features/Portal/Document/DocumentEditorProvider.tsx +0 -34
- package/src/features/Portal/Document/StoreUpdater.tsx +0 -80
- package/src/features/Portal/Document/Title.tsx +0 -54
- package/src/features/Portal/Document/store/action.ts +0 -114
- package/src/features/Portal/Document/store/index.ts +0 -21
- package/src/features/Portal/Document/store/initialState.ts +0 -24
- package/src/features/Portal/Document/useEnable.ts +0 -8
- package/src/features/Portal/FilePreview/useEnable.ts +0 -6
- package/src/features/Portal/GroupThread/hook.ts +0 -9
- package/src/features/Portal/MessageDetail/useEnable.ts +0 -4
- package/src/features/Portal/Notebook/useEnable.ts +0 -6
- package/src/features/Portal/Plugins/useEnable.ts +0 -6
- package/src/features/Portal/Thread/hook.ts +0 -8
- package/src/store/document/slices/notebook/action.ts +0 -119
- package/src/store/document/slices/notebook/index.ts +0 -3
- package/src/store/document/slices/notebook/initialState.ts +0 -12
- 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
|
-
|
|
2
|
-
|
|
3
|
-
import {
|
|
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
|
-
*
|
|
15
|
-
*/
|
|
16
|
-
closeDocument: () => void;
|
|
17
|
-
/**
|
|
18
|
-
* Flush any pending debounced save
|
|
26
|
+
* Get current content from editor
|
|
19
27
|
*/
|
|
20
|
-
|
|
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
|
-
*
|
|
27
|
-
*/
|
|
28
|
-
onEditorInit: () => void;
|
|
29
|
-
/**
|
|
30
|
-
* Open a document for editing
|
|
34
|
+
* Dispatch action to update documents map
|
|
31
35
|
*/
|
|
32
|
-
|
|
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
|
-
*
|
|
38
|
+
* Mark a document as dirty
|
|
40
39
|
*/
|
|
41
|
-
|
|
40
|
+
markDirty: (documentId: string) => void;
|
|
42
41
|
/**
|
|
43
|
-
*
|
|
44
|
-
*/
|
|
45
|
-
setEditor: (editor: IEditor | undefined) => void;
|
|
46
|
-
/**
|
|
47
|
-
* Set editor state
|
|
42
|
+
* Called when editor is initialized
|
|
48
43
|
*/
|
|
49
|
-
|
|
44
|
+
onEditorInit: (editor: IEditor) => Promise<void>;
|
|
50
45
|
/**
|
|
51
|
-
*
|
|
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
|
-
|
|
50
|
+
performSave: (documentId?: string, metadata?: SaveMetadata) => Promise<void>;
|
|
54
51
|
/**
|
|
55
|
-
*
|
|
52
|
+
* Set editor state from useEditorState hook
|
|
56
53
|
*/
|
|
57
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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,
|
|
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
|
|
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 =
|
|
89
|
+
const contentChanged = markdown !== doc.lastSavedContent;
|
|
123
90
|
|
|
124
|
-
|
|
125
|
-
{
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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('[
|
|
105
|
+
console.error('[DocumentStore] Failed to update content:', error);
|
|
137
106
|
}
|
|
138
107
|
},
|
|
139
108
|
|
|
140
|
-
|
|
141
|
-
const {
|
|
109
|
+
internal_dispatchDocument: (payload, action) => {
|
|
110
|
+
const { documents } = get();
|
|
111
|
+
const nextDocuments = documentReducer(documents, payload);
|
|
142
112
|
|
|
143
|
-
if (
|
|
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(
|
|
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
|
-
//
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
165
|
+
performSave: async (documentId, metadata) => {
|
|
166
|
+
const id = documentId || get().activeDocumentId;
|
|
175
167
|
|
|
176
|
-
if (!
|
|
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
|
-
|
|
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
|
-
//
|
|
187
|
-
await updateDocument(
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
n('performSave:success'),
|
|
206
|
-
);
|
|
204
|
+
});
|
|
207
205
|
} catch (error) {
|
|
208
|
-
console.error('[
|
|
209
|
-
|
|
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
|
});
|