mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-17 04:55:51 +00:00
♻️ refactor: refactor page and notebook document usage (#11345)
* update create document
* refactor
* clean document/notebook slice
* update
* fix agent access issue
* ♻️ refactor: 重构 editorCanvas 实现以支持 Notebook editor 的复用
* fix editor autosave time
* refactor page editor
* update
* fix page editor init issue
* fix page editor data flow
* finish Page refactor
* update editor canvas
* improve notebook document
* update editor runtime test
* update mode
* fix editor hot reload issue
* update mode
* fix
* update
* update
* update
This commit is contained in:
@@ -92,6 +92,7 @@
|
||||
"builtins.lobe-local-system.inspector.noResults": "No results",
|
||||
"builtins.lobe-local-system.inspector.rename.result": "<old>{{oldName}}</old> → <new>{{newName}}</new>",
|
||||
"builtins.lobe-local-system.title": "Local System",
|
||||
"builtins.lobe-notebook.actions.collapse": "Collapse",
|
||||
"builtins.lobe-notebook.actions.copy": "Copy",
|
||||
"builtins.lobe-notebook.actions.creating": "Creating document...",
|
||||
"builtins.lobe-notebook.actions.edit": "Edit",
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
"builtins.lobe-local-system.inspector.noResults": "无结果",
|
||||
"builtins.lobe-local-system.inspector.rename.result": "<old>{{oldName}}</old> → <new>{{newName}}</new>",
|
||||
"builtins.lobe-local-system.title": "本地系统",
|
||||
"builtins.lobe-notebook.actions.collapse": "收起",
|
||||
"builtins.lobe-notebook.actions.copy": "复制",
|
||||
"builtins.lobe-notebook.actions.creating": "文档创建中...",
|
||||
"builtins.lobe-notebook.actions.edit": "编辑",
|
||||
|
||||
@@ -21,7 +21,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
border: 1px solid ${cssVar.colorBorderSecondary};
|
||||
border-radius: 16px;
|
||||
|
||||
background: ${cssVar.colorBgElevated};
|
||||
background: ${cssVar.colorBgContainer};
|
||||
`,
|
||||
content: css`
|
||||
padding-block: 16px;
|
||||
@@ -42,11 +42,11 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
transform: translateX(-50%);
|
||||
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
padding-block: 4px;
|
||||
padding-inline: 12px;
|
||||
height: 32px;
|
||||
padding-inline: 16px;
|
||||
border: 1px solid ${cssVar.colorBorderSecondary};
|
||||
border-radius: 16px;
|
||||
|
||||
@@ -90,8 +90,8 @@ export const CreateDocumentPlaceholder = memo<BuiltinPlaceholderProps<CreateDocu
|
||||
)}
|
||||
</ScrollShadow>
|
||||
<div className={styles.statusTag}>
|
||||
<NeuralNetworkLoading size={14} />
|
||||
<span style={{ fontSize: 12 }}>{t('builtins.lobe-notebook.actions.creating')}</span>
|
||||
<NeuralNetworkLoading size={16} />
|
||||
<span>{t('builtins.lobe-notebook.actions.creating')}</span>
|
||||
</div>
|
||||
</Flexbox>
|
||||
);
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
import { ActionIcon, CopyButton, Flexbox, Markdown, ScrollShadow } from '@lobehub/ui';
|
||||
import { Button } from 'antd';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import { Maximize2, NotebookText, PencilLine } from 'lucide-react';
|
||||
import { Maximize2, Minimize2, NotebookText, PencilLine } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { chatPortalSelectors } from '@/store/chat/slices/portal/selectors';
|
||||
|
||||
import { NotebookDocument } from '../../../types';
|
||||
|
||||
@@ -21,7 +22,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
border: 1px solid ${cssVar.colorBorderSecondary};
|
||||
border-radius: 16px;
|
||||
|
||||
background: ${cssVar.colorBgElevated};
|
||||
background: ${cssVar.colorBgContainer};
|
||||
`,
|
||||
content: css`
|
||||
padding-inline: 16px;
|
||||
@@ -60,10 +61,20 @@ interface DocumentCardProps {
|
||||
|
||||
const DocumentCard = memo<DocumentCardProps>(({ document }) => {
|
||||
const { t } = useTranslation('plugin');
|
||||
const openDocument = useChatStore((s) => s.openDocument);
|
||||
const [portalDocumentId, openDocument, closeDocument] = useChatStore((s) => [
|
||||
chatPortalSelectors.portalDocumentId(s),
|
||||
s.openDocument,
|
||||
s.closeDocument,
|
||||
]);
|
||||
|
||||
const handleExpand = () => {
|
||||
openDocument(document.id);
|
||||
const isExpanded = portalDocumentId === document.id;
|
||||
|
||||
const handleToggle = () => {
|
||||
if (isExpanded) {
|
||||
closeDocument();
|
||||
} else {
|
||||
openDocument(document.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -82,7 +93,7 @@ const DocumentCard = memo<DocumentCardProps>(({ document }) => {
|
||||
/>
|
||||
<ActionIcon
|
||||
icon={PencilLine}
|
||||
onClick={handleExpand}
|
||||
onClick={handleToggle}
|
||||
size={'small'}
|
||||
title={t('builtins.lobe-notebook.actions.edit')}
|
||||
/>
|
||||
@@ -95,16 +106,18 @@ const DocumentCard = memo<DocumentCardProps>(({ document }) => {
|
||||
</Markdown>
|
||||
</ScrollShadow>
|
||||
|
||||
{/* Floating expand button */}
|
||||
{/* Floating expand/collapse button */}
|
||||
<Button
|
||||
className={styles.expandButton}
|
||||
color={'default'}
|
||||
icon={<Maximize2 size={14} />}
|
||||
onClick={handleExpand}
|
||||
icon={isExpanded ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
|
||||
onClick={handleToggle}
|
||||
shape={'round'}
|
||||
variant={'outlined'}
|
||||
>
|
||||
{t('builtins.lobe-notebook.actions.expand')}
|
||||
{isExpanded
|
||||
? t('builtins.lobe-notebook.actions.collapse')
|
||||
: t('builtins.lobe-notebook.actions.expand')}
|
||||
</Button>
|
||||
</Flexbox>
|
||||
);
|
||||
|
||||
@@ -20,7 +20,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
border: 1px solid ${cssVar.colorBorderSecondary};
|
||||
border-radius: 16px;
|
||||
|
||||
background: ${cssVar.colorBgElevated};
|
||||
background: ${cssVar.colorBgContainer};
|
||||
`,
|
||||
header: css`
|
||||
padding-block: 10px;
|
||||
|
||||
@@ -155,6 +155,33 @@ describe('AgentModel', () => {
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.files).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not return agent belonging to another user', async () => {
|
||||
const agentId = 'test-agent-other-user';
|
||||
// Create agent for user2
|
||||
await serverDB.insert(agents).values({ id: agentId, userId: userId2 });
|
||||
|
||||
// Try to access with user1's model
|
||||
const result = await agentModel.getAgentConfigById(agentId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should not return knowledge from another user agent', async () => {
|
||||
const agentId = 'test-agent-cross-user-knowledge';
|
||||
// Create agent for user2 with knowledge
|
||||
await serverDB.insert(agents).values({ id: agentId, userId: userId2 });
|
||||
await serverDB
|
||||
.insert(agentsKnowledgeBases)
|
||||
.values({ agentId, knowledgeBaseId: 'kb2', userId: userId2 });
|
||||
await serverDB.insert(agentsFiles).values({ agentId, fileId: '3', userId: userId2 });
|
||||
|
||||
// Try to access with user1's model
|
||||
const result = await agentModel.getAgentConfigById(agentId);
|
||||
|
||||
// Should return null since user1 cannot access user2's agent
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAgentConfig', () => {
|
||||
@@ -197,15 +224,14 @@ describe('AgentModel', () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should find agent by ID even if it belongs to another user', async () => {
|
||||
it('should not find agent by ID if it belongs to another user', async () => {
|
||||
const agentId = 'test-agent-cross-user';
|
||||
await serverDB.insert(agents).values({ id: agentId, userId: userId2 });
|
||||
|
||||
// ID lookup should work across users (ID is globally unique)
|
||||
// ID lookup should not work across users for security
|
||||
const result = await agentModel.getAgentConfig(agentId);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.id).toBe(agentId);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should prefer ID match over slug match', async () => {
|
||||
@@ -257,6 +283,67 @@ describe('AgentModel', () => {
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not return agent from another user session', async () => {
|
||||
const agentId = 'test-agent-other-user-session';
|
||||
const sessionId = 'test-session-other-user';
|
||||
// Create agent and session for user2
|
||||
await serverDB.insert(agents).values({ id: agentId, userId: userId2 });
|
||||
await serverDB.insert(sessions).values({ id: sessionId, userId: userId2 });
|
||||
await serverDB
|
||||
.insert(agentsToSessions)
|
||||
.values({ agentId, sessionId, userId: userId2 });
|
||||
|
||||
// Try to access with user1's model
|
||||
const result = await agentModel.findBySessionId(sessionId);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAgentAssignedKnowledge', () => {
|
||||
it('should return knowledge bases and files for the agent', async () => {
|
||||
const agentId = 'test-agent-knowledge';
|
||||
await serverDB.insert(agents).values({ id: agentId, userId });
|
||||
await serverDB
|
||||
.insert(agentsKnowledgeBases)
|
||||
.values({ agentId, knowledgeBaseId: 'kb1', userId, enabled: true });
|
||||
await serverDB.insert(agentsFiles).values({ agentId, fileId: '1', userId, enabled: true });
|
||||
|
||||
const result = await agentModel.getAgentAssignedKnowledge(agentId);
|
||||
|
||||
expect(result.knowledgeBases).toHaveLength(1);
|
||||
expect(result.files).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should not return knowledge from another user', async () => {
|
||||
const agentId = 'test-agent-knowledge-other-user';
|
||||
// Create agent with knowledge for user2
|
||||
await serverDB.insert(agents).values({ id: agentId, userId: userId2 });
|
||||
await serverDB
|
||||
.insert(agentsKnowledgeBases)
|
||||
.values({ agentId, knowledgeBaseId: 'kb2', userId: userId2, enabled: true });
|
||||
await serverDB
|
||||
.insert(agentsFiles)
|
||||
.values({ agentId, fileId: '3', userId: userId2, enabled: true });
|
||||
|
||||
// Try to access with user1's model
|
||||
const result = await agentModel.getAgentAssignedKnowledge(agentId);
|
||||
|
||||
// Should return empty arrays since user1 cannot access user2's knowledge
|
||||
expect(result.knowledgeBases).toHaveLength(0);
|
||||
expect(result.files).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle empty knowledge bases and files', async () => {
|
||||
const agentId = 'test-agent-no-knowledge';
|
||||
await serverDB.insert(agents).values({ id: agentId, userId });
|
||||
|
||||
const result = await agentModel.getAgentAssignedKnowledge(agentId);
|
||||
|
||||
expect(result.knowledgeBases).toHaveLength(0);
|
||||
expect(result.files).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAgentKnowledgeBase', () => {
|
||||
|
||||
@@ -28,7 +28,9 @@ export class AgentModel {
|
||||
}
|
||||
|
||||
getAgentConfigById = async (id: string) => {
|
||||
const agent = await this.db.query.agents.findFirst({ where: eq(agents.id, id) });
|
||||
const agent = await this.db.query.agents.findFirst({
|
||||
where: and(eq(agents.id, id), eq(agents.userId, this.userId)),
|
||||
});
|
||||
|
||||
if (!agent) return null;
|
||||
|
||||
@@ -76,9 +78,9 @@ export class AgentModel {
|
||||
*/
|
||||
getAgentConfig = async (idOrSlug: string) => {
|
||||
const agent = await this.db.query.agents.findFirst({
|
||||
where: or(
|
||||
eq(agents.id, idOrSlug),
|
||||
and(eq(agents.slug, idOrSlug), eq(agents.userId, this.userId)),
|
||||
where: and(
|
||||
eq(agents.userId, this.userId),
|
||||
or(eq(agents.id, idOrSlug), eq(agents.slug, idOrSlug)),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -118,17 +120,20 @@ export class AgentModel {
|
||||
|
||||
getAgentAssignedKnowledge = async (id: string) => {
|
||||
// Run both queries in parallel for better performance
|
||||
// Include userId check to ensure user can only access their own agent's knowledge
|
||||
const [knowledgeBaseResult, fileResult] = await Promise.all([
|
||||
this.db
|
||||
.select({ enabled: agentsKnowledgeBases.enabled, knowledgeBases })
|
||||
.from(agentsKnowledgeBases)
|
||||
.where(eq(agentsKnowledgeBases.agentId, id))
|
||||
.where(
|
||||
and(eq(agentsKnowledgeBases.agentId, id), eq(agentsKnowledgeBases.userId, this.userId)),
|
||||
)
|
||||
.orderBy(desc(agentsKnowledgeBases.createdAt))
|
||||
.leftJoin(knowledgeBases, eq(knowledgeBases.id, agentsKnowledgeBases.knowledgeBaseId)),
|
||||
this.db
|
||||
.select({ enabled: agentsFiles.enabled, files })
|
||||
.from(agentsFiles)
|
||||
.where(eq(agentsFiles.agentId, id))
|
||||
.where(and(eq(agentsFiles.agentId, id), eq(agentsFiles.userId, this.userId)))
|
||||
.orderBy(desc(agentsFiles.createdAt))
|
||||
.leftJoin(files, eq(files.id, agentsFiles.fileId)),
|
||||
]);
|
||||
@@ -150,7 +155,10 @@ export class AgentModel {
|
||||
*/
|
||||
findBySessionId = async (sessionId: string) => {
|
||||
const item = await this.db.query.agentsToSessions.findFirst({
|
||||
where: eq(agentsToSessions.sessionId, sessionId),
|
||||
where: and(
|
||||
eq(agentsToSessions.sessionId, sessionId),
|
||||
eq(agentsToSessions.userId, this.userId),
|
||||
),
|
||||
});
|
||||
|
||||
if (!item) return;
|
||||
|
||||
@@ -109,7 +109,7 @@ export class EditorRuntime {
|
||||
}
|
||||
|
||||
// Set markdown content directly - the editor will convert it internally
|
||||
editor.setDocument('markdown', markdown);
|
||||
editor.setDocument('markdown', markdown, { keepId: true });
|
||||
|
||||
// Get the resulting document to count nodes
|
||||
const jsonState = editor.getDocument('json') as any;
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
CommonPlugin,
|
||||
type IEditor,
|
||||
Kernel,
|
||||
ListPlugin,
|
||||
LitexmlPlugin,
|
||||
MarkdownPlugin,
|
||||
moment,
|
||||
@@ -10,6 +11,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { EditorRuntime } from '../EditorRuntime';
|
||||
import editAllFixture from './fixtures/edit-all.json';
|
||||
import removeThenAddFixture from './fixtures/remove-then-add.json';
|
||||
import removeFixture from './fixtures/remove.json';
|
||||
|
||||
describe('EditorRuntime - Real Cases', () => {
|
||||
@@ -20,7 +22,7 @@ describe('EditorRuntime - Real Cases', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
editor = new Kernel();
|
||||
editor.registerPlugins([CommonPlugin, MarkdownPlugin, LitexmlPlugin]);
|
||||
editor.registerPlugins([CommonPlugin, MarkdownPlugin, ListPlugin, LitexmlPlugin]);
|
||||
editor.initNodeEditor();
|
||||
|
||||
runtime = new EditorRuntime();
|
||||
@@ -163,9 +165,6 @@ describe('EditorRuntime - Real Cases', () => {
|
||||
|
||||
// Verify paragraphs were removed
|
||||
const xmlAfter = editor.getDocument('litexml') as unknown as string;
|
||||
const paragraphsAfter = [...xmlAfter.matchAll(/<p id="([^"]+)"/g)];
|
||||
|
||||
expect(paragraphsAfter.length).toBe(initialCount - 7);
|
||||
|
||||
// Verify the removed IDs are no longer present
|
||||
expect(xmlAfter).not.toContain('id="wps3"');
|
||||
@@ -179,4 +178,66 @@ describe('EditorRuntime - Real Cases', () => {
|
||||
expect(xmlAfter).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('modifyNodes - remove then add', () => {
|
||||
it('should remove 13 paragraphs then insert a list', async () => {
|
||||
// Initialize editor with the JSON fixture
|
||||
editor.setDocument('json', removeThenAddFixture);
|
||||
await moment();
|
||||
|
||||
// First operation: remove 13 paragraphs
|
||||
const removeResult = await runtime.modifyNodes({
|
||||
operations: [
|
||||
{ action: 'remove', id: 'x4qr' },
|
||||
{ action: 'remove', id: 'xfvd' },
|
||||
{ action: 'remove', id: 'xqzz' },
|
||||
{ action: 'remove', id: 'zrby' },
|
||||
{ action: 'remove', id: '02gk' },
|
||||
{ action: 'remove', id: '0dl6' },
|
||||
{ action: 'remove', id: '0ops' },
|
||||
{ action: 'remove', id: '1rnx' },
|
||||
{ action: 'remove', id: '22sj' },
|
||||
{ action: 'remove', id: '2dx5' },
|
||||
{ action: 'remove', id: '3gva' },
|
||||
{ action: 'remove', id: '3rzw' },
|
||||
{ action: 'remove', id: '434i' },
|
||||
],
|
||||
});
|
||||
|
||||
await moment();
|
||||
|
||||
// Verify all remove operations succeeded
|
||||
expect(removeResult.successCount).toBe(13);
|
||||
expect(removeResult.totalCount).toBe(13);
|
||||
expect(removeResult.results.every((r) => r.success)).toBe(true);
|
||||
expect(removeResult.results.every((r) => r.action === 'remove')).toBe(true);
|
||||
|
||||
// Verify the content was removed
|
||||
const removed = editor.getDocument('litexml') as unknown as string;
|
||||
expect(removed).toMatchSnapshot('remove 13 paragraphs');
|
||||
|
||||
// Second operation: insert a list after wtm5
|
||||
const insertResult = await runtime.modifyNodes({
|
||||
operations: [
|
||||
{
|
||||
action: 'insert',
|
||||
afterId: 'wtm5',
|
||||
litexml:
|
||||
'<ul><li>西湖风景区:杭州的灵魂,世界文化遗产</li><li>灵隐寺:杭州最著名的佛教寺庙</li><li>西溪国家湿地公园:中国第一个国家湿地公园</li><li>宋城:以宋代文化为主题的大型主题公园</li></ul>',
|
||||
},
|
||||
],
|
||||
});
|
||||
await moment();
|
||||
|
||||
// Verify insert operation succeeded
|
||||
expect(insertResult.successCount).toBe(1);
|
||||
expect(insertResult.totalCount).toBe(1);
|
||||
expect(insertResult.results.every((r) => r.success)).toBe(true);
|
||||
expect(insertResult.results.every((r) => r.action === 'insert')).toBe(true);
|
||||
|
||||
// Verify full output
|
||||
const xmlAfter = editor.getDocument('litexml') as unknown as string;
|
||||
expect(xmlAfter).toMatchSnapshot('insert new');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+108
-17
@@ -1,37 +1,128 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`EditorRuntime - Real Cases > modifyNodes - batch modify all paragraphs > should modify all 16 paragraphs in a single call 1`] = `
|
||||
"(雨点敲打着咖啡馆的玻璃窗,像无数细小的手指在弹奏着无声的钢琴。林晓坐在靠窗的位置,手中的咖啡已经凉了,她却浑然不觉。这是她第三次来到这家咖啡馆,每次都是同样的位置,同样的时间。)
|
||||
"(雨点敲打着咖啡馆的玻璃窗,像无数细小的手指在弹奏着无声的钢琴。林晓坐在靠窗的位置,手中的咖啡已经凉了,她却浑然不觉。这是她第三次来到这家咖啡馆,每次都是同样的位置,同样的时间。)
|
||||
|
||||
(窗外雨丝如帘,街灯昏黄。咖啡馆内灯光柔和,墙上挂着旧照片,书架上摆满了书。空气里是咖啡香和旧书纸的味道。)
|
||||
(窗外雨丝如帘,街灯昏黄。咖啡馆内灯光柔和,墙上挂着旧照片,书架上摆满了书。空气里是咖啡香和旧书纸的味道。)
|
||||
|
||||
林晓:(内心独白)第一次来的时候,也是这样的雨夜。那天我刚结束一段五年的感情,整个人像是被掏空了。我点了一杯美式咖啡,就这样坐着,看着窗外的雨,直到打烊。
|
||||
林晓:(内心独白)第一次来的时候,也是这样的雨夜。那天我刚结束一段五年的感情,整个人像是被掏空了。我点了一杯美式咖啡,就这样坐着,看着窗外的雨,直到打烊。
|
||||
|
||||
林晓:(继续独白)第二次来的时候,我遇到了他。穿着灰色风衣,坐在对面的位置。他一直在看书,偶尔抬头看看窗外。他的手指修长,翻书的动作优雅从容。
|
||||
林晓:(继续独白)第二次来的时候,我遇到了他。穿着灰色风衣,坐在对面的位置。他一直在看书,偶尔抬头看看窗外。他的手指修长,翻书的动作优雅从容。
|
||||
|
||||
林晓:(独白)今天,我又来了。雨还是那样下着,咖啡馆还是那样安静。我不知道自己在期待什么,也许只是习惯了这种孤独的仪式感。
|
||||
林晓:(独白)今天,我又来了。雨还是那样下着,咖啡馆还是那样安静。我不知道自己在期待什么,也许只是习惯了这种孤独的仪式感。
|
||||
|
||||
(门上的风铃响了,有人推门进来。林晓下意识地抬头,心跳突然漏了一拍。)
|
||||
(门上的风铃响了,有人推门进来。林晓下意识地抬头,心跳突然漏了一拍。)
|
||||
|
||||
(风铃叮咚作响。一个身影站在门口,雨伞滴着水,灯光勾勒出他的轮廓。)
|
||||
(风铃叮咚作响。一个身影站在门口,雨伞滴着水,灯光勾勒出他的轮廓。)
|
||||
|
||||
林晓:(低声)是他。
|
||||
林晓:(低声)是他。
|
||||
|
||||
(他收起雨伞,抖了抖身上的水珠,然后径直走向她。这一次,他没有坐在对面的位置,而是在她面前停了下来。)
|
||||
(他收起雨伞,抖了抖身上的水珠,然后径直走向她。这一次,他没有坐在对面的位置,而是在她面前停了下来。)
|
||||
|
||||
陈默:(声音低沉温和)我可以坐这里吗?
|
||||
陈默:(声音低沉温和)我可以坐这里吗?
|
||||
|
||||
(林晓点了点头,喉咙有些发干。窗外的雨声似乎变小了,咖啡馆里的音乐也变得清晰起来。)
|
||||
(林晓点了点头,喉咙有些发干。窗外的雨声似乎变小了,咖啡馆里的音乐也变得清晰起来。)
|
||||
|
||||
陈默:(眼中带着笑意)我注意到你每次都在这里。
|
||||
陈默:(眼中带着笑意)我注意到你每次都在这里。
|
||||
|
||||
林晓:(凝视着他,内心独白)他的眼睛是深褐色的,像秋天的落叶,温暖而深邃。他的鼻梁挺直,唇线分明,微笑时眼角有细微的皱纹,更添了几分沧桑感。我忽然觉得这个人似曾相识,却又分明是第一次见面。
|
||||
林晓:(凝视着他,内心独白)他的眼睛是深褐色的,像秋天的落叶,温暖而深邃。他的鼻梁挺直,唇线分明,微笑时眼角有细微的皱纹,更添了几分沧桑感。我忽然觉得这个人似曾相识,却又分明是第一次见面。
|
||||
|
||||
陈默:(伸出手)我叫陈默。很高兴终于能和你说话。我观察你三次了,每次你都这样静静地坐着看雨,若有所思,若有所待。
|
||||
陈默:(伸出手)我叫陈默。很高兴终于能和你说话。我观察你三次了,每次你都这样静静地坐着看雨,若有所思,若有所待。
|
||||
|
||||
(林晓握住他的手,感受到他掌心的温度。他的手温暖,却有薄茧,像是经常写字或弹琴的人。雨还在下,但咖啡馆里忽然觉得温暖如春,先前的孤独寂寥,竟然悄然消散了。)
|
||||
|
||||
(旁白)也许,有些相遇注定要在雨夜发生。就像有些故事,注定要从一句简单的问候开始。
|
||||
(林晓握住他的手,感受到他掌心的温度。他的手温暖,却有薄茧,像是经常写字或弹琴的人。雨还在下,但咖啡馆里忽然觉得温暖如春,先前的孤独寂寥,竟然悄然消散了。)
|
||||
|
||||
(旁白)也许,有些相遇注定要在雨夜发生。就像有些故事,注定要从一句简单的问候开始。
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`EditorRuntime - Real Cases > modifyNodes - batch remove paragraphs > should remove 7 paragraphs in a single call 1`] = `
|
||||
"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<root>
|
||||
<p id="lwap">
|
||||
<span id="m1v0">杭州,一座被诗意浸润的千年古都,静静地依偎在钱塘江畔,宛如一幅徐徐展开的水墨长卷。这里不仅是浙江省的政治、经济、文化中心,更是中国七大古都之一,承载着2200余年的历史记忆。从南宋临安的繁华盛景到今日数字经济的创新高地,杭州始终以"人间天堂"的美誉,向世界展示着东方文明的独特魅力。</span>
|
||||
</p>
|
||||
<h2 id="m7fb">
|
||||
<span id="mczm">地理与气候</span>
|
||||
</h2>
|
||||
<p id="mijx">
|
||||
<span id="mo48">杭州地处钱塘江下游,京杭大运河南端,东临杭州湾,西接天目山。全市总面积16850平方公里,常住人口超过1200万。杭州属于亚热带季风气候,四季分明,雨量充沛,年平均气温17.8℃,气候宜人。</span>
|
||||
</p>
|
||||
<h2 id="mtoj">
|
||||
<span id="mz8u">历史文化</span>
|
||||
</h2>
|
||||
<p id="n4t5">
|
||||
<span id="nadg">杭州是吴越文化和南宋文化的发源地之一。公元1138年,南宋定都临安(今杭州),使其成为当时世界上最繁华的城市之一。杭州拥有丰富的历史文化遗产,包括西湖文化景观、京杭大运河、良渚古城遗址等世界文化遗产。</span>
|
||||
</p>
|
||||
<p id="vbpc">
|
||||
<span id="vh9n">南宋时期(1127-1279年)是杭州历史上的黄金时代。宋室南渡后定都临安(今杭州),使其成为当时世界上人口最多、经济最繁荣的城市之一。马可·波罗在游记中称杭州为"世界上最美丽华贵之天城"。南宋时期杭州的工商业、文化艺术、科学技术都达到了空前的高度:</span>
|
||||
</p>
|
||||
<ul id="t5t2">
|
||||
<li id="tbdd">
|
||||
<span id="tgxo" bold="true">经济繁荣</span>
|
||||
<span id="tmhz">:丝绸、瓷器、茶叶贸易发达,出现了世界上最早的纸币"交子"</span>
|
||||
</li>
|
||||
<li id="ts2a">
|
||||
<span id="txml" bold="true">文化鼎盛</span>
|
||||
<span id="u36w">:宋词达到艺术高峰,苏轼、柳永、李清照等文人雅士云集</span>
|
||||
</li>
|
||||
<li id="u8r7">
|
||||
<span id="uebi" bold="true">科技创新</span>
|
||||
<span id="ujvt">:活字印刷术、指南针、火药等重大发明得到广泛应用</span>
|
||||
</li>
|
||||
<li id="upg4">
|
||||
<span id="uv0f" bold="true">城市建设</span>
|
||||
<span id="v0kq">:形成了"前朝后市"的格局,御街、清河坊等商业区繁华异常</span>
|
||||
</li>
|
||||
</ul>
|
||||
<h2 id="nfxr">
|
||||
<span id="nli2">西湖风光</span>
|
||||
</h2>
|
||||
<p id="nr2d">
|
||||
<span id="nwmo">西湖是杭州的灵魂,也是中国最著名的风景名胜之一。西湖十景(如苏堤春晓、断桥残雪、雷峰夕照等)闻名遐迩。西湖不仅自然风光秀丽,更承载着深厚的文化内涵,历代文人墨客在此留下了无数诗词歌赋。</span>
|
||||
</p>
|
||||
</root>"
|
||||
`;
|
||||
|
||||
exports[`EditorRuntime - Real Cases > modifyNodes - remove then add > should remove 13 paragraphs then insert a list > insert new 1`] = `
|
||||
"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<root>
|
||||
<p id="wihj">
|
||||
<span id="wo1u">杭州是中国著名的旅游城市,素有"人间天堂"的美誉。这里既有美丽的自然风光,又有深厚的历史文化底蕴。</span>
|
||||
</p>
|
||||
<h2 id="wtm5">
|
||||
<span id="wz6g">必游景点</span>
|
||||
</h2>
|
||||
<ul id="fiv4">
|
||||
<li id="foff">
|
||||
<span id="ftzq">西湖风景区:杭州的灵魂,世界文化遗产</span>
|
||||
</li>
|
||||
<li id="fzk1">
|
||||
<span id="g54c">灵隐寺:杭州最著名的佛教寺庙</span>
|
||||
</li>
|
||||
<li id="gaon">
|
||||
<span id="gg8y">西溪国家湿地公园:中国第一个国家湿地公园</span>
|
||||
</li>
|
||||
<li id="glt9">
|
||||
<span id="grdk">宋城:以宋代文化为主题的大型主题公园</span>
|
||||
</li>
|
||||
</ul>
|
||||
<h2 id="562n">
|
||||
<span id="5bmy">美食推荐</span>
|
||||
</h2>
|
||||
</root>"
|
||||
`;
|
||||
|
||||
exports[`EditorRuntime - Real Cases > modifyNodes - remove then add > should remove 13 paragraphs then insert a list > remove 13 paragraphs 1`] = `
|
||||
"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<root>
|
||||
<p id="wihj">
|
||||
<span id="wo1u">杭州是中国著名的旅游城市,素有"人间天堂"的美誉。这里既有美丽的自然风光,又有深厚的历史文化底蕴。</span>
|
||||
</p>
|
||||
<h2 id="wtm5">
|
||||
<span id="wz6g">必游景点</span>
|
||||
</h2>
|
||||
<h2 id="562n">
|
||||
<span id="5bmy">美食推荐</span>
|
||||
</h2>
|
||||
</root>"
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,636 @@
|
||||
{
|
||||
"keepId": true,
|
||||
"root": {
|
||||
"children": [
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"detail": 0,
|
||||
"format": 0,
|
||||
"mode": "normal",
|
||||
"style": "",
|
||||
"text": "杭州是中国著名的旅游城市,素有\"人间天堂\"的美誉。这里既有美丽的自然风光,又有深厚的历史文化底蕴。",
|
||||
"type": "text",
|
||||
"version": 1,
|
||||
"id": "17542"
|
||||
}
|
||||
],
|
||||
"direction": "ltr",
|
||||
"format": "",
|
||||
"indent": 0,
|
||||
"type": "paragraph",
|
||||
"version": 1,
|
||||
"textFormat": 0,
|
||||
"textStyle": "",
|
||||
"id": "17541"
|
||||
},
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"detail": 0,
|
||||
"format": 0,
|
||||
"mode": "normal",
|
||||
"style": "",
|
||||
"text": "必游景点",
|
||||
"type": "text",
|
||||
"version": 1,
|
||||
"id": "17544"
|
||||
}
|
||||
],
|
||||
"direction": "ltr",
|
||||
"format": "",
|
||||
"indent": 0,
|
||||
"type": "heading",
|
||||
"version": 1,
|
||||
"tag": "h2",
|
||||
"id": "17543"
|
||||
},
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"detail": 0,
|
||||
"format": 0,
|
||||
"mode": "normal",
|
||||
"style": "",
|
||||
"text": "西湖风景区",
|
||||
"type": "text",
|
||||
"version": 1,
|
||||
"id": "17546"
|
||||
}
|
||||
],
|
||||
"direction": "ltr",
|
||||
"format": "",
|
||||
"indent": 0,
|
||||
"type": "heading",
|
||||
"version": 1,
|
||||
"tag": "h3",
|
||||
"id": "17545"
|
||||
},
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"detail": 0,
|
||||
"format": 0,
|
||||
"mode": "normal",
|
||||
"style": "",
|
||||
"text": "西湖是杭州的灵魂,被联合国教科文组织列为世界文化遗产。主要景点包括:",
|
||||
"type": "text",
|
||||
"version": 1,
|
||||
"id": "17548"
|
||||
}
|
||||
],
|
||||
"direction": "ltr",
|
||||
"format": "",
|
||||
"indent": 0,
|
||||
"type": "paragraph",
|
||||
"version": 1,
|
||||
"textFormat": 0,
|
||||
"textStyle": "",
|
||||
"id": "17547"
|
||||
},
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"detail": 0,
|
||||
"format": 1,
|
||||
"mode": "normal",
|
||||
"style": "",
|
||||
"text": "苏堤春晓",
|
||||
"type": "text",
|
||||
"version": 1,
|
||||
"id": "17551"
|
||||
},
|
||||
{
|
||||
"detail": 0,
|
||||
"format": 0,
|
||||
"mode": "normal",
|
||||
"style": "",
|
||||
"text": ":春季赏花的最佳地点",
|
||||
"type": "text",
|
||||
"version": 1,
|
||||
"id": "17552"
|
||||
}
|
||||
],
|
||||
"direction": "ltr",
|
||||
"format": "",
|
||||
"indent": 0,
|
||||
"type": "listitem",
|
||||
"version": 1,
|
||||
"value": 1,
|
||||
"id": "17550"
|
||||
},
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"detail": 0,
|
||||
"format": 1,
|
||||
"mode": "normal",
|
||||
"style": "",
|
||||
"text": "断桥残雪",
|
||||
"type": "text",
|
||||
"version": 1,
|
||||
"id": "17554"
|
||||
},
|
||||
{
|
||||
"detail": 0,
|
||||
"format": 0,
|
||||
"mode": "normal",
|
||||
"style": "",
|
||||
"text": ":冬季雪景格外迷人",
|
||||
"type": "text",
|
||||
"version": 1,
|
||||
"id": "17555"
|
||||
}
|
||||
],
|
||||
"direction": "ltr",
|
||||
"format": "",
|
||||
"indent": 0,
|
||||
"type": "listitem",
|
||||
"version": 1,
|
||||
"value": 2,
|
||||
"id": "17553"
|
||||
},
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"detail": 0,
|
||||
"format": 1,
|
||||
"mode": "normal",
|
||||
"style": "",
|
||||
"text": "雷峰夕照",
|
||||
"type": "text",
|
||||
"version": 1,
|
||||
"id": "17557"
|
||||
},
|
||||
{
|
||||
"detail": 0,
|
||||
"format": 0,
|
||||
"mode": "normal",
|
||||
"style": "",
|
||||
"text": ":傍晚时分观赏日落",
|
||||
"type": "text",
|
||||
"version": 1,
|
||||
"id": "17558"
|
||||
}
|
||||
],
|
||||
"direction": "ltr",
|
||||
"format": "",
|
||||
"indent": 0,
|
||||
"type": "listitem",
|
||||
"version": 1,
|
||||
"value": 3,
|
||||
"id": "17556"
|
||||
},
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"detail": 0,
|
||||
"format": 1,
|
||||
"mode": "normal",
|
||||
"style": "",
|
||||
"text": "三潭印月",
|
||||
"type": "text",
|
||||
"version": 1,
|
||||
"id": "17560"
|
||||
},
|
||||
{
|
||||
"detail": 0,
|
||||
"format": 0,
|
||||
"mode": "normal",
|
||||
"style": "",
|
||||
"text": ":湖中三座石塔,夜晚灯光璀璨",
|
||||
"type": "text",
|
||||
"version": 1,
|
||||
"id": "17561"
|
||||
}
|
||||
],
|
||||
"direction": "ltr",
|
||||
"format": "",
|
||||
"indent": 0,
|
||||
"type": "listitem",
|
||||
"version": 1,
|
||||
"value": 4,
|
||||
"id": "17559"
|
||||
}
|
||||
],
|
||||
"direction": "ltr",
|
||||
"format": "",
|
||||
"indent": 0,
|
||||
"type": "list",
|
||||
"version": 1,
|
||||
"listType": "bullet",
|
||||
"start": 1,
|
||||
"tag": "ul",
|
||||
"id": "17549"
|
||||
},
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"detail": 0,
|
||||
"format": 0,
|
||||
"mode": "normal",
|
||||
"style": "",
|
||||
"text": "阿斯达",
|
||||
"type": "text",
|
||||
"version": 1,
|
||||
"id": "17563"
|
||||
}
|
||||
],
|
||||
"direction": null,
|
||||
"format": "",
|
||||
"indent": 0,
|
||||
"type": "paragraph",
|
||||
"version": 1,
|
||||
"textFormat": 0,
|
||||
"textStyle": "",
|
||||
"id": "17562"
|
||||
},
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"detail": 0,
|
||||
"format": 0,
|
||||
"mode": "normal",
|
||||
"style": "",
|
||||
"text": "灵隐寺",
|
||||
"type": "text",
|
||||
"version": 1,
|
||||
"id": "17565"
|
||||
}
|
||||
],
|
||||
"direction": "ltr",
|
||||
"format": "",
|
||||
"indent": 0,
|
||||
"type": "heading",
|
||||
"version": 1,
|
||||
"tag": "h3",
|
||||
"id": "17564"
|
||||
},
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"detail": 0,
|
||||
"format": 0,
|
||||
"mode": "normal",
|
||||
"style": "",
|
||||
"text": "杭州最著名的佛教寺庙,始建于东晋年间。寺内有:",
|
||||
"type": "text",
|
||||
"version": 1,
|
||||
"id": "17567"
|
||||
}
|
||||
],
|
||||
"direction": "ltr",
|
||||
"format": "",
|
||||
"indent": 0,
|
||||
"type": "paragraph",
|
||||
"version": 1,
|
||||
"textFormat": 0,
|
||||
"textStyle": "",
|
||||
"id": "17566"
|
||||
},
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"detail": 0,
|
||||
"format": 0,
|
||||
"mode": "normal",
|
||||
"style": "",
|
||||
"text": "大雄宝殿",
|
||||
"type": "text",
|
||||
"version": 1,
|
||||
"id": "17570"
|
||||
}
|
||||
],
|
||||
"direction": "ltr",
|
||||
"format": "",
|
||||
"indent": 0,
|
||||
"type": "listitem",
|
||||
"version": 1,
|
||||
"value": 1,
|
||||
"id": "17569"
|
||||
},
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"detail": 0,
|
||||
"format": 0,
|
||||
"mode": "normal",
|
||||
"style": "",
|
||||
"text": "飞来峰石窟造像",
|
||||
"type": "text",
|
||||
"version": 1,
|
||||
"id": "17572"
|
||||
}
|
||||
],
|
||||
"direction": "ltr",
|
||||
"format": "",
|
||||
"indent": 0,
|
||||
"type": "listitem",
|
||||
"version": 1,
|
||||
"value": 2,
|
||||
"id": "17571"
|
||||
},
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"detail": 0,
|
||||
"format": 0,
|
||||
"mode": "normal",
|
||||
"style": "",
|
||||
"text": "五百罗汉堂",
|
||||
"type": "text",
|
||||
"version": 1,
|
||||
"id": "17574"
|
||||
}
|
||||
],
|
||||
"direction": "ltr",
|
||||
"format": "",
|
||||
"indent": 0,
|
||||
"type": "listitem",
|
||||
"version": 1,
|
||||
"value": 3,
|
||||
"id": "17573"
|
||||
}
|
||||
],
|
||||
"direction": "ltr",
|
||||
"format": "",
|
||||
"indent": 0,
|
||||
"type": "list",
|
||||
"version": 1,
|
||||
"listType": "bullet",
|
||||
"start": 1,
|
||||
"tag": "ul",
|
||||
"id": "17568"
|
||||
},
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"detail": 0,
|
||||
"format": 0,
|
||||
"mode": "normal",
|
||||
"style": "",
|
||||
"text": "西溪国家湿地公园",
|
||||
"type": "text",
|
||||
"version": 1,
|
||||
"id": "17576"
|
||||
}
|
||||
],
|
||||
"direction": "ltr",
|
||||
"format": "",
|
||||
"indent": 0,
|
||||
"type": "heading",
|
||||
"version": 1,
|
||||
"tag": "h3",
|
||||
"id": "17575"
|
||||
},
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"detail": 0,
|
||||
"format": 0,
|
||||
"mode": "normal",
|
||||
"style": "",
|
||||
"text": "中国第一个国家湿地公园,被誉为\"城市之肾\":",
|
||||
"type": "text",
|
||||
"version": 1,
|
||||
"id": "17578"
|
||||
}
|
||||
],
|
||||
"direction": "ltr",
|
||||
"format": "",
|
||||
"indent": 0,
|
||||
"type": "paragraph",
|
||||
"version": 1,
|
||||
"textFormat": 0,
|
||||
"textStyle": "",
|
||||
"id": "17577"
|
||||
},
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"detail": 0,
|
||||
"format": 0,
|
||||
"mode": "normal",
|
||||
"style": "",
|
||||
"text": "乘船游览湿地风光",
|
||||
"type": "text",
|
||||
"version": 1,
|
||||
"id": "17581"
|
||||
}
|
||||
],
|
||||
"direction": "ltr",
|
||||
"format": "",
|
||||
"indent": 0,
|
||||
"type": "listitem",
|
||||
"version": 1,
|
||||
"value": 1,
|
||||
"id": "17580"
|
||||
},
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"detail": 0,
|
||||
"format": 0,
|
||||
"mode": "normal",
|
||||
"style": "",
|
||||
"text": "观鸟和摄影的好去处",
|
||||
"type": "text",
|
||||
"version": 1,
|
||||
"id": "17583"
|
||||
}
|
||||
],
|
||||
"direction": "ltr",
|
||||
"format": "",
|
||||
"indent": 0,
|
||||
"type": "listitem",
|
||||
"version": 1,
|
||||
"value": 2,
|
||||
"id": "17582"
|
||||
},
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"detail": 0,
|
||||
"format": 0,
|
||||
"mode": "normal",
|
||||
"style": "",
|
||||
"text": "体验江南水乡风情",
|
||||
"type": "text",
|
||||
"version": 1,
|
||||
"id": "17585"
|
||||
}
|
||||
],
|
||||
"direction": "ltr",
|
||||
"format": "",
|
||||
"indent": 0,
|
||||
"type": "listitem",
|
||||
"version": 1,
|
||||
"value": 3,
|
||||
"id": "17584"
|
||||
}
|
||||
],
|
||||
"direction": "ltr",
|
||||
"format": "",
|
||||
"indent": 0,
|
||||
"type": "list",
|
||||
"version": 1,
|
||||
"listType": "bullet",
|
||||
"start": 1,
|
||||
"tag": "ul",
|
||||
"id": "17579"
|
||||
},
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"detail": 0,
|
||||
"format": 0,
|
||||
"mode": "normal",
|
||||
"style": "",
|
||||
"text": "宋城",
|
||||
"type": "text",
|
||||
"version": 1,
|
||||
"id": "17587"
|
||||
}
|
||||
],
|
||||
"direction": "ltr",
|
||||
"format": "",
|
||||
"indent": 0,
|
||||
"type": "heading",
|
||||
"version": 1,
|
||||
"tag": "h3",
|
||||
"id": "17586"
|
||||
},
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"detail": 0,
|
||||
"format": 0,
|
||||
"mode": "normal",
|
||||
"style": "",
|
||||
"text": "以宋代文化为主题的大型主题公园:",
|
||||
"type": "text",
|
||||
"version": 1,
|
||||
"id": "17589"
|
||||
}
|
||||
],
|
||||
"direction": "ltr",
|
||||
"format": "",
|
||||
"indent": 0,
|
||||
"type": "paragraph",
|
||||
"version": 1,
|
||||
"textFormat": 0,
|
||||
"textStyle": "",
|
||||
"id": "17588"
|
||||
},
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"detail": 0,
|
||||
"format": 0,
|
||||
"mode": "normal",
|
||||
"style": "",
|
||||
"text": "《宋城千古情》大型实景演出",
|
||||
"type": "text",
|
||||
"version": 1,
|
||||
"id": "17592"
|
||||
}
|
||||
],
|
||||
"direction": "ltr",
|
||||
"format": "",
|
||||
"indent": 0,
|
||||
"type": "listitem",
|
||||
"version": 1,
|
||||
"value": 1,
|
||||
"id": "17591"
|
||||
},
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"detail": 0,
|
||||
"format": 0,
|
||||
"mode": "normal",
|
||||
"style": "",
|
||||
"text": "宋代街市体验",
|
||||
"type": "text",
|
||||
"version": 1,
|
||||
"id": "17594"
|
||||
}
|
||||
],
|
||||
"direction": "ltr",
|
||||
"format": "",
|
||||
"indent": 0,
|
||||
"type": "listitem",
|
||||
"version": 1,
|
||||
"value": 2,
|
||||
"id": "17593"
|
||||
},
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"detail": 0,
|
||||
"format": 0,
|
||||
"mode": "normal",
|
||||
"style": "",
|
||||
"text": "传统手工艺展示",
|
||||
"type": "text",
|
||||
"version": 1,
|
||||
"id": "17596"
|
||||
}
|
||||
],
|
||||
"direction": "ltr",
|
||||
"format": "",
|
||||
"indent": 0,
|
||||
"type": "listitem",
|
||||
"version": 1,
|
||||
"value": 3,
|
||||
"id": "17595"
|
||||
}
|
||||
],
|
||||
"direction": "ltr",
|
||||
"format": "",
|
||||
"indent": 0,
|
||||
"type": "list",
|
||||
"version": 1,
|
||||
"listType": "bullet",
|
||||
"start": 1,
|
||||
"tag": "ul",
|
||||
"id": "17590"
|
||||
},
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"detail": 0,
|
||||
"format": 0,
|
||||
"mode": "normal",
|
||||
"style": "",
|
||||
"text": "美食推荐",
|
||||
"type": "text",
|
||||
"version": 1,
|
||||
"id": "17598"
|
||||
}
|
||||
],
|
||||
"direction": "ltr",
|
||||
"format": "",
|
||||
"indent": 0,
|
||||
"type": "heading",
|
||||
"version": 1,
|
||||
"tag": "h2",
|
||||
"id": "17597"
|
||||
}
|
||||
],
|
||||
"direction": "ltr",
|
||||
"format": "",
|
||||
"indent": 0,
|
||||
"type": "root",
|
||||
"version": 1,
|
||||
"id": "root"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"keepId": true,
|
||||
"root": {
|
||||
"children": [
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { PortalContent } from '@/features/Portal/router';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { portalThreadSelectors } from '@/store/chat/selectors';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
container: css`
|
||||
@@ -17,7 +18,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
const Layout = () => {
|
||||
const [showMobilePortal, isPortalThread, togglePortal] = useChatStore((s) => [
|
||||
s.showPortal,
|
||||
!!s.portalThreadId,
|
||||
portalThreadSelectors.showThread(s),
|
||||
s.togglePortal,
|
||||
]);
|
||||
const { t } = useTranslation('portal');
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { PortalContent } from '@/features/Portal/router';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { portalThreadSelectors } from '@/store/chat/selectors';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
container: css`
|
||||
@@ -17,7 +18,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
const Layout = () => {
|
||||
const [showMobilePortal, isPortalThread, togglePortal] = useChatStore((s) => [
|
||||
s.showPortal,
|
||||
!!s.portalThreadId,
|
||||
portalThreadSelectors.showThread(s),
|
||||
s.togglePortal,
|
||||
]);
|
||||
const { t } = useTranslation('portal');
|
||||
|
||||
@@ -13,8 +13,8 @@ import { DEFAULT_CHAT_GROUP_CHAT_CONFIG } from '@/const/settings';
|
||||
import { type GroupMemberConfig, chatGroupService } from '@/services/chatGroup';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
import { useAgentGroupStore } from '@/store/agentGroup';
|
||||
import { useFileStore } from '@/store/file';
|
||||
import { useHomeStore } from '@/store/home';
|
||||
import { usePageStore } from '@/store/page';
|
||||
|
||||
interface HostConfig {
|
||||
model?: string;
|
||||
@@ -45,7 +45,7 @@ export const useCreateMenuItems = () => {
|
||||
s.switchToGroup,
|
||||
]);
|
||||
const [createGroup, loadGroups] = useAgentGroupStore((s) => [s.createGroup, s.loadGroups]);
|
||||
const createNewPage = useFileStore((s) => s.createNewPage);
|
||||
const createNewPage = usePageStore((s) => s.createNewPage);
|
||||
|
||||
const [isCreatingGroup, setIsCreatingGroup] = useState(false);
|
||||
const [isCreatingSessionGroup, setIsCreatingSessionGroup] = useState(false);
|
||||
|
||||
-1
@@ -11,4 +11,3 @@ const Title = memo(() => {
|
||||
Title.displayName = 'PageTitle';
|
||||
|
||||
export default Title;
|
||||
|
||||
@@ -1 +1,43 @@
|
||||
export { default } from '../index';
|
||||
'use client';
|
||||
|
||||
import { useUnmount } from 'ahooks';
|
||||
import { Suspense, memo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { createStoreUpdater } from 'zustand-utils';
|
||||
|
||||
import Loading from '@/components/Loading/BrandTextLoading';
|
||||
import PageExplorer from '@/features/PageExplorer';
|
||||
import { usePageStore } from '@/store/page';
|
||||
import { getIdFromIdentifier } from '@/utils/identifier';
|
||||
|
||||
import PageTitle from '../PageTitle';
|
||||
|
||||
/**
|
||||
* Pages route - dedicated page for managing documents/pages
|
||||
* This is extracted from the /resource route to have its own dedicated space
|
||||
*/
|
||||
const PagesPage = memo(() => {
|
||||
const storeUpdater = createStoreUpdater(usePageStore);
|
||||
const params = useParams<{ id: string }>();
|
||||
|
||||
const pageId = getIdFromIdentifier(params.id ?? '', 'docs');
|
||||
storeUpdater('selectedPageId', pageId);
|
||||
|
||||
// Clear activeAgentId when unmounting (leaving chat page)
|
||||
useUnmount(() => {
|
||||
usePageStore.setState({ selectedPageId: undefined });
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle />
|
||||
<Suspense fallback={<Loading debugId="PagesPage" />}>
|
||||
<PageExplorer pageId={pageId} />
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
PagesPage.displayName = 'PagesPage';
|
||||
|
||||
export default PagesPage;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { VList, type VListHandle } from 'virtua';
|
||||
|
||||
import SkeletonList from '@/features/NavPanel/components/SkeletonList';
|
||||
import PageEmpty from '@/features/PageEmpty';
|
||||
import { documentSelectors, useFileStore } from '@/store/file';
|
||||
import { pageSelectors, usePageStore } from '@/store/page';
|
||||
import { type LobeDocument } from '@/types/document';
|
||||
|
||||
import Item from '../List/Item';
|
||||
@@ -19,27 +19,27 @@ const Content = memo<ContentProps>(({ searchKeyword }) => {
|
||||
const virtuaRef = useRef<VListHandle>(null);
|
||||
const fetchedCountRef = useRef(-1);
|
||||
|
||||
const [hasMore, isLoadingMore, loadMoreDocuments] = useFileStore((s) => [
|
||||
documentSelectors.hasMoreDocuments(s),
|
||||
documentSelectors.isLoadingMoreDocuments(s),
|
||||
const [hasMore, isLoadingMore, loadMoreDocuments] = usePageStore((s) => [
|
||||
pageSelectors.hasMoreDocuments(s),
|
||||
pageSelectors.isLoadingMoreDocuments(s),
|
||||
s.loadMoreDocuments,
|
||||
]);
|
||||
|
||||
const allFilteredPages = useFileStore(documentSelectors.getFilteredPages);
|
||||
const allFilteredDocuments = usePageStore(pageSelectors.getFilteredDocuments);
|
||||
|
||||
// Filter by search keyword
|
||||
const displayPages = useMemo(() => {
|
||||
if (!searchKeyword.trim()) return allFilteredPages;
|
||||
const displayDocuments = useMemo(() => {
|
||||
if (!searchKeyword.trim()) return allFilteredDocuments;
|
||||
|
||||
const keyword = searchKeyword.toLowerCase();
|
||||
return allFilteredPages.filter((page: LobeDocument) => {
|
||||
const content = page.content?.toLowerCase() || '';
|
||||
const title = page.title?.toLowerCase() || '';
|
||||
return allFilteredDocuments.filter((doc: LobeDocument) => {
|
||||
const content = doc.content?.toLowerCase() || '';
|
||||
const title = doc.title?.toLowerCase() || '';
|
||||
return content.includes(keyword) || title.includes(keyword);
|
||||
});
|
||||
}, [allFilteredPages, searchKeyword]);
|
||||
}, [allFilteredDocuments, searchKeyword]);
|
||||
|
||||
const count = displayPages.length;
|
||||
const count = displayDocuments.length;
|
||||
const isSearching = searchKeyword.trim().length > 0;
|
||||
|
||||
// Handle scroll - use findItemIndex (official pattern)
|
||||
@@ -74,9 +74,9 @@ const Content = memo<ContentProps>(({ searchKeyword }) => {
|
||||
ref={virtuaRef}
|
||||
style={{ height: '100%' }}
|
||||
>
|
||||
{displayPages.map((page) => (
|
||||
<Flexbox gap={1} key={page.id} padding={'4px 8px'}>
|
||||
<Item pageId={page.id} />
|
||||
{displayDocuments.map((doc) => (
|
||||
<Flexbox gap={1} key={doc.id} padding={'4px 8px'}>
|
||||
<Item pageId={doc.id} />
|
||||
</Flexbox>
|
||||
))}
|
||||
{showLoading && (
|
||||
|
||||
@@ -4,9 +4,9 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import EmojiPicker from '@/components/EmojiPicker';
|
||||
import { useIsDark } from '@/hooks/useIsDark';
|
||||
import { useFileStore } from '@/store/file';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { globalGeneralSelectors } from '@/store/global/selectors';
|
||||
import { usePageStore } from '@/store/page';
|
||||
|
||||
interface EditingProps {
|
||||
currentEmoji?: string;
|
||||
@@ -20,7 +20,7 @@ const Editing = memo<EditingProps>(({ documentId, title, currentEmoji, toggleEdi
|
||||
const isDarkMode = useIsDark();
|
||||
const { t } = useTranslation('file');
|
||||
|
||||
const editing = useFileStore((s) => s.renamingPageId === documentId);
|
||||
const editing = usePageStore((s) => s.renamingPageId === documentId);
|
||||
|
||||
const [newTitle, setNewTitle] = useState(title);
|
||||
const [newEmoji, setNewEmoji] = useState(currentEmoji);
|
||||
@@ -35,7 +35,7 @@ const Editing = memo<EditingProps>(({ documentId, title, currentEmoji, toggleEdi
|
||||
if (newTitle && title !== newTitle) updates.title = newTitle;
|
||||
if (newEmoji !== undefined && currentEmoji !== newEmoji) updates.emoji = newEmoji;
|
||||
|
||||
await useFileStore.getState().renamePage(documentId, updates.title || title, updates.emoji);
|
||||
await usePageStore.getState().renamePage(documentId, updates.title || title, updates.emoji);
|
||||
} catch (error) {
|
||||
console.error('Failed to update page:', error);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { memo, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import NavItem from '@/features/NavPanel/components/NavItem';
|
||||
import { documentSelectors, useFileStore } from '@/store/file';
|
||||
import { pageSelectors, usePageStore } from '@/store/page';
|
||||
|
||||
import Actions from './Actions';
|
||||
import Editing from './Editing';
|
||||
@@ -17,18 +17,13 @@ interface DocumentItemProps {
|
||||
|
||||
const PageListItem = memo<DocumentItemProps>(({ pageId, className }) => {
|
||||
const { t } = useTranslation('file');
|
||||
const [editing, selectedPageId, document] = useFileStore(
|
||||
useCallback(
|
||||
(s) => {
|
||||
const doc = documentSelectors.getDocumentById(pageId)(s);
|
||||
return [s.renamingPageId === pageId, s.selectedPageId, doc] as const;
|
||||
},
|
||||
[pageId],
|
||||
),
|
||||
);
|
||||
const [editing, selectedPageId, document] = usePageStore((s) => {
|
||||
const doc = pageSelectors.getDocumentById(pageId)(s);
|
||||
return [s.renamingPageId === pageId, s.selectedPageId, doc] as const;
|
||||
});
|
||||
|
||||
const selectPage = useFileStore((s) => s.selectPage);
|
||||
const setRenamingPageId = useFileStore((s) => s.setRenamingPageId);
|
||||
const selectPage = usePageStore((s) => s.selectPage);
|
||||
const setRenamingPageId = usePageStore((s) => s.setRenamingPageId);
|
||||
|
||||
const active = selectedPageId === pageId;
|
||||
const title = document?.title || t('pageList.untitled');
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Copy, CopyPlus, Pencil, Trash2 } from 'lucide-react';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useFileStore } from '@/store/file';
|
||||
import { usePageStore } from '@/store/page';
|
||||
|
||||
interface ActionProps {
|
||||
documentContent?: string;
|
||||
@@ -19,8 +19,8 @@ export const useDropdownMenu = ({
|
||||
}: ActionProps): (() => MenuProps['items']) => {
|
||||
const { t } = useTranslation(['common', 'file']);
|
||||
const { message, modal } = App.useApp();
|
||||
const removeDocument = useFileStore((s) => s.removeDocument);
|
||||
const duplicateDocument = useFileStore((s) => s.duplicateDocument);
|
||||
const removePage = usePageStore((s) => s.removePage);
|
||||
const duplicatePage = usePageStore((s) => s.duplicatePage);
|
||||
|
||||
const handleDelete = () => {
|
||||
modal.confirm({
|
||||
@@ -30,7 +30,7 @@ export const useDropdownMenu = ({
|
||||
okText: t('delete'),
|
||||
onOk: async () => {
|
||||
try {
|
||||
await removeDocument(pageId);
|
||||
await removePage(pageId);
|
||||
message.success(t('pageEditor.deleteSuccess', { ns: 'file' }));
|
||||
} catch (error) {
|
||||
console.error('Failed to delete page:', error);
|
||||
@@ -53,7 +53,7 @@ export const useDropdownMenu = ({
|
||||
|
||||
const handleDuplicate = async () => {
|
||||
try {
|
||||
await duplicateDocument(pageId);
|
||||
await duplicatePage(pageId);
|
||||
} catch (error) {
|
||||
console.error('Failed to duplicate page:', error);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import NavItem from '@/features/NavPanel/components/NavItem';
|
||||
import SkeletonList from '@/features/NavPanel/components/SkeletonList';
|
||||
import { documentSelectors, useFileStore } from '@/store/file';
|
||||
import { pageSelectors, usePageStore } from '@/store/page';
|
||||
|
||||
import Item from './Item';
|
||||
|
||||
@@ -16,17 +16,17 @@ import Item from './Item';
|
||||
const PageList = () => {
|
||||
const { t } = useTranslation(['file', 'common']);
|
||||
|
||||
const [filteredPages, hasMore, isLoadingMore, openAllPagesDrawer] = useFileStore((s) => [
|
||||
documentSelectors.getFilteredPagesLimited(s),
|
||||
documentSelectors.hasMoreFilteredPages(s),
|
||||
documentSelectors.isLoadingMoreDocuments(s),
|
||||
const [filteredDocuments, hasMore, isLoadingMore, openAllPagesDrawer] = usePageStore((s) => [
|
||||
pageSelectors.getFilteredDocumentsLimited(s),
|
||||
pageSelectors.hasMoreFilteredDocuments(s),
|
||||
pageSelectors.isLoadingMoreDocuments(s),
|
||||
s.openAllPagesDrawer,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Flexbox gap={1}>
|
||||
{filteredPages.map((page) => (
|
||||
<Item key={page.id} pageId={page.id} />
|
||||
{filteredDocuments.map((doc) => (
|
||||
<Item key={doc.id} pageId={doc.id} />
|
||||
))}
|
||||
{isLoadingMore && <SkeletonList rows={3} />}
|
||||
{hasMore && !isLoadingMore && (
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import SkeletonList from '@/features/NavPanel/components/SkeletonList';
|
||||
import PageEmpty from '@/features/PageEmpty';
|
||||
import { documentSelectors, useFileStore } from '@/store/file';
|
||||
import { pageSelectors, usePageStore } from '@/store/page';
|
||||
|
||||
import Actions from './Actions';
|
||||
import AllPagesDrawer from './AllPagesDrawer';
|
||||
@@ -22,12 +22,18 @@ export enum GroupKey {
|
||||
*/
|
||||
const Body = memo(() => {
|
||||
const { t } = useTranslation('file');
|
||||
const isDocumentListLoading = useFileStore((s) => s.isDocumentListLoading);
|
||||
const filteredPagesCount = useFileStore(documentSelectors.filteredPagesCount);
|
||||
const filteredPages = useFileStore(documentSelectors.getFilteredPagesLimited);
|
||||
const searchKeywords = useFileStore((s) => s.searchKeywords);
|
||||
|
||||
// Initialize documents list via SWR
|
||||
const useFetchDocuments = usePageStore((s) => s.useFetchDocuments);
|
||||
useFetchDocuments();
|
||||
|
||||
const isLoading = usePageStore(pageSelectors.isDocumentsLoading);
|
||||
|
||||
const filteredDocumentsCount = usePageStore(pageSelectors.filteredDocumentsCount);
|
||||
const filteredDocuments = usePageStore(pageSelectors.getFilteredDocumentsLimited);
|
||||
const searchKeywords = usePageStore((s) => s.searchKeywords);
|
||||
const dropdownMenu = useDropdownMenu();
|
||||
const [allPagesDrawerOpen, closeAllPagesDrawer] = useFileStore((s) => [
|
||||
const [allPagesDrawerOpen, closeAllPagesDrawer] = usePageStore((s) => [
|
||||
s.allPagesDrawerOpen,
|
||||
s.closeAllPagesDrawer,
|
||||
]);
|
||||
@@ -53,16 +59,16 @@ const Body = memo(() => {
|
||||
title={
|
||||
<Text ellipsis fontSize={12} type={'secondary'} weight={500}>
|
||||
{t('pageList.title')}
|
||||
{filteredPagesCount > 0 && ` ${filteredPagesCount}`}
|
||||
{filteredDocumentsCount > 0 && ` ${filteredDocumentsCount}`}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<SkeletonList />}>
|
||||
{isDocumentListLoading ? (
|
||||
{isLoading ? (
|
||||
<SkeletonList />
|
||||
) : (
|
||||
<Flexbox gap={1} paddingBlock={1}>
|
||||
{filteredPages.length === 0 ? (
|
||||
{filteredDocuments.length === 0 ? (
|
||||
<PageEmpty search={Boolean(searchKeywords.trim())} />
|
||||
) : (
|
||||
<List />
|
||||
|
||||
@@ -6,14 +6,14 @@ import { Hash, LucideCheck } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useFileStore } from '@/store/file';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { systemStatusSelectors } from '@/store/global/selectors';
|
||||
import { usePageStore } from '@/store/page';
|
||||
|
||||
export const useDropdownMenu = (): MenuProps['items'] => {
|
||||
const { t } = useTranslation();
|
||||
const showOnlyPagesNotInLibrary = useFileStore((s) => s.showOnlyPagesNotInLibrary);
|
||||
const setShowOnlyPagesNotInLibrary = useFileStore((s) => s.setShowOnlyPagesNotInLibrary);
|
||||
const showOnlyPagesNotInLibrary = usePageStore((s) => s.showOnlyPagesNotInLibrary);
|
||||
const setShowOnlyPagesNotInLibrary = usePageStore((s) => s.setShowOnlyPagesNotInLibrary);
|
||||
|
||||
const [pagePageSize, updateSystemStatus] = useGlobalStore((s) => [
|
||||
systemStatusSelectors.pagePageSize(s),
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { createStoreUpdater } from 'zustand-utils';
|
||||
|
||||
import { usePageStore } from '@/store/page';
|
||||
|
||||
const DataSync = () => {
|
||||
const usePageStoreUpdater = createStoreUpdater(usePageStore);
|
||||
|
||||
const navigate = useNavigate();
|
||||
usePageStoreUpdater('navigate', navigate);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default DataSync;
|
||||
@@ -5,12 +5,12 @@ import { SquarePenIcon } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useFileStore } from '@/store/file';
|
||||
import { usePageStore } from '@/store/page';
|
||||
|
||||
const AddButton = memo(() => {
|
||||
const { t } = useTranslation('file');
|
||||
|
||||
const createNewPage = useFileStore((s) => s.createNewPage);
|
||||
const createNewPage = usePageStore((s) => s.createNewPage);
|
||||
|
||||
const handleNewDocument = () => {
|
||||
const untitledTitle = t('pageList.untitled');
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Flexbox } from '@lobehub/ui';
|
||||
import { type FC } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
import DataSync from './DataSync';
|
||||
import Sidebar from './Sidebar';
|
||||
import { styles } from './style';
|
||||
|
||||
@@ -14,6 +15,7 @@ const DesktopPagesLayout: FC = () => {
|
||||
<Flexbox className={styles.mainContainer} flex={1} height={'100%'}>
|
||||
<Outlet />
|
||||
</Flexbox>
|
||||
<DataSync />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,26 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, memo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import Loading from '@/components/Loading/BrandTextLoading';
|
||||
import PageExplorer from '@/features/PageExplorer';
|
||||
import { standardizeIdentifier } from '@/utils/identifier';
|
||||
import PageExplorerPlaceholder from '@/features/PageExplorer/PageExplorerPlaceholder';
|
||||
|
||||
import PageTitle from './features/PageTitle';
|
||||
import PageTitle from './PageTitle';
|
||||
|
||||
/**
|
||||
* Pages route - dedicated page for managing documents/pages
|
||||
* This is extracted from the /resource route to have its own dedicated space
|
||||
*/
|
||||
const PagesPage = memo(() => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle />
|
||||
<Suspense fallback={<Loading debugId="PagesPage" />}>
|
||||
<PageExplorer pageId={standardizeIdentifier(id ?? '', 'docs')} />
|
||||
<PageExplorerPlaceholder />
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next';
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface AutoSaveHintProps {
|
||||
lastUpdatedTime?: Date | null;
|
||||
lastUpdatedTime?: string | Date | null;
|
||||
saveStatus: 'idle' | 'saving' | 'saved';
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
@@ -76,7 +76,11 @@ export const UserActionsBar = memo<UserActionsProps>(({ actionsConfig, id, data
|
||||
// Use external config if provided, otherwise use defaults
|
||||
// Append extra actions from factories
|
||||
const barItems = useMemo(() => {
|
||||
const base = actionsConfig?.bar ?? [defaultActions.regenerate, defaultActions.edit];
|
||||
const base = actionsConfig?.bar ?? [
|
||||
defaultActions.regenerate,
|
||||
defaultActions.edit,
|
||||
defaultActions.copy,
|
||||
];
|
||||
return [...base, ...extraBarItems];
|
||||
}, [actionsConfig?.bar, defaultActions.regenerate, defaultActions.edit, extraBarItems]);
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { type CSSProperties, memo } from 'react';
|
||||
|
||||
import AutoSaveHintBase from '@/components/Editor/AutoSaveHint';
|
||||
import { useDocumentStore } from '@/store/document';
|
||||
import { editorSelectors } from '@/store/document/slices/editor';
|
||||
|
||||
export interface AutoSaveHintProps {
|
||||
/**
|
||||
* Document ID to get save status from DocumentStore
|
||||
*/
|
||||
documentId: string;
|
||||
/**
|
||||
* Custom styles
|
||||
*/
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* AutoSave hint component that reads from DocumentStore
|
||||
* Use this component externally to display save status for a document
|
||||
*/
|
||||
const AutoSaveHint = memo<AutoSaveHintProps>(({ documentId, style }) => {
|
||||
const saveStatus = useDocumentStore((s) => editorSelectors.saveStatus(documentId)(s));
|
||||
const lastUpdatedTime = useDocumentStore(
|
||||
(s) => editorSelectors.lastUpdatedTime(documentId)(s) ?? null,
|
||||
);
|
||||
|
||||
return (
|
||||
<AutoSaveHintBase lastUpdatedTime={lastUpdatedTime} saveStatus={saveStatus} style={style} />
|
||||
);
|
||||
});
|
||||
|
||||
AutoSaveHint.displayName = 'AutoSaveHint';
|
||||
|
||||
export default AutoSaveHint;
|
||||
+57
-16
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { DiffAction, LITEXML_DIFFNODE_ALL_COMMAND } from '@lobehub/editor';
|
||||
import { DiffAction, IEditor, LITEXML_DIFFNODE_ALL_COMMAND } from '@lobehub/editor';
|
||||
import { Block, Icon } from '@lobehub/ui';
|
||||
import { Button, Space } from 'antd';
|
||||
import { createStaticStyles, cssVar, cx } from 'antd-style';
|
||||
@@ -9,8 +9,7 @@ import { memo, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useIsDark } from '@/hooks/useIsDark';
|
||||
|
||||
import { usePageEditorStore } from './store';
|
||||
import { useDocumentStore } from '@/store/document';
|
||||
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
container: css`
|
||||
@@ -36,18 +35,36 @@ const styles = createStaticStyles(({ css }) => ({
|
||||
`,
|
||||
}));
|
||||
|
||||
const DiffAllToolbar = memo(() => {
|
||||
const { t } = useTranslation('editor');
|
||||
const isDarkMode = useIsDark();
|
||||
const [editor, performSave] = usePageEditorStore((s) => [s.editor, s.performSave]);
|
||||
const useIsEditorInit = (editor: IEditor) => {
|
||||
const [isEditInit, setEditInit] = useState<boolean>(!!editor.getLexicalEditor());
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
|
||||
const onInit = () => {
|
||||
console.log('init: id', editor.getLexicalEditor()?._key);
|
||||
setEditInit(true);
|
||||
};
|
||||
editor.on('initialized', onInit);
|
||||
return () => {
|
||||
editor.off('initialized', onInit);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
return isEditInit;
|
||||
};
|
||||
|
||||
const useEditorHasPendingDiffs = (editor: IEditor) => {
|
||||
const [hasPendingDiffs, setHasPendingDiffs] = useState(false);
|
||||
const isEditInit = useIsEditorInit(editor);
|
||||
|
||||
// Listen to editor state changes to detect diff nodes
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
|
||||
const lexicalEditor = editor.getLexicalEditor();
|
||||
if (!lexicalEditor) return;
|
||||
|
||||
if (!lexicalEditor || !isEditInit) return;
|
||||
|
||||
const checkForDiffNodes = () => {
|
||||
const editorState = lexicalEditor.getEditorState();
|
||||
@@ -67,15 +84,39 @@ const DiffAllToolbar = memo(() => {
|
||||
// Check initially
|
||||
checkForDiffNodes();
|
||||
|
||||
// Register update listener
|
||||
const unregister = lexicalEditor.registerUpdateListener(() => {
|
||||
checkForDiffNodes();
|
||||
});
|
||||
// Register update listener
|
||||
return () => {
|
||||
unregister();
|
||||
};
|
||||
}, [editor, isEditInit]);
|
||||
|
||||
return unregister;
|
||||
}, [editor]);
|
||||
return hasPendingDiffs;
|
||||
};
|
||||
|
||||
if (!editor || !hasPendingDiffs) return null;
|
||||
interface DiffAllToolbarProps {
|
||||
documentId: string;
|
||||
editor: IEditor;
|
||||
}
|
||||
const DiffAllToolbar = memo<DiffAllToolbarProps>(({ documentId }) => {
|
||||
const { t } = useTranslation('editor');
|
||||
const isDarkMode = useIsDark();
|
||||
const [editor, performSave, markDirty] = useDocumentStore((s) => [
|
||||
s.editor!,
|
||||
s.performSave,
|
||||
s.markDirty,
|
||||
]);
|
||||
|
||||
const hasPendingDiffs = useEditorHasPendingDiffs(editor);
|
||||
|
||||
if (!hasPendingDiffs) return null;
|
||||
|
||||
const handleSave = async () => {
|
||||
markDirty(documentId);
|
||||
await performSave();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
@@ -90,10 +131,10 @@ const DiffAllToolbar = memo(() => {
|
||||
<Space>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
editor.dispatchCommand(LITEXML_DIFFNODE_ALL_COMMAND, {
|
||||
editor?.dispatchCommand(LITEXML_DIFFNODE_ALL_COMMAND, {
|
||||
action: DiffAction.Reject,
|
||||
});
|
||||
await performSave({ force: true });
|
||||
await handleSave();
|
||||
}}
|
||||
size={'small'}
|
||||
type="text"
|
||||
@@ -104,10 +145,10 @@ const DiffAllToolbar = memo(() => {
|
||||
<Button
|
||||
color={'default'}
|
||||
onClick={async () => {
|
||||
editor.dispatchCommand(LITEXML_DIFFNODE_ALL_COMMAND, {
|
||||
editor?.dispatchCommand(LITEXML_DIFFNODE_ALL_COMMAND, {
|
||||
action: DiffAction.Accept,
|
||||
});
|
||||
await performSave({ force: true });
|
||||
await handleSave();
|
||||
}}
|
||||
size={'small'}
|
||||
variant="filled"
|
||||
@@ -0,0 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import { type IEditor } from '@lobehub/editor';
|
||||
import { Alert } from '@lobehub/ui';
|
||||
import { Skeleton } from 'antd';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createStoreUpdater } from 'zustand-utils';
|
||||
|
||||
import { useSaveDocumentHotkey } from '@/hooks/useHotkeys';
|
||||
import { useDocumentStore } from '@/store/document';
|
||||
import { editorSelectors } from '@/store/document/slices/editor';
|
||||
|
||||
import type { EditorCanvasProps } from './EditorCanvas';
|
||||
import InternalEditor from './InternalEditor';
|
||||
|
||||
/**
|
||||
* Loading skeleton for the editor
|
||||
*/
|
||||
const EditorSkeleton = memo(() => (
|
||||
<div style={{ paddingBlock: 24 }}>
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
</div>
|
||||
));
|
||||
|
||||
/**
|
||||
* Error display for fetch failures
|
||||
*/
|
||||
const EditorError = memo<{ error: Error }>(({ error }) => {
|
||||
const { t } = useTranslation('file');
|
||||
|
||||
return (
|
||||
<Alert
|
||||
description={error.message || t('pageEditor.loadError', 'Failed to load document')}
|
||||
showIcon
|
||||
style={{ margin: 16 }}
|
||||
title={t('pageEditor.error', 'Error')}
|
||||
type="error"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export interface DocumentIdModeProps extends EditorCanvasProps {
|
||||
documentId: string;
|
||||
editor: IEditor | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* EditorCanvas with documentId mode - handles data fetching internally
|
||||
*/
|
||||
const DocumentIdMode = memo<DocumentIdModeProps>(
|
||||
({
|
||||
editor,
|
||||
documentId,
|
||||
autoSave = true,
|
||||
sourceType = 'page',
|
||||
onContentChange,
|
||||
style,
|
||||
...editorProps
|
||||
}) => {
|
||||
const { t } = useTranslation('file');
|
||||
|
||||
const storeUpdater = createStoreUpdater(useDocumentStore);
|
||||
storeUpdater('activeDocumentId', documentId);
|
||||
storeUpdater('editor', editor);
|
||||
|
||||
// Get document store actions
|
||||
const [onEditorInit, handleContentChangeStore, useFetchDocument, flushSave] = useDocumentStore(
|
||||
(s) => [s.onEditorInit, s.handleContentChange, s.useFetchDocument, s.flushSave],
|
||||
);
|
||||
|
||||
useSaveDocumentHotkey(flushSave);
|
||||
|
||||
// Use SWR hook for document fetching (auto-initializes via onSuccess in DocumentStore)
|
||||
const { error } = useFetchDocument(documentId, { autoSave, editor, sourceType });
|
||||
|
||||
// Check loading state via selector (document not yet in store)
|
||||
const isLoading = useDocumentStore(editorSelectors.isDocumentLoading(documentId));
|
||||
|
||||
// Handle content change
|
||||
const handleChange = () => {
|
||||
handleContentChangeStore();
|
||||
onContentChange?.();
|
||||
};
|
||||
|
||||
// Show loading state
|
||||
if (isLoading) {
|
||||
return <EditorSkeleton />;
|
||||
}
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <EditorError error={error as Error} />}
|
||||
<InternalEditor
|
||||
editor={editor}
|
||||
onContentChange={handleChange}
|
||||
onInit={onEditorInit}
|
||||
placeholder={editorProps.placeholder || t('pageEditor.editorPlaceholder')}
|
||||
style={style}
|
||||
{...editorProps}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
DocumentIdMode.displayName = 'DocumentIdMode';
|
||||
|
||||
export default DocumentIdMode;
|
||||
@@ -0,0 +1,148 @@
|
||||
'use client';
|
||||
|
||||
import { type IEditor, type SlashOptions } from '@lobehub/editor';
|
||||
import { type ChatInputActionsProps } from '@lobehub/editor/react';
|
||||
import { Editor } from '@lobehub/editor/react';
|
||||
import { type CSSProperties, memo } from 'react';
|
||||
|
||||
import DocumentIdMode from './DocumentIdMode';
|
||||
import EditorDataMode from './EditorDataMode';
|
||||
import { EditorErrorBoundary } from './ErrorBoundary';
|
||||
import InternalEditor from './InternalEditor';
|
||||
|
||||
/**
|
||||
* Plugin type for the editor
|
||||
* Allows any array of plugins that the Editor component accepts
|
||||
*/
|
||||
type EditorPlugins = Parameters<typeof Editor>[0]['plugins'];
|
||||
|
||||
export interface EditorCanvasProps {
|
||||
/**
|
||||
* Whether to enable auto-save in DocumentStore. Defaults to true.
|
||||
* Only applies when documentId is provided.
|
||||
*/
|
||||
autoSave?: boolean;
|
||||
|
||||
/**
|
||||
* Document ID to load from server.
|
||||
* When provided, component will use useSWR to fetch document data.
|
||||
*/
|
||||
documentId?: string;
|
||||
|
||||
/**
|
||||
* Editor data to render directly (skip fetch).
|
||||
* Use this when you already have the content and don't need to fetch.
|
||||
*/
|
||||
editorData?: {
|
||||
content?: string;
|
||||
editorData?: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extra plugins to prepend to BASE_PLUGINS (e.g., ReactLiteXmlPlugin)
|
||||
*/
|
||||
extraPlugins?: EditorPlugins;
|
||||
|
||||
/**
|
||||
* Whether to show the floating toolbar. Defaults to true.
|
||||
*/
|
||||
floatingToolbar?: boolean;
|
||||
|
||||
/**
|
||||
* Content change handler
|
||||
*/
|
||||
onContentChange?: () => void;
|
||||
|
||||
/**
|
||||
* Editor initialization handler
|
||||
*/
|
||||
onInit?: (editor: IEditor) => void;
|
||||
|
||||
/**
|
||||
* Placeholder text for empty editor
|
||||
*/
|
||||
placeholder?: string;
|
||||
|
||||
/**
|
||||
* Custom plugins for the editor. If provided, replaces BASE_PLUGINS entirely.
|
||||
* Use this when you need complete control over plugins.
|
||||
*/
|
||||
plugins?: EditorPlugins;
|
||||
|
||||
/**
|
||||
* Slash menu items
|
||||
*/
|
||||
slashItems?: SlashOptions['items'];
|
||||
|
||||
/**
|
||||
* Source type for DocumentStore. Defaults to 'page'.
|
||||
*/
|
||||
sourceType?: 'page' | 'notebook';
|
||||
|
||||
/**
|
||||
* Custom styles for the editor
|
||||
*/
|
||||
style?: CSSProperties;
|
||||
|
||||
/**
|
||||
* Extra items to add to the floating toolbar (e.g., "Ask Copilot" button)
|
||||
*/
|
||||
toolbarExtraItems?: ChatInputActionsProps['items'];
|
||||
}
|
||||
|
||||
export interface EditorCanvasWithEditorProps extends EditorCanvasProps {
|
||||
/**
|
||||
* Editor instance
|
||||
*/
|
||||
editor: IEditor | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* EditorCanvas component that accepts editor as a prop
|
||||
*
|
||||
* Three modes of operation:
|
||||
* 1. documentId mode: Pass documentId, component fetches data via useSWR, shows loading/error states
|
||||
* 2. editorData mode: Pass editorData directly, skips fetch and renders immediately
|
||||
* 3. Basic mode: No documentId or editorData, just renders the editor (original behavior)
|
||||
*
|
||||
* Features:
|
||||
* - Internal ErrorBoundary for graceful error handling
|
||||
* - Loading skeleton during fetch (documentId mode)
|
||||
* - Error state display for fetch failures (documentId mode)
|
||||
* - Auto-save integration with DocumentStore (documentId mode)
|
||||
* - AutoSave hint display (documentId mode)
|
||||
*/
|
||||
export const EditorCanvas = memo<EditorCanvasWithEditorProps>(
|
||||
({ editor, documentId, editorData, ...props }) => {
|
||||
// documentId mode - fetch and render with loading/error states
|
||||
if (documentId) {
|
||||
return (
|
||||
<EditorErrorBoundary>
|
||||
<DocumentIdMode documentId={documentId} editor={editor} {...props} />
|
||||
</EditorErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
// editorData mode - render with provided data
|
||||
if (editorData) {
|
||||
return (
|
||||
<EditorErrorBoundary>
|
||||
<EditorDataMode editor={editor} editorData={editorData} {...props} />
|
||||
</EditorErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
// Basic mode - original behavior
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<EditorErrorBoundary>
|
||||
<InternalEditor editor={editor} {...props} />
|
||||
</EditorErrorBoundary>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
EditorCanvas.displayName = 'EditorCanvas';
|
||||
|
||||
export default EditorCanvas;
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { type IEditor } from '@lobehub/editor';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { EditorCanvasProps } from './EditorCanvas';
|
||||
import InternalEditor from './InternalEditor';
|
||||
|
||||
export interface EditorDataModeProps extends EditorCanvasProps {
|
||||
editor: IEditor | undefined;
|
||||
editorData: NonNullable<EditorCanvasProps['editorData']>;
|
||||
}
|
||||
|
||||
/**
|
||||
* EditorCanvas with editorData mode - uses provided data directly
|
||||
*/
|
||||
const EditorDataMode = memo<EditorDataModeProps>(
|
||||
({ editor, editorData, onContentChange, style, ...editorProps }) => {
|
||||
const { t } = useTranslation('file');
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
// Load content into editor on mount
|
||||
useEffect(() => {
|
||||
if (!editor || isInitialized) return;
|
||||
|
||||
const hasValidEditorData =
|
||||
editorData.editorData &&
|
||||
typeof editorData.editorData === 'object' &&
|
||||
Object.keys(editorData.editorData as object).length > 0;
|
||||
|
||||
try {
|
||||
if (hasValidEditorData) {
|
||||
editor.setDocument('json', JSON.stringify(editorData.editorData));
|
||||
} else if (editorData.content?.trim()) {
|
||||
editor.setDocument('markdown', editorData.content, { keepId: true });
|
||||
} else {
|
||||
console.error('[EditorCanvas] load content error:', editorData);
|
||||
}
|
||||
|
||||
setIsInitialized(true);
|
||||
} catch (err) {
|
||||
console.error('[EditorCanvas] Failed to load content:', err);
|
||||
}
|
||||
}, [editorData, editor, isInitialized]);
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', ...style }}>
|
||||
<InternalEditor
|
||||
editor={editor}
|
||||
onContentChange={onContentChange}
|
||||
placeholder={editorProps.placeholder || t('pageEditor.editorPlaceholder')}
|
||||
{...editorProps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
EditorDataMode.displayName = 'EditorDataMode';
|
||||
|
||||
export default EditorDataMode;
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { Alert } from '@lobehub/ui';
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
||||
|
||||
interface EditorErrorBoundaryState {
|
||||
error: Error | null;
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
interface EditorErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* ErrorBoundary for EditorCanvas component.
|
||||
* Catches rendering errors in the editor and displays a fallback error UI
|
||||
* instead of crashing the entire page.
|
||||
*/
|
||||
export class EditorErrorBoundary extends Component<
|
||||
EditorErrorBoundaryProps,
|
||||
EditorErrorBoundaryState
|
||||
> {
|
||||
public state: EditorErrorBoundaryState = {
|
||||
error: null,
|
||||
hasError: false,
|
||||
};
|
||||
|
||||
public static getDerivedStateFromError(error: Error): Partial<EditorErrorBoundaryState> {
|
||||
return { error, hasError: true };
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('[EditorErrorBoundary] Caught error in editor render:', {
|
||||
componentStack: errorInfo.componentStack,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert
|
||||
message={this.state.error?.message || 'An unknown error occurred in the editor'}
|
||||
showIcon
|
||||
style={{
|
||||
margin: 16,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
}}
|
||||
title="Editor Error"
|
||||
type="error"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
HotkeyEnum,
|
||||
INSERT_HEADING_COMMAND,
|
||||
getHotkeyById,
|
||||
type IEditor,
|
||||
} from '@lobehub/editor';
|
||||
import {
|
||||
ChatInputActions,
|
||||
type ChatInputActionsProps,
|
||||
type EditorState,
|
||||
FloatActions,
|
||||
} from '@lobehub/editor/react';
|
||||
import { Block } from '@lobehub/ui';
|
||||
import { cssVar } from 'antd-style';
|
||||
import {
|
||||
BoldIcon,
|
||||
CodeXmlIcon,
|
||||
Heading1Icon,
|
||||
Heading2Icon,
|
||||
Heading3Icon,
|
||||
ItalicIcon,
|
||||
LinkIcon,
|
||||
ListIcon,
|
||||
ListOrderedIcon,
|
||||
ListTodoIcon,
|
||||
MessageSquareQuote,
|
||||
Redo2Icon,
|
||||
SigmaIcon,
|
||||
SquareDashedBottomCodeIcon,
|
||||
StrikethroughIcon,
|
||||
UnderlineIcon,
|
||||
Undo2Icon,
|
||||
} from 'lucide-react';
|
||||
import { type CSSProperties, memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface InlineToolbarProps {
|
||||
className?: string;
|
||||
editor?: IEditor;
|
||||
editorState?: EditorState;
|
||||
/**
|
||||
* Extra items to prepend to the toolbar (e.g., "Ask Copilot" button)
|
||||
*/
|
||||
extraItems?: ChatInputActionsProps['items'];
|
||||
floating?: boolean;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
const InlineToolbar = memo<InlineToolbarProps>(
|
||||
({ floating, style, className, editor, editorState, extraItems }) => {
|
||||
const { t } = useTranslation('editor');
|
||||
|
||||
const items: ChatInputActionsProps['items'] = useMemo(() => {
|
||||
if (!editorState) return [];
|
||||
|
||||
const baseItems = [
|
||||
// Extra items (like "Ask Copilot") come first
|
||||
...(extraItems || []),
|
||||
extraItems?.length ? { type: 'divider' as const } : null,
|
||||
!floating && {
|
||||
disabled: !editorState.canUndo,
|
||||
icon: Undo2Icon,
|
||||
key: 'undo',
|
||||
label: t('typobar.undo', 'Undo'),
|
||||
onClick: editorState.undo,
|
||||
tooltipProps: { hotkey: getHotkeyById(HotkeyEnum.Undo).keys },
|
||||
},
|
||||
!floating && {
|
||||
disabled: !editorState.canRedo,
|
||||
icon: Redo2Icon,
|
||||
key: 'redo',
|
||||
label: t('typobar.redo', 'Redo'),
|
||||
onClick: editorState.redo,
|
||||
tooltipProps: { hotkey: getHotkeyById(HotkeyEnum.Redo).keys },
|
||||
},
|
||||
!floating && {
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
active: editorState.isBold,
|
||||
icon: BoldIcon,
|
||||
key: 'bold',
|
||||
label: t('typobar.bold'),
|
||||
onClick: editorState.bold,
|
||||
tooltipProps: { hotkey: getHotkeyById(HotkeyEnum.Bold).keys },
|
||||
},
|
||||
{
|
||||
active: editorState.isItalic,
|
||||
icon: ItalicIcon,
|
||||
key: 'italic',
|
||||
label: t('typobar.italic'),
|
||||
onClick: editorState.italic,
|
||||
tooltipProps: { hotkey: getHotkeyById(HotkeyEnum.Italic).keys },
|
||||
},
|
||||
{
|
||||
active: editorState.isUnderline,
|
||||
icon: UnderlineIcon,
|
||||
key: 'underline',
|
||||
label: t('typobar.underline'),
|
||||
onClick: editorState.underline,
|
||||
tooltipProps: { hotkey: getHotkeyById(HotkeyEnum.Underline).keys },
|
||||
},
|
||||
{
|
||||
active: editorState.isStrikethrough,
|
||||
icon: StrikethroughIcon,
|
||||
key: 'strikethrough',
|
||||
label: t('typobar.strikethrough'),
|
||||
onClick: editorState.strikethrough,
|
||||
tooltipProps: { hotkey: getHotkeyById(HotkeyEnum.Strikethrough).keys },
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
!floating && {
|
||||
icon: Heading1Icon,
|
||||
key: 'h1',
|
||||
label: t('slash.h1'),
|
||||
onClick: () => {
|
||||
if (editor) {
|
||||
editor.dispatchCommand(INSERT_HEADING_COMMAND, { tag: 'h1' });
|
||||
}
|
||||
},
|
||||
},
|
||||
!floating && {
|
||||
icon: Heading2Icon,
|
||||
key: 'h2',
|
||||
label: t('slash.h2'),
|
||||
onClick: () => {
|
||||
if (editor) {
|
||||
editor.dispatchCommand(INSERT_HEADING_COMMAND, { tag: 'h2' });
|
||||
}
|
||||
},
|
||||
},
|
||||
!floating && {
|
||||
icon: Heading3Icon,
|
||||
key: 'h3',
|
||||
label: t('slash.h3'),
|
||||
onClick: () => {
|
||||
if (editor) {
|
||||
editor.dispatchCommand(INSERT_HEADING_COMMAND, { tag: 'h3' });
|
||||
}
|
||||
},
|
||||
},
|
||||
!floating && {
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
icon: ListIcon,
|
||||
key: 'bulletList',
|
||||
label: t('typobar.bulletList'),
|
||||
onClick: editorState.bulletList,
|
||||
tooltipProps: { hotkey: getHotkeyById(HotkeyEnum.BulletList).keys },
|
||||
},
|
||||
{
|
||||
icon: ListOrderedIcon,
|
||||
key: 'numberlist',
|
||||
label: t('typobar.numberList'),
|
||||
onClick: editorState.numberList,
|
||||
tooltipProps: { hotkey: getHotkeyById(HotkeyEnum.NumberList).keys },
|
||||
},
|
||||
{
|
||||
icon: ListTodoIcon,
|
||||
key: 'tasklist',
|
||||
label: t('typobar.taskList'),
|
||||
onClick: editorState.checkList,
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
active: editorState.isBlockquote,
|
||||
icon: MessageSquareQuote,
|
||||
key: 'blockquote',
|
||||
label: t('typobar.blockquote'),
|
||||
onClick: editorState.blockquote,
|
||||
},
|
||||
{
|
||||
icon: LinkIcon,
|
||||
key: 'link',
|
||||
label: t('typobar.link'),
|
||||
onClick: editorState.insertLink,
|
||||
tooltipProps: { hotkey: getHotkeyById(HotkeyEnum.Link).keys },
|
||||
},
|
||||
{
|
||||
icon: SigmaIcon,
|
||||
key: 'math',
|
||||
label: t('typobar.tex'),
|
||||
onClick: editorState.insertMath,
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
active: editorState.isCode,
|
||||
icon: CodeXmlIcon,
|
||||
key: 'code',
|
||||
label: t('typobar.code'),
|
||||
onClick: editorState.code,
|
||||
tooltipProps: { hotkey: getHotkeyById(HotkeyEnum.CodeInline).keys },
|
||||
},
|
||||
!floating && {
|
||||
icon: SquareDashedBottomCodeIcon,
|
||||
key: 'codeblock',
|
||||
label: t('typobar.codeblock'),
|
||||
onClick: editorState.codeblock,
|
||||
},
|
||||
];
|
||||
|
||||
return baseItems.filter(Boolean) as ChatInputActionsProps['items'];
|
||||
}, [editor, editorState, extraItems, floating, t]);
|
||||
|
||||
if (!editorState) return null;
|
||||
|
||||
// Floating toolbar - just return the actions
|
||||
if (floating) return <FloatActions className={className} items={items} style={style} />;
|
||||
|
||||
// Fixed toolbar - wrap in a styled container
|
||||
return (
|
||||
<Block
|
||||
className={className}
|
||||
padding={4}
|
||||
shadow
|
||||
style={{
|
||||
background: cssVar.colorBgElevated,
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
marginTop: 16,
|
||||
position: 'sticky',
|
||||
top: 12,
|
||||
zIndex: 10,
|
||||
...style,
|
||||
}}
|
||||
variant={'outlined'}
|
||||
>
|
||||
<ChatInputActions items={items} />
|
||||
</Block>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
InlineToolbar.displayName = 'InlineToolbar';
|
||||
|
||||
export default InlineToolbar;
|
||||
@@ -0,0 +1,134 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
type IEditor,
|
||||
ReactCodePlugin,
|
||||
ReactCodemirrorPlugin,
|
||||
ReactHRPlugin,
|
||||
ReactImagePlugin,
|
||||
ReactLinkPlugin,
|
||||
ReactListPlugin,
|
||||
ReactLiteXmlPlugin,
|
||||
ReactMathPlugin,
|
||||
ReactTablePlugin,
|
||||
ReactToolbarPlugin,
|
||||
} from '@lobehub/editor';
|
||||
import { Editor, useEditorState } from '@lobehub/editor/react';
|
||||
import { memo, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { EditorCanvasProps } from './EditorCanvas';
|
||||
import InlineToolbar from './InlineToolbar';
|
||||
|
||||
/**
|
||||
* Base plugins for the editor (without toolbar)
|
||||
*/
|
||||
const BASE_PLUGINS = [
|
||||
ReactLiteXmlPlugin,
|
||||
ReactListPlugin,
|
||||
ReactCodePlugin,
|
||||
ReactCodemirrorPlugin,
|
||||
ReactHRPlugin,
|
||||
ReactLinkPlugin,
|
||||
ReactTablePlugin,
|
||||
ReactMathPlugin,
|
||||
Editor.withProps(ReactImagePlugin, {
|
||||
defaultBlockImage: true,
|
||||
}),
|
||||
];
|
||||
|
||||
export interface InternalEditorProps extends EditorCanvasProps {
|
||||
/**
|
||||
* Editor instance (required)
|
||||
*/
|
||||
editor: IEditor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal EditorCanvas component that requires editor instance
|
||||
*/
|
||||
const InternalEditor = memo<InternalEditorProps>(
|
||||
({
|
||||
editor,
|
||||
extraPlugins,
|
||||
floatingToolbar = true,
|
||||
onContentChange,
|
||||
onInit,
|
||||
placeholder,
|
||||
plugins: customPlugins,
|
||||
slashItems,
|
||||
style,
|
||||
toolbarExtraItems,
|
||||
}) => {
|
||||
const { t } = useTranslation('file');
|
||||
const editorState = useEditorState(editor);
|
||||
|
||||
const finalPlaceholder = placeholder || t('pageEditor.editorPlaceholder');
|
||||
|
||||
// Build plugins array
|
||||
const plugins = useMemo(() => {
|
||||
// If custom plugins provided, use them directly
|
||||
if (customPlugins) return customPlugins;
|
||||
|
||||
// Build base plugins with optional extra plugins prepended
|
||||
const basePlugins = extraPlugins ? [...extraPlugins, ...BASE_PLUGINS] : BASE_PLUGINS;
|
||||
|
||||
// Add toolbar if enabled
|
||||
if (floatingToolbar) {
|
||||
return [
|
||||
...basePlugins,
|
||||
Editor.withProps(ReactToolbarPlugin, {
|
||||
children: (
|
||||
<InlineToolbar
|
||||
editor={editor}
|
||||
editorState={editorState}
|
||||
extraItems={toolbarExtraItems}
|
||||
floating
|
||||
/>
|
||||
),
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
return basePlugins;
|
||||
}, [customPlugins, editor, editorState, extraPlugins, floatingToolbar, toolbarExtraItems]);
|
||||
|
||||
useEffect(() => {
|
||||
// for easier debug, mount editor instance to window
|
||||
if (editor) window.__editor = editor;
|
||||
|
||||
return () => {
|
||||
window.__editor = undefined;
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<Editor
|
||||
content={''}
|
||||
editor={editor}
|
||||
lineEmptyPlaceholder={finalPlaceholder}
|
||||
onInit={onInit}
|
||||
onTextChange={onContentChange}
|
||||
placeholder={finalPlaceholder}
|
||||
plugins={plugins}
|
||||
slashOption={slashItems ? { items: slashItems } : undefined}
|
||||
style={{
|
||||
paddingBottom: 64,
|
||||
...style,
|
||||
}}
|
||||
type={'text'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
InternalEditor.displayName = 'InternalEditor';
|
||||
|
||||
export default InternalEditor;
|
||||
+8
-6
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* Opens a native file selector dialog
|
||||
* @param handleFiles - Callback function to handle selected files
|
||||
* @param accept - MIME type filter for accepted files (default: all files)
|
||||
*/
|
||||
export function openFileSelector(handleFiles: (files: FileList) => void, accept = '*/*') {
|
||||
// Skip on server side
|
||||
if (typeof document === 'undefined') {
|
||||
@@ -7,16 +12,13 @@ export function openFileSelector(handleFiles: (files: FileList) => void, accept
|
||||
// Create a hidden input element
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = accept; // Accept all file types
|
||||
input.multiple = false; // Whether to allow multiple selection
|
||||
input.accept = accept;
|
||||
input.multiple = false;
|
||||
|
||||
// Listen for file selection events
|
||||
// eslint-disable-next-line unicorn/prefer-add-event-listener
|
||||
input.onchange = (event) => {
|
||||
// @ts-expect-error not error
|
||||
const files = event.target?.files;
|
||||
const files = (event.target as HTMLInputElement)?.files;
|
||||
if (files && files.length > 0) {
|
||||
// Handle selected files
|
||||
handleFiles(files);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
export { openFileSelector } from './actions';
|
||||
export { default as AutoSaveHint, type AutoSaveHintProps } from './AutoSaveHint';
|
||||
export {
|
||||
EditorCanvas,
|
||||
type EditorCanvasProps,
|
||||
type EditorCanvasWithEditorProps,
|
||||
} from './EditorCanvas';
|
||||
export { EditorErrorBoundary } from './ErrorBoundary';
|
||||
export { default as InlineToolbar, type InlineToolbarProps } from './InlineToolbar';
|
||||
@@ -1,68 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Flexbox, Skeleton } from '@lobehub/ui';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
|
||||
import EditorCanvas from '../EditorCanvas';
|
||||
import { usePageEditorStore } from '../store';
|
||||
import Title from './Title';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
loadingOverlay: css`
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
inset: 0;
|
||||
|
||||
padding: 24px;
|
||||
|
||||
background: ${cssVar.colorBgContainer};
|
||||
`,
|
||||
}));
|
||||
|
||||
const Body = memo(() => {
|
||||
const isLoadingContent = usePageEditorStore((s) => s.isLoadingContent);
|
||||
const [showSkeleton, setShowSkeleton] = useState(false);
|
||||
|
||||
// Only show skeleton if loading takes more than 1 second
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line no-undef
|
||||
let timer: NodeJS.Timeout;
|
||||
|
||||
if (isLoadingContent) {
|
||||
timer = setTimeout(() => {
|
||||
setShowSkeleton(true);
|
||||
}, 1000);
|
||||
} else {
|
||||
setShowSkeleton(false);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [isLoadingContent]);
|
||||
|
||||
return (
|
||||
<Flexbox flex={1} style={{ overflowY: 'auto', position: 'relative' }}>
|
||||
<Title />
|
||||
<EditorCanvas />
|
||||
{/* Show overlay immediately to hide old content */}
|
||||
{isLoadingContent && (
|
||||
<div className={styles.loadingOverlay}>
|
||||
{/* Only show skeleton after 1 second */}
|
||||
{showSkeleton && (
|
||||
<Flexbox gap={16}>
|
||||
<Skeleton.Title width="80%" />
|
||||
<Skeleton.Title width="60%" />
|
||||
<Skeleton.Title width="70%" />
|
||||
<Skeleton.Title width="50%" />
|
||||
<Skeleton.Title width="65%" />
|
||||
</Flexbox>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
export default Body;
|
||||
@@ -1,316 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { nanoid } from '@lobechat/utils';
|
||||
import {
|
||||
HIDE_TOOLBAR_COMMAND,
|
||||
HotkeyEnum,
|
||||
INSERT_HEADING_COMMAND,
|
||||
getHotkeyById,
|
||||
} from '@lobehub/editor';
|
||||
import { ChatInputActions, type ChatInputActionsProps, FloatActions } from '@lobehub/editor/react';
|
||||
import { Block } from '@lobehub/ui';
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
import {
|
||||
BoldIcon,
|
||||
BotIcon,
|
||||
CodeXmlIcon,
|
||||
Heading1Icon,
|
||||
Heading2Icon,
|
||||
Heading3Icon,
|
||||
ItalicIcon,
|
||||
LinkIcon,
|
||||
ListIcon,
|
||||
ListOrderedIcon,
|
||||
ListTodoIcon,
|
||||
MessageSquareQuote,
|
||||
Redo2Icon,
|
||||
SigmaIcon,
|
||||
SquareDashedBottomCodeIcon,
|
||||
StrikethroughIcon,
|
||||
UnderlineIcon,
|
||||
Undo2Icon,
|
||||
} from 'lucide-react';
|
||||
import { type CSSProperties, memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useFileStore } from '@/store/file';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
|
||||
import { usePageEditorStore } from '../store';
|
||||
|
||||
interface ToolbarProps {
|
||||
className?: string;
|
||||
floating?: boolean;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
askCopilot: css`
|
||||
border-radius: 6px;
|
||||
color: ${cssVar.colorTextDescription};
|
||||
|
||||
&:hover {
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
}
|
||||
`,
|
||||
}));
|
||||
|
||||
const TypoBar = memo<ToolbarProps>(({ floating, style, className }) => {
|
||||
const { t } = useTranslation('editor');
|
||||
const editor = usePageEditorStore((s) => s.editor);
|
||||
const editorState = usePageEditorStore((s) => s.editorState);
|
||||
const addSelectionContext = useFileStore((s) => s.addChatContextSelection);
|
||||
|
||||
const items: ChatInputActionsProps['items'] = useMemo(() => {
|
||||
if (!editorState) return [];
|
||||
const baseItems = [
|
||||
floating && {
|
||||
children: (
|
||||
<Block
|
||||
align="center"
|
||||
className={styles.askCopilot}
|
||||
clickable
|
||||
gap={8}
|
||||
horizontal
|
||||
onClick={() => {
|
||||
if (!editor) return;
|
||||
|
||||
const xml = (editor.getSelectionDocument?.('litexml') as string) || '';
|
||||
const plainText = (editor.getSelectionDocument?.('text') as string) || '';
|
||||
const content = xml.trim() || plainText.trim();
|
||||
|
||||
if (!content) return;
|
||||
|
||||
const format = xml.trim() ? 'xml' : 'text';
|
||||
const preview =
|
||||
(plainText || xml)
|
||||
.replaceAll(/<[^>]*>/g, ' ')
|
||||
.replaceAll(/\s+/g, ' ')
|
||||
.trim() || undefined;
|
||||
|
||||
// Store action handles deduplication
|
||||
addSelectionContext({
|
||||
content,
|
||||
format,
|
||||
id: `selection-${nanoid(6)}`,
|
||||
preview,
|
||||
title: 'Selection',
|
||||
type: 'text',
|
||||
});
|
||||
|
||||
// Open right panel if not opened
|
||||
useGlobalStore.getState().toggleRightPanel(true);
|
||||
|
||||
// Focus on chat input after a short delay to ensure panel is opened
|
||||
setTimeout(() => {
|
||||
// Find the chat input editor within the right panel
|
||||
// Query all lexical editors and get the last one (which should be the chat input)
|
||||
const allEditors = [...document.querySelectorAll('[data-lexical-editor="true"]')];
|
||||
const chatInputEditor = allEditors.at(-1) as HTMLElement;
|
||||
if (chatInputEditor) {
|
||||
chatInputEditor.focus();
|
||||
}
|
||||
}, 300);
|
||||
|
||||
editor.dispatchCommand(HIDE_TOOLBAR_COMMAND, undefined);
|
||||
editor.blur();
|
||||
}}
|
||||
paddingBlock={6}
|
||||
paddingInline={12}
|
||||
variant="borderless"
|
||||
>
|
||||
<BotIcon />
|
||||
<span>Ask Copilot</span>
|
||||
</Block>
|
||||
),
|
||||
key: 'ask-copilot',
|
||||
label: 'Ask Copilot',
|
||||
onClick: () => {},
|
||||
tooltipProps: { hotkey: undefined },
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
!floating && {
|
||||
disabled: !editorState.canUndo,
|
||||
icon: Undo2Icon,
|
||||
key: 'undo',
|
||||
label: t('typobar.undo', 'Undo'),
|
||||
onClick: editorState.undo,
|
||||
tooltipProps: { hotkey: getHotkeyById(HotkeyEnum.Undo).keys },
|
||||
},
|
||||
!floating && {
|
||||
disabled: !editorState.canRedo,
|
||||
icon: Redo2Icon,
|
||||
key: 'redo',
|
||||
label: t('typobar.redo', 'Redo'),
|
||||
onClick: editorState.redo,
|
||||
tooltipProps: { hotkey: getHotkeyById(HotkeyEnum.Redo).keys },
|
||||
},
|
||||
!floating && {
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
active: editorState.isBold,
|
||||
icon: BoldIcon,
|
||||
key: 'bold',
|
||||
label: t('typobar.bold'),
|
||||
onClick: editorState.bold,
|
||||
tooltipProps: { hotkey: getHotkeyById(HotkeyEnum.Bold).keys },
|
||||
},
|
||||
{
|
||||
active: editorState.isItalic,
|
||||
icon: ItalicIcon,
|
||||
key: 'italic',
|
||||
label: t('typobar.italic'),
|
||||
onClick: editorState.italic,
|
||||
tooltipProps: { hotkey: getHotkeyById(HotkeyEnum.Italic).keys },
|
||||
},
|
||||
{
|
||||
active: editorState.isUnderline,
|
||||
icon: UnderlineIcon,
|
||||
key: 'underline',
|
||||
label: t('typobar.underline'),
|
||||
onClick: editorState.underline,
|
||||
tooltipProps: { hotkey: getHotkeyById(HotkeyEnum.Underline).keys },
|
||||
},
|
||||
{
|
||||
active: editorState.isStrikethrough,
|
||||
icon: StrikethroughIcon,
|
||||
key: 'strikethrough',
|
||||
label: t('typobar.strikethrough'),
|
||||
onClick: editorState.strikethrough,
|
||||
tooltipProps: { hotkey: getHotkeyById(HotkeyEnum.Strikethrough).keys },
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
!floating && {
|
||||
icon: Heading1Icon,
|
||||
key: 'h1',
|
||||
label: t('slash.h1'),
|
||||
onClick: () => {
|
||||
if (editor) {
|
||||
editor.dispatchCommand(INSERT_HEADING_COMMAND, { tag: 'h1' });
|
||||
}
|
||||
},
|
||||
},
|
||||
!floating && {
|
||||
icon: Heading2Icon,
|
||||
key: 'h2',
|
||||
label: t('slash.h2'),
|
||||
onClick: () => {
|
||||
if (editor) {
|
||||
editor.dispatchCommand(INSERT_HEADING_COMMAND, { tag: 'h2' });
|
||||
}
|
||||
},
|
||||
},
|
||||
!floating && {
|
||||
icon: Heading3Icon,
|
||||
key: 'h3',
|
||||
label: t('slash.h3'),
|
||||
onClick: () => {
|
||||
if (editor) {
|
||||
editor.dispatchCommand(INSERT_HEADING_COMMAND, { tag: 'h3' });
|
||||
}
|
||||
},
|
||||
},
|
||||
!floating && {
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
icon: ListIcon,
|
||||
key: 'bulletList',
|
||||
label: t('typobar.bulletList'),
|
||||
onClick: editorState.bulletList,
|
||||
tooltipProps: { hotkey: getHotkeyById(HotkeyEnum.BulletList).keys },
|
||||
},
|
||||
{
|
||||
icon: ListOrderedIcon,
|
||||
key: 'numberlist',
|
||||
label: t('typobar.numberList'),
|
||||
onClick: editorState.numberList,
|
||||
tooltipProps: { hotkey: getHotkeyById(HotkeyEnum.NumberList).keys },
|
||||
},
|
||||
{
|
||||
icon: ListTodoIcon,
|
||||
key: 'tasklist',
|
||||
label: t('typobar.taskList'),
|
||||
onClick: editorState.checkList,
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
active: editorState.isBlockquote,
|
||||
icon: MessageSquareQuote,
|
||||
key: 'blockquote',
|
||||
label: t('typobar.blockquote'),
|
||||
onClick: editorState.blockquote,
|
||||
},
|
||||
{
|
||||
icon: LinkIcon,
|
||||
key: 'link',
|
||||
label: t('typobar.link'),
|
||||
onClick: editorState.insertLink,
|
||||
tooltipProps: { hotkey: getHotkeyById(HotkeyEnum.Link).keys },
|
||||
},
|
||||
{
|
||||
icon: SigmaIcon,
|
||||
key: 'math',
|
||||
label: t('typobar.tex'),
|
||||
onClick: editorState.insertMath,
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
active: editorState.isCode,
|
||||
icon: CodeXmlIcon,
|
||||
key: 'code',
|
||||
label: t('typobar.code'),
|
||||
onClick: editorState.code,
|
||||
tooltipProps: { hotkey: getHotkeyById(HotkeyEnum.CodeInline).keys },
|
||||
},
|
||||
!floating && {
|
||||
icon: SquareDashedBottomCodeIcon,
|
||||
key: 'codeblock',
|
||||
label: t('typobar.codeblock'),
|
||||
onClick: editorState.codeblock,
|
||||
},
|
||||
];
|
||||
|
||||
return baseItems.filter(Boolean) as ChatInputActionsProps['items'];
|
||||
}, [addSelectionContext, editor, editorState, floating, t]);
|
||||
|
||||
if (!editorState) return null;
|
||||
|
||||
// Floating toolbar - just return the actions
|
||||
if (floating) return <FloatActions className={className} items={items} style={style} />;
|
||||
|
||||
// Fixed toolbar - wrap in a styled container
|
||||
return (
|
||||
<Block
|
||||
className={className}
|
||||
padding={4}
|
||||
shadow
|
||||
style={{
|
||||
background: cssVar.colorBgElevated,
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
marginTop: 16,
|
||||
position: 'sticky',
|
||||
top: 12,
|
||||
zIndex: 10,
|
||||
...style,
|
||||
}}
|
||||
variant={'outlined'}
|
||||
>
|
||||
<ChatInputActions items={items} />
|
||||
</Block>
|
||||
);
|
||||
});
|
||||
|
||||
TypoBar.displayName = 'PageEditorToolbar';
|
||||
|
||||
export default TypoBar;
|
||||
@@ -1,76 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
ReactCodePlugin,
|
||||
ReactCodemirrorPlugin,
|
||||
ReactHRPlugin,
|
||||
ReactImagePlugin,
|
||||
ReactLinkPlugin,
|
||||
ReactListPlugin,
|
||||
ReactLiteXmlPlugin,
|
||||
ReactMathPlugin,
|
||||
ReactTablePlugin,
|
||||
ReactToolbarPlugin,
|
||||
} from '@lobehub/editor';
|
||||
import { Editor } from '@lobehub/editor/react';
|
||||
import { Alert } from '@lobehub/ui';
|
||||
import { type CSSProperties, Component, type ErrorInfo, type ReactNode, memo } from 'react';
|
||||
import { type CSSProperties, memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { EditorCanvas as SharedEditorCanvas } from '@/features/EditorCanvas';
|
||||
|
||||
import { usePageEditorStore } from '../store';
|
||||
import InlineToolbar from './InlineToolbar';
|
||||
import { useAskCopilotItem } from './useAskCopilotItem';
|
||||
import { useSlashItems } from './useSlashItems';
|
||||
|
||||
interface EditorErrorBoundaryState {
|
||||
error: Error | null;
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* ErrorBoundary for EditorCanvas component.
|
||||
* Catches rendering errors in the editor and displays a fallback error UI
|
||||
* instead of crashing the entire page.
|
||||
*/
|
||||
class EditorErrorBoundary extends Component<{ children: ReactNode }, EditorErrorBoundaryState> {
|
||||
public state: EditorErrorBoundaryState = {
|
||||
error: null,
|
||||
hasError: false,
|
||||
};
|
||||
|
||||
public static getDerivedStateFromError(error: Error): Partial<EditorErrorBoundaryState> {
|
||||
return { error, hasError: true };
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('[EditorErrorBoundary] Caught error in editor render:', {
|
||||
componentStack: errorInfo.componentStack,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Alert
|
||||
message={this.state.error?.message || 'An unknown error occurred in the editor'}
|
||||
showIcon
|
||||
style={{
|
||||
margin: 16,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
}}
|
||||
title="Editor Error"
|
||||
type="error"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
interface EditorCanvasProps {
|
||||
placeholder?: string;
|
||||
style?: CSSProperties;
|
||||
@@ -80,55 +18,20 @@ const EditorCanvas = memo<EditorCanvasProps>(({ placeholder, style }) => {
|
||||
const { t } = useTranslation(['file', 'editor']);
|
||||
|
||||
const editor = usePageEditorStore((s) => s.editor);
|
||||
const handleContentChange = usePageEditorStore((s) => s.handleContentChange);
|
||||
const onEditorInit = usePageEditorStore((s) => s.onEditorInit);
|
||||
const documentId = usePageEditorStore((s) => s.documentId);
|
||||
|
||||
const slashItems = useSlashItems(editor);
|
||||
|
||||
if (!editor) return null;
|
||||
const askCopilotItem = useAskCopilotItem(editor);
|
||||
|
||||
return (
|
||||
<EditorErrorBoundary>
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<Editor
|
||||
content={''}
|
||||
editor={editor!}
|
||||
lineEmptyPlaceholder={placeholder || t('pageEditor.editorPlaceholder')}
|
||||
onInit={onEditorInit}
|
||||
onTextChange={handleContentChange}
|
||||
placeholder={placeholder || t('pageEditor.editorPlaceholder')}
|
||||
plugins={[
|
||||
ReactLiteXmlPlugin,
|
||||
ReactListPlugin,
|
||||
ReactCodePlugin,
|
||||
ReactCodemirrorPlugin,
|
||||
ReactHRPlugin,
|
||||
ReactLinkPlugin,
|
||||
ReactTablePlugin,
|
||||
ReactMathPlugin,
|
||||
Editor.withProps(ReactImagePlugin, {
|
||||
defaultBlockImage: true,
|
||||
}),
|
||||
Editor.withProps(ReactToolbarPlugin, {
|
||||
children: <InlineToolbar floating />,
|
||||
}),
|
||||
]}
|
||||
slashOption={{
|
||||
items: slashItems,
|
||||
}}
|
||||
style={{
|
||||
paddingBottom: 64,
|
||||
...style,
|
||||
}}
|
||||
type={'text'}
|
||||
/>
|
||||
</div>
|
||||
</EditorErrorBoundary>
|
||||
<SharedEditorCanvas
|
||||
documentId={documentId}
|
||||
editor={editor}
|
||||
placeholder={placeholder || t('pageEditor.editorPlaceholder')}
|
||||
slashItems={slashItems}
|
||||
style={style}
|
||||
toolbarExtraItems={askCopilotItem}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import { nanoid } from '@lobechat/utils';
|
||||
import { HIDE_TOOLBAR_COMMAND, type IEditor } from '@lobehub/editor';
|
||||
import { type ChatInputActionsProps } from '@lobehub/editor/react';
|
||||
import { Block } from '@lobehub/ui';
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
import { BotIcon } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useFileStore } from '@/store/file';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
askCopilot: css`
|
||||
border-radius: 6px;
|
||||
color: ${cssVar.colorTextDescription};
|
||||
|
||||
&:hover {
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
}
|
||||
`,
|
||||
}));
|
||||
|
||||
export const useAskCopilotItem = (editor: IEditor | undefined): ChatInputActionsProps['items'] => {
|
||||
const addSelectionContext = useFileStore((s) => s.addChatContextSelection);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!editor) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
children: (
|
||||
<Block
|
||||
align="center"
|
||||
className={styles.askCopilot}
|
||||
clickable
|
||||
gap={8}
|
||||
horizontal
|
||||
onClick={() => {
|
||||
const xml = (editor.getSelectionDocument?.('litexml') as string) || '';
|
||||
const plainText = (editor.getSelectionDocument?.('text') as string) || '';
|
||||
const content = xml.trim() || plainText.trim();
|
||||
|
||||
if (!content) return;
|
||||
|
||||
const format = xml.trim() ? 'xml' : 'text';
|
||||
const preview =
|
||||
(plainText || xml)
|
||||
.replaceAll(/<[^>]*>/g, ' ')
|
||||
.replaceAll(/\s+/g, ' ')
|
||||
.trim() || undefined;
|
||||
|
||||
// Store action handles deduplication
|
||||
addSelectionContext({
|
||||
content,
|
||||
format,
|
||||
id: `selection-${nanoid(6)}`,
|
||||
preview,
|
||||
title: 'Selection',
|
||||
type: 'text',
|
||||
});
|
||||
|
||||
// Open right panel if not opened
|
||||
useGlobalStore.getState().toggleRightPanel(true);
|
||||
|
||||
// Focus on chat input after a short delay to ensure panel is opened
|
||||
setTimeout(() => {
|
||||
// Find the chat input editor within the right panel
|
||||
// Query all lexical editors and get the last one (which should be the chat input)
|
||||
const allEditors = [...document.querySelectorAll('[data-lexical-editor="true"]')];
|
||||
const chatInputEditor = allEditors.at(-1) as HTMLElement;
|
||||
if (chatInputEditor) {
|
||||
chatInputEditor.focus();
|
||||
}
|
||||
}, 300);
|
||||
|
||||
editor.dispatchCommand(HIDE_TOOLBAR_COMMAND, undefined);
|
||||
editor.blur();
|
||||
}}
|
||||
paddingBlock={6}
|
||||
paddingInline={12}
|
||||
variant="borderless"
|
||||
>
|
||||
<BotIcon />
|
||||
<span>Ask Copilot</span>
|
||||
</Block>
|
||||
),
|
||||
key: 'ask-copilot',
|
||||
label: 'Ask Copilot',
|
||||
onClick: () => {},
|
||||
},
|
||||
];
|
||||
}, [addSelectionContext, editor]);
|
||||
};
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { openFileSelector } from '@/features/PageEditor/EditorCanvas/actions';
|
||||
import { openFileSelector } from '@/features/EditorCanvas';
|
||||
import { useFileStore } from '@/store/file';
|
||||
|
||||
export const useSlashItems = (editor: IEditor | undefined): SlashOptions['items'] => {
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
|
||||
import AutoSaveHintBase from '@/components/Editor/AutoSaveHint';
|
||||
|
||||
import { usePageEditorStore } from '../store';
|
||||
|
||||
/**
|
||||
* AutoSaveHint - Save status indicator for page editor
|
||||
*/
|
||||
const AutoSaveHint = memo(() => {
|
||||
const saveStatus = usePageEditorStore((s) => s.saveStatus);
|
||||
const lastUpdatedTime = usePageEditorStore((s) => s.lastUpdatedTime);
|
||||
|
||||
return (
|
||||
<AutoSaveHintBase
|
||||
lastUpdatedTime={lastUpdatedTime}
|
||||
saveStatus={saveStatus}
|
||||
style={{
|
||||
marginLeft: 6,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default AutoSaveHint;
|
||||
@@ -40,7 +40,7 @@ interface FolderCrumb {
|
||||
const Breadcrumb = memo(() => {
|
||||
const { t } = useTranslation('file');
|
||||
|
||||
const currentTitle = usePageEditorStore((s) => s.currentTitle);
|
||||
const title = usePageEditorStore((s) => s.title);
|
||||
const knowledgeBaseId = usePageEditorStore((s) => s.knowledgeBaseId);
|
||||
const parentId = usePageEditorStore((s) => s.parentId);
|
||||
|
||||
@@ -61,7 +61,7 @@ const Breadcrumb = memo(() => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const documentTitle = currentTitle || t('pageEditor.titlePlaceholder');
|
||||
const documentTitle = title || t('pageEditor.titlePlaceholder');
|
||||
|
||||
return (
|
||||
<Flexbox align={'center'} className={styles.breadcrumb} flex={1} gap={0} horizontal>
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { ActionIcon, Avatar, Dropdown, Skeleton, Text } from '@lobehub/ui';
|
||||
import { ActionIcon, Avatar, Dropdown, Text } from '@lobehub/ui';
|
||||
import { ArrowLeftIcon, BotMessageSquareIcon, MoreHorizontal } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { DESKTOP_HEADER_ICON_SIZE } from '@/const/layoutTokens';
|
||||
import { AutoSaveHint } from '@/features/EditorCanvas';
|
||||
import NavHeader from '@/features/NavHeader';
|
||||
import ToggleRightPanelButton from '@/features/RightPanel/ToggleRightPanelButton';
|
||||
|
||||
import { usePageEditorStore } from '../store';
|
||||
import AutoSaveHint from './AutoSaveHint';
|
||||
import Breadcrumb from './Breadcrumb';
|
||||
import { useMenu } from './useMenu';
|
||||
|
||||
const Header = memo(() => {
|
||||
const { t } = useTranslation('file');
|
||||
const [currentEmoji, currentTitle, isLoadingContent, parentId, onBack] = usePageEditorStore(
|
||||
(s) => [s.currentEmoji, s.currentTitle, s.isLoadingContent, s.parentId, s.onBack],
|
||||
);
|
||||
const [documentId, emoji, title, parentId, onBack] = usePageEditorStore((s) => [
|
||||
s.documentId,
|
||||
s.emoji,
|
||||
s.title,
|
||||
s.parentId,
|
||||
s.onBack,
|
||||
]);
|
||||
const { menuItems } = useMenu();
|
||||
|
||||
return (
|
||||
@@ -32,22 +36,15 @@ const Header = memo(() => {
|
||||
{!parentId && (
|
||||
<>
|
||||
{/* Icon */}
|
||||
{currentEmoji && <Avatar avatar={currentEmoji} shape={'square'} size={28} />}
|
||||
{emoji && <Avatar avatar={emoji} shape={'square'} size={28} />}
|
||||
{/* Title */}
|
||||
{isLoadingContent ? (
|
||||
<Skeleton.Button
|
||||
active
|
||||
style={{ height: 20, marginLeft: 4, maxWidth: 200, width: 200 }}
|
||||
/>
|
||||
) : (
|
||||
<Text ellipsis style={{ marginLeft: 4 }} weight={500}>
|
||||
{currentTitle || t('pageEditor.titlePlaceholder')}
|
||||
</Text>
|
||||
)}
|
||||
<Text ellipsis style={{ marginLeft: 4 }} weight={500}>
|
||||
{title || t('pageEditor.titlePlaceholder')}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{/* Auto Save Status - hide while loading */}
|
||||
{!isLoadingContent && <AutoSaveHint />}
|
||||
{/* Auto Save Status */}
|
||||
{documentId && <AutoSaveHint documentId={documentId} style={{ marginLeft: 6 }} />}
|
||||
</>
|
||||
}
|
||||
right={
|
||||
|
||||
@@ -6,6 +6,8 @@ import { CopyPlus, Download, Link2, Trash2 } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useDocumentStore } from '@/store/document';
|
||||
import { editorSelectors } from '@/store/document/slices/editor';
|
||||
import { useFileStore } from '@/store/file';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { systemStatusSelectors } from '@/store/global/selectors';
|
||||
@@ -21,9 +23,12 @@ export const useMenu = (): { menuItems: any[] } => {
|
||||
const storeApi = useStoreApi();
|
||||
const { lg = true } = useResponsive();
|
||||
|
||||
const lastUpdatedTime = usePageEditorStore((s) => s.lastUpdatedTime);
|
||||
const wordCount = usePageEditorStore((s) => s.wordCount);
|
||||
const currentDocId = usePageEditorStore((s) => s.currentDocId);
|
||||
const documentId = usePageEditorStore((s) => s.documentId);
|
||||
|
||||
// Get lastUpdatedTime from DocumentStore
|
||||
const lastUpdatedTime = useDocumentStore((s) =>
|
||||
documentId ? editorSelectors.lastUpdatedTime(documentId)(s) : null,
|
||||
);
|
||||
|
||||
const duplicateDocument = useFileStore((s) => s.duplicateDocument);
|
||||
|
||||
@@ -36,9 +41,9 @@ export const useMenu = (): { menuItems: any[] } => {
|
||||
const showViewModeSwitch = lg;
|
||||
|
||||
const handleDuplicate = async () => {
|
||||
if (!currentDocId) return;
|
||||
if (!documentId) return;
|
||||
try {
|
||||
await duplicateDocument(currentDocId);
|
||||
await duplicateDocument(documentId);
|
||||
message.success(t('pageEditor.duplicateSuccess'));
|
||||
} catch (error) {
|
||||
console.error('Failed to duplicate page:', error);
|
||||
@@ -48,7 +53,7 @@ export const useMenu = (): { menuItems: any[] } => {
|
||||
|
||||
const handleExportMarkdown = () => {
|
||||
const state = storeApi.getState();
|
||||
const { editor, currentTitle } = state;
|
||||
const { editor, title } = state;
|
||||
|
||||
if (!editor) return;
|
||||
|
||||
@@ -58,7 +63,7 @@ export const useMenu = (): { menuItems: any[] } => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${currentTitle || 'Untitled'}.md`;
|
||||
a.download = `${title || 'Untitled'}.md`;
|
||||
document.body.append(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
@@ -143,7 +148,6 @@ export const useMenu = (): { menuItems: any[] } => {
|
||||
key: 'page-info',
|
||||
label: (
|
||||
<div style={{ color: cssVar.colorTextTertiary, fontSize: 12, lineHeight: 1.6 }}>
|
||||
<div>{t('pageEditor.wordCount', { wordCount })}</div>
|
||||
<div>
|
||||
{lastUpdatedTime
|
||||
? t('pageEditor.editedAt', {
|
||||
@@ -156,7 +160,6 @@ export const useMenu = (): { menuItems: any[] } => {
|
||||
},
|
||||
],
|
||||
[
|
||||
wordCount,
|
||||
lastUpdatedTime,
|
||||
storeApi,
|
||||
t,
|
||||
|
||||
@@ -1,43 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import { BUILTIN_AGENT_SLUGS } from '@lobechat/builtin-agents';
|
||||
import { EditorProvider } from '@lobehub/editor/react';
|
||||
import { Flexbox } from '@lobehub/ui';
|
||||
import { cssVar } from 'antd-style';
|
||||
import { type FC, memo, useEffect } from 'react';
|
||||
|
||||
import Loading from '@/components/Loading/BrandTextLoading';
|
||||
import DiffAllToolbar from '@/features/EditorCanvas/DiffAllToolbar';
|
||||
import WideScreenContainer from '@/features/WideScreenContainer';
|
||||
import { useRegisterFilesHotkeys, useSaveDocumentHotkey } from '@/hooks/useHotkeys';
|
||||
import { useRegisterFilesHotkeys } from '@/hooks/useHotkeys';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
import { builtinAgentSelectors } from '@/store/agent/selectors';
|
||||
import { useFileStore } from '@/store/file';
|
||||
import { useDocumentStore } from '@/store/document';
|
||||
import { editorSelectors } from '@/store/document/slices/editor';
|
||||
import { usePageStore } from '@/store/page';
|
||||
|
||||
import Body from './Body';
|
||||
import Copilot from './Copilot';
|
||||
import DiffAllToolbar from './DiffAllToolbar';
|
||||
import EditorCanvas from './EditorCanvas';
|
||||
import Header from './Header';
|
||||
import PageAgentProvider from './PageAgentProvider';
|
||||
import { PageEditorProvider } from './PageEditorProvider';
|
||||
import PageTitle from './PageTitle';
|
||||
import TitleSection from './TitleSection';
|
||||
import { usePageEditorStore } from './store';
|
||||
|
||||
interface PageEditorProps {
|
||||
emoji?: string;
|
||||
knowledgeBaseId?: string;
|
||||
onBack?: () => void;
|
||||
onDelete?: () => void;
|
||||
onDocumentIdChange?: (newId: string) => void;
|
||||
onEmojiChange?: (emoji: string | undefined) => void;
|
||||
onSave?: () => void;
|
||||
onTitleChange?: (title: string) => void;
|
||||
pageId?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const PageEditorCanvas = memo(() => {
|
||||
const editor = usePageEditorStore((s) => s.editor);
|
||||
const flushSave = usePageEditorStore((s) => s.flushSave);
|
||||
const isDirty = usePageEditorStore((s) => s.isDirty);
|
||||
const documentId = usePageEditorStore((s) => s.documentId);
|
||||
|
||||
// Get isDirty from DocumentStore
|
||||
const isDirty = useDocumentStore((s) =>
|
||||
documentId ? editorSelectors.isDirty(documentId)(s) : false,
|
||||
);
|
||||
|
||||
// Register Files scope and save document hotkey
|
||||
useRegisterFilesHotkeys();
|
||||
useSaveDocumentHotkey(flushSave);
|
||||
|
||||
// Warn user before leaving page with unsaved changes
|
||||
useEffect(() => {
|
||||
@@ -75,10 +86,13 @@ const PageEditorCanvas = memo(() => {
|
||||
width={'100%'}
|
||||
>
|
||||
<WideScreenContainer onClick={() => editor?.focus()} wrapperStyle={{ cursor: 'text' }}>
|
||||
<Body />
|
||||
<Flexbox flex={1} style={{ overflowY: 'auto', position: 'relative' }}>
|
||||
<TitleSection />
|
||||
<EditorCanvas />
|
||||
</Flexbox>
|
||||
</WideScreenContainer>
|
||||
</Flexbox>
|
||||
<DiffAllToolbar />
|
||||
{documentId && <DiffAllToolbar documentId={documentId} editor={editor!} />}
|
||||
</Flexbox>
|
||||
<Copilot />
|
||||
</Flexbox>
|
||||
@@ -95,31 +109,41 @@ export const PageEditor: FC<PageEditorProps> = ({
|
||||
pageId,
|
||||
knowledgeBaseId,
|
||||
onDocumentIdChange,
|
||||
onEmojiChange,
|
||||
onSave,
|
||||
onTitleChange,
|
||||
onBack,
|
||||
title,
|
||||
emoji,
|
||||
}) => {
|
||||
const useInitBuiltinAgent = useAgentStore((s) => s.useInitBuiltinAgent);
|
||||
const pageAgentId = useAgentStore(builtinAgentSelectors.pageAgentId);
|
||||
|
||||
useInitBuiltinAgent(BUILTIN_AGENT_SLUGS.pageAgent);
|
||||
|
||||
const [deletePage] = useFileStore((s) => [s.deletePage]);
|
||||
const deletePage = usePageStore((s) => s.deletePage);
|
||||
|
||||
if (!pageAgentId) return <Loading debugId="PageEditor > PageAgent Init" />;
|
||||
|
||||
return (
|
||||
<PageAgentProvider pageAgentId={pageAgentId}>
|
||||
<PageEditorProvider
|
||||
key={pageId}
|
||||
knowledgeBaseId={knowledgeBaseId}
|
||||
onBack={onBack}
|
||||
onDelete={() => deletePage(pageId || '')}
|
||||
onDocumentIdChange={onDocumentIdChange}
|
||||
onSave={onSave}
|
||||
pageId={pageId}
|
||||
>
|
||||
<PageEditorCanvas />
|
||||
</PageEditorProvider>
|
||||
<EditorProvider>
|
||||
<PageEditorProvider
|
||||
emoji={emoji}
|
||||
key={pageId}
|
||||
knowledgeBaseId={knowledgeBaseId}
|
||||
onBack={onBack}
|
||||
onDelete={() => deletePage(pageId || '')}
|
||||
onDocumentIdChange={onDocumentIdChange}
|
||||
onEmojiChange={onEmojiChange}
|
||||
onSave={onSave}
|
||||
onTitleChange={onTitleChange}
|
||||
pageId={pageId}
|
||||
title={title}
|
||||
>
|
||||
<PageEditorCanvas />
|
||||
</PageEditorProvider>
|
||||
</EditorProvider>
|
||||
</PageAgentProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -19,10 +19,14 @@ export const PageEditorProvider = memo<PageEditorProviderProps>(
|
||||
pageId,
|
||||
knowledgeBaseId,
|
||||
onDocumentIdChange,
|
||||
onEmojiChange,
|
||||
onSave,
|
||||
onTitleChange,
|
||||
onDelete,
|
||||
onBack,
|
||||
parentId,
|
||||
title,
|
||||
emoji,
|
||||
}) => {
|
||||
const editor = useEditor();
|
||||
|
||||
@@ -30,25 +34,33 @@ export const PageEditorProvider = memo<PageEditorProviderProps>(
|
||||
<Provider
|
||||
createStore={() =>
|
||||
createStore({
|
||||
documentId: pageId,
|
||||
editor,
|
||||
emoji,
|
||||
knowledgeBaseId,
|
||||
onBack,
|
||||
onDelete,
|
||||
onDocumentIdChange,
|
||||
onEmojiChange,
|
||||
onSave,
|
||||
pageId,
|
||||
onTitleChange,
|
||||
parentId,
|
||||
title,
|
||||
})
|
||||
}
|
||||
>
|
||||
<StoreUpdater
|
||||
emoji={emoji}
|
||||
knowledgeBaseId={knowledgeBaseId}
|
||||
onBack={onBack}
|
||||
onDelete={onDelete}
|
||||
onDocumentIdChange={onDocumentIdChange}
|
||||
onEmojiChange={onEmojiChange}
|
||||
onSave={onSave}
|
||||
onTitleChange={onTitleChange}
|
||||
pageId={pageId}
|
||||
parentId={parentId}
|
||||
title={title}
|
||||
/>
|
||||
{children}
|
||||
</Provider>
|
||||
|
||||
@@ -6,8 +6,8 @@ import PageTitle from '@/components/PageTitle';
|
||||
import { selectors, usePageEditorStore } from '@/features/PageEditor/store';
|
||||
|
||||
const Title = memo(() => {
|
||||
const pageTitle = usePageEditorStore(selectors.currentTitle);
|
||||
return <PageTitle title={pageTitle} />;
|
||||
const pageTitle = usePageEditorStore(selectors.title);
|
||||
return pageTitle && <PageTitle title={pageTitle} />;
|
||||
});
|
||||
|
||||
export default Title;
|
||||
|
||||
@@ -1,319 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import { useEditorState } from '@lobehub/editor/react';
|
||||
import debug from 'debug';
|
||||
import React, { memo, useEffect, useRef } from 'react';
|
||||
import { memo, useEffect } from 'react';
|
||||
import { createStoreUpdater } from 'zustand-utils';
|
||||
|
||||
import { useFileStore } from '@/store/file';
|
||||
import { documentSelectors } from '@/store/file/slices/document/selectors';
|
||||
import { pageAgentRuntime } from '@/store/tool/slices/builtin/executors/lobe-page-agent';
|
||||
|
||||
import { type PublicState, usePageEditorStore, useStoreApi } from './store';
|
||||
|
||||
const log = debug('page:store-updater');
|
||||
|
||||
export type StoreUpdaterProps = Partial<PublicState>;
|
||||
|
||||
// State machine types
|
||||
type InitPhase =
|
||||
| 'idle' // Initial state
|
||||
| 'waiting-for-data' // Waiting for SWR to fetch/return cached data
|
||||
| 'initializing' // Setting metadata (currentDocId, title, emoji)
|
||||
| 'loading-content' // Loading content into editor
|
||||
| 'ready' // Initialization complete
|
||||
| 'error'; // Error occurred
|
||||
|
||||
interface InitState {
|
||||
error?: Error;
|
||||
phase: InitPhase;
|
||||
targetPageId: string | undefined;
|
||||
export interface StoreUpdaterProps extends Partial<PublicState> {
|
||||
pageId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* StoreUpdater syncs PageEditorStore props and connects to page agent runtime.
|
||||
*
|
||||
* Note: Document content loading is handled by EditorCanvas via DocumentStore.
|
||||
* Title/emoji are consumed from PageEditorStore (set via setCurrentTitle/setCurrentEmoji).
|
||||
*/
|
||||
const StoreUpdater = memo<StoreUpdaterProps>(
|
||||
({ pageId, knowledgeBaseId, onDocumentIdChange, onSave, onDelete, onBack, parentId }) => {
|
||||
({
|
||||
pageId,
|
||||
knowledgeBaseId,
|
||||
onDocumentIdChange,
|
||||
onEmojiChange,
|
||||
onSave,
|
||||
onTitleChange,
|
||||
onDelete,
|
||||
onBack,
|
||||
parentId,
|
||||
title,
|
||||
emoji,
|
||||
}) => {
|
||||
const storeApi = useStoreApi();
|
||||
const useStoreUpdater = createStoreUpdater(storeApi);
|
||||
|
||||
const editor = usePageEditorStore((s) => s.editor);
|
||||
const editorState = useEditorState(editor);
|
||||
const currentDocId = usePageEditorStore((s) => s.currentDocId);
|
||||
|
||||
// Use SWR hook for document fetching with caching
|
||||
const { isLoading: isLoadingDetail, error: swrError } = useFileStore((s) =>
|
||||
s.useFetchDocumentDetail(pageId),
|
||||
);
|
||||
const currentPage = useFileStore(documentSelectors.getDocumentById(pageId));
|
||||
|
||||
const [editorInit, setEditorInit] = React.useState(false);
|
||||
const [contentInit, setContentInit] = React.useState(false);
|
||||
const [phaseUpdateCounter, setPhaseUpdateCounter] = React.useState(0);
|
||||
const lastLoadedDocIdRef = useRef<string | undefined>(undefined);
|
||||
const initStateRef = useRef<InitState>({
|
||||
phase: 'idle',
|
||||
targetPageId: undefined,
|
||||
});
|
||||
|
||||
// Helper to transition phase and trigger re-render
|
||||
const transitionPhase = React.useCallback((newPhase: InitPhase) => {
|
||||
log(`Transitioning phase: ${initStateRef.current.phase} -> ${newPhase}`);
|
||||
initStateRef.current.phase = newPhase;
|
||||
setPhaseUpdateCounter((n) => n + 1); // Trigger re-render
|
||||
}, []);
|
||||
|
||||
// Update editorState in store
|
||||
useEffect(() => {
|
||||
storeApi.setState({ editorState });
|
||||
}, [editorState, storeApi]);
|
||||
const initMeta = usePageEditorStore((s) => s.initMeta);
|
||||
|
||||
// Update store with props
|
||||
useStoreUpdater('pageId', pageId);
|
||||
useStoreUpdater('documentId', pageId);
|
||||
useStoreUpdater('knowledgeBaseId', knowledgeBaseId);
|
||||
useStoreUpdater('onDocumentIdChange', onDocumentIdChange);
|
||||
useStoreUpdater('onEmojiChange', onEmojiChange);
|
||||
useStoreUpdater('onSave', onSave);
|
||||
useStoreUpdater('onTitleChange', onTitleChange);
|
||||
useStoreUpdater('onDelete', onDelete);
|
||||
useStoreUpdater('onBack', onBack);
|
||||
useStoreUpdater('parentId', parentId);
|
||||
|
||||
// State machine effect for deterministic initialization
|
||||
// Initialize meta (title/emoji) with dirty tracking
|
||||
useEffect(() => {
|
||||
const state = initStateRef.current;
|
||||
|
||||
// Phase handler functions
|
||||
const handleIdlePhase = () => {
|
||||
// Check if we can start initialization
|
||||
if (!pageId || !editor || !editorInit) {
|
||||
log('idle: Waiting for prerequisites', { editor: !!editor, editorInit, pageId });
|
||||
return;
|
||||
}
|
||||
|
||||
// Transition to waiting-for-data
|
||||
log('idle -> waiting-for-data:', pageId);
|
||||
|
||||
// Reset UI state
|
||||
setContentInit(false);
|
||||
storeApi.setState({
|
||||
currentTitle: '',
|
||||
isLoadingContent: true,
|
||||
wordCount: 0,
|
||||
});
|
||||
|
||||
transitionPhase('waiting-for-data');
|
||||
};
|
||||
|
||||
const handleWaitingForDataPhase = () => {
|
||||
// Check for errors
|
||||
if (swrError && !isLoadingDetail) {
|
||||
log('waiting-for-data: Error occurred', swrError);
|
||||
initStateRef.current.error = swrError as Error;
|
||||
storeApi.setState({ isLoadingContent: false });
|
||||
transitionPhase('error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for SWR to finish loading
|
||||
if (isLoadingDetail) {
|
||||
log('waiting-for-data: Still loading...');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we have data
|
||||
if (!currentPage) {
|
||||
log('waiting-for-data: No data available yet');
|
||||
return;
|
||||
}
|
||||
|
||||
// Transition to initializing
|
||||
log('waiting-for-data -> initializing');
|
||||
transitionPhase('initializing');
|
||||
};
|
||||
|
||||
const handleInitializingPhase = () => {
|
||||
// Check if already initialized for this pageId
|
||||
if (lastLoadedDocIdRef.current === pageId && currentDocId === pageId) {
|
||||
log('initializing: Already initialized, moving to loading-content');
|
||||
transitionPhase('loading-content');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set metadata
|
||||
log('initializing: Setting metadata for pageId:', pageId, {
|
||||
hasEditorData: !!currentPage?.editorData,
|
||||
title: currentPage?.title,
|
||||
});
|
||||
|
||||
lastLoadedDocIdRef.current = pageId;
|
||||
setContentInit(false);
|
||||
|
||||
storeApi.setState({
|
||||
currentDocId: pageId,
|
||||
currentEmoji: currentPage?.metadata?.emoji,
|
||||
currentTitle: currentPage?.title || '',
|
||||
});
|
||||
|
||||
// Transition to loading-content
|
||||
log('initializing -> loading-content');
|
||||
transitionPhase('loading-content');
|
||||
};
|
||||
|
||||
const handleLoadingContentPhase = () => {
|
||||
// Prerequisites check
|
||||
if (!editor || !editorInit || contentInit) {
|
||||
log('loading-content: Waiting', { contentInit, editor: !!editor, editorInit });
|
||||
return;
|
||||
}
|
||||
|
||||
// Safety check: Prevent loading stale content
|
||||
const currentState = storeApi.getState();
|
||||
if (currentState.currentDocId && currentState.currentDocId !== pageId) {
|
||||
log('loading-content: currentDocId mismatch, aborting', {
|
||||
currentDocId: currentState.currentDocId,
|
||||
pageId,
|
||||
});
|
||||
initStateRef.current.error = new Error('Document ID mismatch');
|
||||
storeApi.setState({ isLoadingContent: false });
|
||||
transitionPhase('error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Load content (defer to avoid flushSync warning)
|
||||
log('loading-content: Queueing content load');
|
||||
|
||||
queueMicrotask(() => {
|
||||
try {
|
||||
// Re-check state in case pageId changed during microtask
|
||||
if (initStateRef.current.targetPageId !== pageId) {
|
||||
log('loading-content: PageId changed during queue, aborting');
|
||||
return;
|
||||
}
|
||||
|
||||
log('Loading content for page:', pageId);
|
||||
|
||||
// Helper to calculate word count
|
||||
const calculateWordCount = (text: string) =>
|
||||
text.trim().split(/\s+/).filter(Boolean).length;
|
||||
|
||||
storeApi.setState({ lastUpdatedTime: null });
|
||||
|
||||
// Check if editorData is valid and non-empty
|
||||
const hasValidEditorData =
|
||||
currentPage?.editorData &&
|
||||
typeof currentPage.editorData === 'object' &&
|
||||
Object.keys(currentPage.editorData).length > 0;
|
||||
|
||||
// Load from editorData if available
|
||||
if (hasValidEditorData) {
|
||||
log('Loading from editorData');
|
||||
editor.setDocument('json', JSON.stringify(currentPage.editorData));
|
||||
const textContent = currentPage.content || '';
|
||||
storeApi.setState({ wordCount: calculateWordCount(textContent) });
|
||||
} else if (currentPage?.content && currentPage.content.trim()) {
|
||||
log('Loading from content - no valid editorData found');
|
||||
editor.setDocument('markdown', currentPage.content);
|
||||
storeApi.setState({ wordCount: calculateWordCount(currentPage.content) });
|
||||
} else if (currentPage?.pages) {
|
||||
// Fallback to pages content
|
||||
const pagesContent = currentPage.pages
|
||||
.map((page) => page.pageContent)
|
||||
.join('\n\n')
|
||||
.trim();
|
||||
if (pagesContent) {
|
||||
log('Loading from pages content');
|
||||
editor.setDocument('markdown', pagesContent);
|
||||
storeApi.setState({ wordCount: calculateWordCount(pagesContent) });
|
||||
} else {
|
||||
log('Clearing editor - empty pages');
|
||||
editor.setDocument('markdown', ' ');
|
||||
storeApi.setState({ wordCount: 0 });
|
||||
}
|
||||
} else {
|
||||
// Empty document or temp page - clear editor with minimal content
|
||||
log('Clearing editor - empty/new page');
|
||||
editor.setDocument('markdown', ' ');
|
||||
storeApi.setState({ wordCount: 0 });
|
||||
}
|
||||
|
||||
setContentInit(true);
|
||||
storeApi.setState({ isLoadingContent: false });
|
||||
|
||||
// Transition to ready
|
||||
log('loading-content -> ready');
|
||||
transitionPhase('ready');
|
||||
} catch (error) {
|
||||
log('Failed to load editor content:', error);
|
||||
storeApi.setState({ isLoadingContent: false });
|
||||
initStateRef.current.error = error as Error;
|
||||
transitionPhase('error');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleErrorPhase = () => {
|
||||
const error = initStateRef.current.error;
|
||||
log('error phase:', error?.message);
|
||||
// Error state is sticky until pageId changes
|
||||
};
|
||||
|
||||
// Reset to idle if pageId changes
|
||||
if (pageId !== state.targetPageId) {
|
||||
log('PageId changed, resetting to idle', { from: state.targetPageId, to: pageId });
|
||||
initStateRef.current = { phase: 'idle', targetPageId: pageId };
|
||||
setContentInit(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Early exit if already ready
|
||||
if (state.phase === 'ready') return;
|
||||
|
||||
// Execute phase handler
|
||||
switch (state.phase) {
|
||||
case 'idle': {
|
||||
handleIdlePhase();
|
||||
break;
|
||||
}
|
||||
case 'waiting-for-data': {
|
||||
handleWaitingForDataPhase();
|
||||
break;
|
||||
}
|
||||
case 'initializing': {
|
||||
handleInitializingPhase();
|
||||
break;
|
||||
}
|
||||
case 'loading-content': {
|
||||
handleLoadingContentPhase();
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
handleErrorPhase();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [
|
||||
contentInit,
|
||||
currentDocId,
|
||||
currentPage,
|
||||
editor,
|
||||
editorInit,
|
||||
isLoadingDetail,
|
||||
pageId,
|
||||
phaseUpdateCounter,
|
||||
storeApi,
|
||||
swrError,
|
||||
transitionPhase,
|
||||
]);
|
||||
|
||||
// Track editor initialization
|
||||
useEffect(() => {
|
||||
if (editor && !editorInit) {
|
||||
setEditorInit(true);
|
||||
}
|
||||
}, [editor, editorInit]);
|
||||
initMeta(title, emoji);
|
||||
}, [pageId, title, emoji]);
|
||||
|
||||
// Connect editor to page agent runtime
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
// for easier debug , mount editor instance to window
|
||||
window.__editor = editor;
|
||||
pageAgentRuntime.setEditor(editor);
|
||||
}
|
||||
return () => {
|
||||
@@ -321,35 +63,20 @@ const StoreUpdater = memo<StoreUpdaterProps>(
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
// Connect title handlers to page agent runtime
|
||||
// Connect title handlers and document ID to page agent runtime
|
||||
useEffect(() => {
|
||||
const titleSetter = (title: string) => {
|
||||
storeApi.setState({ currentTitle: title });
|
||||
};
|
||||
|
||||
const titleGetter = () => {
|
||||
return storeApi.getState().currentTitle;
|
||||
return storeApi.getState().title || '';
|
||||
};
|
||||
|
||||
pageAgentRuntime.setTitleHandlers(titleSetter, titleGetter);
|
||||
pageAgentRuntime.setCurrentDocId(pageId);
|
||||
pageAgentRuntime.setTitleHandlers(storeApi.getState().setTitle, titleGetter);
|
||||
|
||||
return () => {
|
||||
pageAgentRuntime.setCurrentDocId(undefined);
|
||||
pageAgentRuntime.setTitleHandlers(null, null);
|
||||
};
|
||||
}, [storeApi]);
|
||||
|
||||
// Update current document ID in page agent runtime when page changes
|
||||
useEffect(() => {
|
||||
// Use currentDocId (which includes temp docs) or fallback to pageId
|
||||
const activeId = currentDocId || pageId;
|
||||
log('Updating currentDocId in page agent runtime:', activeId);
|
||||
pageAgentRuntime.setCurrentDocId(activeId);
|
||||
|
||||
return () => {
|
||||
log('Clearing currentDocId on unmount');
|
||||
pageAgentRuntime.setCurrentDocId(undefined);
|
||||
};
|
||||
}, [currentDocId, pageId]);
|
||||
}, [pageId, storeApi]);
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
@@ -11,16 +11,16 @@ import { useGlobalStore } from '@/store/global';
|
||||
import { globalGeneralSelectors } from '@/store/global/selectors';
|
||||
import { truncateByWeightedLength } from '@/utils/textLength';
|
||||
|
||||
import { usePageEditorStore } from '../store';
|
||||
import { usePageEditorStore } from './store';
|
||||
|
||||
const Title = memo(() => {
|
||||
const TitleSection = memo(() => {
|
||||
const { t } = useTranslation('file');
|
||||
const locale = useGlobalStore(globalGeneralSelectors.currentLanguage);
|
||||
|
||||
const currentEmoji = usePageEditorStore((s) => s.currentEmoji);
|
||||
const currentTitle = usePageEditorStore((s) => s.currentTitle);
|
||||
const setCurrentEmoji = usePageEditorStore((s) => s.setCurrentEmoji);
|
||||
const setCurrentTitle = usePageEditorStore((s) => s.setCurrentTitle);
|
||||
const emoji = usePageEditorStore((s) => s.emoji);
|
||||
const title = usePageEditorStore((s) => s.title);
|
||||
const setEmoji = usePageEditorStore((s) => s.setEmoji);
|
||||
const setTitle = usePageEditorStore((s) => s.setTitle);
|
||||
const handleTitleSubmit = usePageEditorStore((s) => s.handleTitleSubmit);
|
||||
|
||||
const [isHoveringTitle, setIsHoveringTitle] = useState(false);
|
||||
@@ -41,16 +41,16 @@ const Title = memo(() => {
|
||||
}}
|
||||
>
|
||||
{/* Emoji picker above Choose Icon button */}
|
||||
{(currentEmoji || showEmojiPicker) && (
|
||||
{(emoji || showEmojiPicker) && (
|
||||
<EmojiPicker
|
||||
allowDelete
|
||||
locale={locale}
|
||||
onChange={(emoji) => {
|
||||
setCurrentEmoji(emoji);
|
||||
onChange={(e) => {
|
||||
setEmoji(e);
|
||||
setShowEmojiPicker(false);
|
||||
}}
|
||||
onDelete={() => {
|
||||
setCurrentEmoji(undefined);
|
||||
setEmoji(undefined);
|
||||
setShowEmojiPicker(false);
|
||||
}}
|
||||
onOpenChange={(open) => {
|
||||
@@ -60,16 +60,16 @@ const Title = memo(() => {
|
||||
shape={'square'}
|
||||
size={72}
|
||||
title={t('pageEditor.chooseIcon')}
|
||||
value={currentEmoji}
|
||||
value={emoji}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Choose Icon button - only shown when no emoji */}
|
||||
{!currentEmoji && !showEmojiPicker && (
|
||||
{!emoji && !showEmojiPicker && (
|
||||
<Button
|
||||
icon={<Icon icon={SmilePlus} />}
|
||||
onClick={() => {
|
||||
setCurrentEmoji('📄');
|
||||
setEmoji('📄');
|
||||
setShowEmojiPicker(true);
|
||||
}}
|
||||
size="small"
|
||||
@@ -89,7 +89,7 @@ const Title = memo(() => {
|
||||
autoSize={{ minRows: 1 }}
|
||||
onChange={(e) => {
|
||||
const truncated = truncateByWeightedLength(e.target.value, 100);
|
||||
setCurrentTitle(truncated);
|
||||
setTitle(truncated);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
@@ -105,11 +105,11 @@ const Title = memo(() => {
|
||||
resize: 'none',
|
||||
width: '100%',
|
||||
}}
|
||||
value={currentTitle}
|
||||
value={title}
|
||||
variant={'borderless'}
|
||||
/>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
export default Title;
|
||||
export default TitleSection;
|
||||
@@ -3,18 +3,15 @@ import debug from 'debug';
|
||||
import { debounce } from 'es-toolkit/compat';
|
||||
import { type StateCreator } from 'zustand';
|
||||
|
||||
import { documentService } from '@/services/document';
|
||||
import { useDocumentStore } from '@/store/document';
|
||||
import { useFileStore } from '@/store/file';
|
||||
import { pageAgentRuntime } from '@/store/tool/slices/builtin/executors/lobe-page-agent';
|
||||
import { DocumentSourceType, type LobeDocument } from '@/types/document';
|
||||
|
||||
import { type State, initialState } from './initialState';
|
||||
|
||||
const log = debug('page:editor');
|
||||
|
||||
export interface Action {
|
||||
flushSave: () => void;
|
||||
handleContentChange: () => void;
|
||||
flushMetaSave: () => void;
|
||||
handleCopyLink: (t: (key: string) => string, message: any) => void;
|
||||
handleDelete: (
|
||||
t: (key: string) => string,
|
||||
@@ -23,66 +20,48 @@ export interface Action {
|
||||
onDeleteCallback?: () => void,
|
||||
) => Promise<void>;
|
||||
handleTitleSubmit: () => Promise<void>;
|
||||
onEditorInit: () => void;
|
||||
performSave: (options?: { force?: boolean }) => Promise<void>;
|
||||
setCurrentEmoji: (emoji: string | undefined) => void;
|
||||
setCurrentTitle: (title: string) => void;
|
||||
initMeta: (title?: string, emoji?: string) => void;
|
||||
performMetaSave: () => Promise<void>;
|
||||
setEmoji: (emoji: string | undefined) => void;
|
||||
setTitle: (title: string) => void;
|
||||
triggerDebouncedMetaSave: () => void;
|
||||
}
|
||||
|
||||
export type Store = State & Action;
|
||||
|
||||
// Create debounced save function outside of store for reuse
|
||||
const createDebouncedSave = (get: () => Store) =>
|
||||
debounce(
|
||||
async () => {
|
||||
try {
|
||||
await get().performSave();
|
||||
} catch (error) {
|
||||
log('Failed to auto-save:', error);
|
||||
}
|
||||
},
|
||||
EDITOR_DEBOUNCE_TIME,
|
||||
{ leading: false, maxWait: EDITOR_MAX_WAIT, trailing: true },
|
||||
);
|
||||
|
||||
export const store: (initState?: Partial<State>) => StateCreator<Store> =
|
||||
(initState) => (set, get) => {
|
||||
const debouncedSave = createDebouncedSave(get);
|
||||
// Debounced save function for meta (title/emoji)
|
||||
let debouncedMetaSave: ReturnType<typeof debounce> | null = null;
|
||||
|
||||
const getOrCreateDebouncedMetaSave = () => {
|
||||
if (!debouncedMetaSave) {
|
||||
debouncedMetaSave = debounce(
|
||||
async () => {
|
||||
try {
|
||||
await get().performMetaSave();
|
||||
} catch (error) {
|
||||
console.error('[PageEditor] Failed to auto-save meta:', error);
|
||||
}
|
||||
},
|
||||
EDITOR_DEBOUNCE_TIME,
|
||||
{ leading: false, maxWait: EDITOR_MAX_WAIT, trailing: true },
|
||||
);
|
||||
}
|
||||
return debouncedMetaSave;
|
||||
};
|
||||
|
||||
return {
|
||||
...initialState,
|
||||
...initState,
|
||||
|
||||
flushSave: () => {
|
||||
debouncedSave.flush();
|
||||
},
|
||||
|
||||
handleContentChange: () => {
|
||||
const { editor, lastSavedContent } = get();
|
||||
if (!editor) return;
|
||||
|
||||
try {
|
||||
const textContent = (editor.getDocument('text') as unknown as string) || '';
|
||||
const markdownContent = (editor.getDocument('markdown') as unknown as string) || '';
|
||||
const wordCount = textContent.trim().split(/\s+/).filter(Boolean).length;
|
||||
|
||||
// Check if content actually changed
|
||||
const contentChanged = markdownContent !== lastSavedContent;
|
||||
|
||||
set({ isDirty: contentChanged, wordCount });
|
||||
|
||||
// Only trigger auto-save if content actually changed
|
||||
if (contentChanged) {
|
||||
debouncedSave();
|
||||
}
|
||||
} catch (error) {
|
||||
log('Failed to update content:', error);
|
||||
}
|
||||
flushMetaSave: () => {
|
||||
debouncedMetaSave?.flush();
|
||||
},
|
||||
|
||||
handleCopyLink: (t, message) => {
|
||||
const { currentDocId } = get();
|
||||
if (currentDocId) {
|
||||
const { documentId } = get();
|
||||
if (documentId) {
|
||||
const url = `${window.location.origin}${window.location.pathname}`;
|
||||
navigator.clipboard.writeText(url);
|
||||
message.success(t('pageEditor.linkCopied'));
|
||||
@@ -90,8 +69,8 @@ export const store: (initState?: Partial<State>) => StateCreator<Store> =
|
||||
},
|
||||
|
||||
handleDelete: async (t, message, modal, onDeleteCallback) => {
|
||||
const { currentDocId } = get();
|
||||
if (!currentDocId) return;
|
||||
const { documentId } = get();
|
||||
if (!documentId) return;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
modal.confirm({
|
||||
@@ -102,7 +81,7 @@ export const store: (initState?: Partial<State>) => StateCreator<Store> =
|
||||
onOk: async () => {
|
||||
try {
|
||||
const { removeDocument } = useFileStore.getState();
|
||||
await removeDocument(currentDocId);
|
||||
await removeDocument(documentId);
|
||||
message.success(t('pageEditor.deleteSuccess'));
|
||||
onDeleteCallback?.();
|
||||
resolve();
|
||||
@@ -118,163 +97,92 @@ export const store: (initState?: Partial<State>) => StateCreator<Store> =
|
||||
},
|
||||
|
||||
handleTitleSubmit: async () => {
|
||||
const { performSave, editor } = get();
|
||||
await performSave();
|
||||
const { editor, flushMetaSave } = get();
|
||||
|
||||
// Flush pending save and focus editor
|
||||
flushMetaSave();
|
||||
editor?.focus();
|
||||
},
|
||||
|
||||
onEditorInit: () => {
|
||||
// Called when editor is initialized
|
||||
const { editor, setCurrentTitle, currentTitle } = get();
|
||||
|
||||
if (editor) {
|
||||
// Connect the editor instance to the page agent runtime
|
||||
pageAgentRuntime.setEditor(editor);
|
||||
|
||||
// Set up title handlers for the runtime
|
||||
pageAgentRuntime.setTitleHandlers(
|
||||
(title: string) => setCurrentTitle(title),
|
||||
() => currentTitle,
|
||||
);
|
||||
|
||||
log('Connected editor to page agent runtime');
|
||||
}
|
||||
initMeta: (title, emoji) => {
|
||||
set({
|
||||
emoji,
|
||||
isMetaDirty: false,
|
||||
lastSavedEmoji: emoji,
|
||||
lastSavedTitle: title,
|
||||
metaSaveStatus: 'idle',
|
||||
title,
|
||||
});
|
||||
},
|
||||
|
||||
performSave: async (options) => {
|
||||
performMetaSave: async () => {
|
||||
const {
|
||||
editor,
|
||||
currentDocId,
|
||||
currentTitle,
|
||||
currentEmoji,
|
||||
knowledgeBaseId,
|
||||
parentId,
|
||||
onDocumentIdChange,
|
||||
onSave,
|
||||
isDirty,
|
||||
documentId,
|
||||
title,
|
||||
emoji,
|
||||
lastSavedTitle,
|
||||
lastSavedEmoji,
|
||||
isMetaDirty,
|
||||
onTitleChange,
|
||||
onEmojiChange,
|
||||
} = get();
|
||||
|
||||
if (!editor) return;
|
||||
if (!documentId || !isMetaDirty) return;
|
||||
|
||||
// Skip save if no changes (unless force is true)
|
||||
if (
|
||||
!options?.force &&
|
||||
!isDirty &&
|
||||
currentDocId &&
|
||||
!currentDocId.startsWith('temp-document-')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
set({ saveStatus: 'saving' });
|
||||
set({ metaSaveStatus: 'saving' });
|
||||
|
||||
try {
|
||||
const editorElement = editor.getRootElement();
|
||||
const hadFocus = editorElement?.contains(document.activeElement) ?? false;
|
||||
|
||||
const currentEditorData = editor.getDocument('json');
|
||||
const currentContent = (editor.getDocument('markdown') as unknown as string) || '';
|
||||
|
||||
// Don't save if there's no content AND no title/emoji changes
|
||||
if (!currentContent?.trim() && !currentTitle?.trim() && !currentEmoji) {
|
||||
set({ saveStatus: 'idle' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { updateDocumentOptimistically, replaceTempDocumentWithReal } =
|
||||
useFileStore.getState();
|
||||
|
||||
if (currentDocId && !currentDocId.startsWith('temp-document-')) {
|
||||
await updateDocumentOptimistically(currentDocId, {
|
||||
content: currentContent,
|
||||
editorData: structuredClone(currentEditorData),
|
||||
metadata: currentEmoji ? { emoji: currentEmoji } : { emoji: undefined },
|
||||
title: currentTitle,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
} else {
|
||||
const now = Date.now();
|
||||
const timestamp = new Date(now).toLocaleString('en-US', {
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
const finalTitle = currentTitle || `Page - ${timestamp}`;
|
||||
|
||||
const newPage = await documentService.createDocument({
|
||||
content: currentContent,
|
||||
editorData: JSON.stringify(currentEditorData),
|
||||
fileType: 'custom/document',
|
||||
knowledgeBaseId,
|
||||
metadata: {
|
||||
createdAt: now,
|
||||
...(currentEmoji ? { emoji: currentEmoji } : {}),
|
||||
},
|
||||
parentId,
|
||||
title: finalTitle,
|
||||
});
|
||||
|
||||
const realPage: LobeDocument = {
|
||||
content: currentContent,
|
||||
createdAt: new Date(now),
|
||||
editorData: structuredClone(currentEditorData) || null,
|
||||
fileType: 'custom/document' as const,
|
||||
filename: finalTitle,
|
||||
id: newPage.id,
|
||||
metadata: {
|
||||
createdAt: now,
|
||||
...(currentEmoji ? { emoji: currentEmoji } : {}),
|
||||
},
|
||||
source: 'document',
|
||||
sourceType: DocumentSourceType.EDITOR,
|
||||
title: finalTitle,
|
||||
totalCharCount: currentContent.length,
|
||||
totalLineCount: 0,
|
||||
updatedAt: new Date(now),
|
||||
};
|
||||
|
||||
if (currentDocId?.startsWith('temp-document-')) {
|
||||
replaceTempDocumentWithReal(currentDocId, realPage);
|
||||
}
|
||||
|
||||
set({ currentDocId: newPage.id });
|
||||
onDocumentIdChange?.(newPage.id);
|
||||
|
||||
// Refetch resource list to show newly created page
|
||||
const { revalidateResources } = await import('@/store/file/slices/resource/hooks');
|
||||
await revalidateResources();
|
||||
}
|
||||
|
||||
if (hadFocus) {
|
||||
setTimeout(() => {
|
||||
editor?.focus();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// Mark as clean and update save status
|
||||
set({
|
||||
isDirty: false,
|
||||
lastSavedContent: currentContent,
|
||||
lastUpdatedTime: new Date(),
|
||||
saveStatus: 'saved',
|
||||
// Trigger save via DocumentStore with metadata
|
||||
await useDocumentStore.getState().performSave(documentId, {
|
||||
emoji,
|
||||
title,
|
||||
});
|
||||
|
||||
// Notify parent after successful save
|
||||
if (title !== lastSavedTitle) {
|
||||
onTitleChange?.(title || '');
|
||||
}
|
||||
if (emoji !== lastSavedEmoji) {
|
||||
onEmojiChange?.(emoji);
|
||||
}
|
||||
|
||||
set({
|
||||
isMetaDirty: false,
|
||||
lastSavedEmoji: emoji,
|
||||
lastSavedTitle: title,
|
||||
metaSaveStatus: 'saved',
|
||||
});
|
||||
onSave?.();
|
||||
} catch (error) {
|
||||
log('Failed to save:', error);
|
||||
set({ saveStatus: 'idle' });
|
||||
console.error('[PageEditor] Failed to save meta:', error);
|
||||
set({ metaSaveStatus: 'idle' });
|
||||
}
|
||||
},
|
||||
|
||||
setCurrentEmoji: (emoji: string | undefined) => {
|
||||
set({ currentEmoji: emoji, isDirty: true });
|
||||
debouncedSave();
|
||||
setEmoji: (emoji: string | undefined) => {
|
||||
const { lastSavedEmoji, triggerDebouncedMetaSave } = get();
|
||||
|
||||
const isDirty = emoji !== lastSavedEmoji;
|
||||
set({ emoji, isMetaDirty: isDirty });
|
||||
|
||||
if (isDirty) {
|
||||
triggerDebouncedMetaSave();
|
||||
}
|
||||
},
|
||||
|
||||
setCurrentTitle: (title: string) => {
|
||||
set({ currentTitle: title, isDirty: true });
|
||||
debouncedSave();
|
||||
setTitle: (title: string) => {
|
||||
const { lastSavedTitle, triggerDebouncedMetaSave } = get();
|
||||
|
||||
const isDirty = title !== lastSavedTitle;
|
||||
set({ isMetaDirty: isDirty, title });
|
||||
|
||||
if (isDirty) {
|
||||
triggerDebouncedMetaSave();
|
||||
}
|
||||
},
|
||||
|
||||
triggerDebouncedMetaSave: () => {
|
||||
const save = getOrCreateDebouncedMetaSave();
|
||||
save();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,40 +1,35 @@
|
||||
import { type IEditor } from '@lobehub/editor';
|
||||
import { type EditorState } from '@lobehub/editor/react';
|
||||
|
||||
export type MetaSaveStatus = 'idle' | 'saving' | 'saved';
|
||||
|
||||
export interface PublicState {
|
||||
autoSave?: boolean;
|
||||
emoji?: string;
|
||||
knowledgeBaseId?: string;
|
||||
onBack?: () => void;
|
||||
onDelete?: () => void;
|
||||
onDocumentIdChange?: (newId: string) => void;
|
||||
onEmojiChange?: (emoji: string | undefined) => void;
|
||||
onSave?: () => void;
|
||||
pageId?: string;
|
||||
onTitleChange?: (title: string) => void;
|
||||
parentId?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface State extends PublicState {
|
||||
currentDocId: string | undefined;
|
||||
currentEmoji: string | undefined;
|
||||
currentTitle: string;
|
||||
documentId: string | undefined;
|
||||
editor?: IEditor;
|
||||
editorState?: EditorState;
|
||||
isDirty: boolean; // Track if there are unsaved changes
|
||||
isLoadingContent: boolean; // Track if content is being loaded
|
||||
lastSavedContent: string; // Last saved content hash for comparison
|
||||
lastUpdatedTime: Date | null;
|
||||
saveStatus: 'idle' | 'saving' | 'saved';
|
||||
wordCount: number;
|
||||
isMetaDirty?: boolean;
|
||||
lastSavedEmoji?: string;
|
||||
lastSavedTitle?: string;
|
||||
metaSaveStatus?: MetaSaveStatus;
|
||||
}
|
||||
|
||||
export const initialState: State = {
|
||||
autoSave: true,
|
||||
currentDocId: undefined,
|
||||
currentEmoji: undefined,
|
||||
currentTitle: '',
|
||||
isDirty: false,
|
||||
isLoadingContent: false,
|
||||
lastSavedContent: '',
|
||||
lastUpdatedTime: null,
|
||||
saveStatus: 'idle',
|
||||
wordCount: 0,
|
||||
documentId: undefined,
|
||||
emoji: undefined,
|
||||
isMetaDirty: false,
|
||||
metaSaveStatus: 'idle',
|
||||
title: undefined,
|
||||
};
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { type Store } from './action';
|
||||
|
||||
export const selectors = {
|
||||
currentDocId: (s: Store) => s.currentDocId,
|
||||
currentTitle: (s: Store) => s.currentTitle,
|
||||
documentId: (s: Store) => s.documentId,
|
||||
editor: (s: Store) => s.editor,
|
||||
saveStatus: (s: Store) => s.saveStatus,
|
||||
wordCount: (s: Store) => s.wordCount,
|
||||
emoji: (s: Store) => s.emoji,
|
||||
title: (s: Store) => s.title,
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import GuideVideo from '@/components/GuideVideo';
|
||||
import NavHeader from '@/features/NavHeader';
|
||||
import useNotionImport from '@/features/ResourceManager/components/Header/hooks/useNotionImport';
|
||||
import { useFileStore } from '@/store/file';
|
||||
import { usePageStore } from '@/store/page';
|
||||
import { DocumentSourceType } from '@/types/document';
|
||||
import { standardizeIdentifier } from '@/utils/identifier';
|
||||
|
||||
@@ -76,22 +77,25 @@ const PageExplorerPlaceholder = memo<PageExplorerPlaceholderProps>(
|
||||
({ hasPages = false, knowledgeBaseId }) => {
|
||||
const { t } = useTranslation(['file', 'common']);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
// Page-specific operations from pageStore
|
||||
const [
|
||||
createNewPage,
|
||||
createDocument,
|
||||
createOptimisticDocument,
|
||||
replaceTempDocumentWithReal,
|
||||
createOptimisticPage,
|
||||
replaceTempPageWithReal,
|
||||
setSelectedPageId,
|
||||
fetchDocuments,
|
||||
] = useFileStore((s) => [
|
||||
] = usePageStore((s) => [
|
||||
s.createNewPage,
|
||||
s.createDocument,
|
||||
s.createOptimisticDocument,
|
||||
s.replaceTempDocumentWithReal,
|
||||
s.createOptimisticPage,
|
||||
s.replaceTempPageWithReal,
|
||||
s.setSelectedPageId,
|
||||
s.fetchDocuments,
|
||||
]);
|
||||
|
||||
// File operations from FileStore (for uploads and notion import)
|
||||
const [createDocument] = useFileStore((s) => [s.createDocument]);
|
||||
|
||||
const notionImport = useNotionImport({
|
||||
createDocument,
|
||||
currentFolderId: null,
|
||||
@@ -99,7 +103,7 @@ const PageExplorerPlaceholder = memo<PageExplorerPlaceholderProps>(
|
||||
refetchResources: async () => {
|
||||
const { revalidateResources } = await import('@/store/file/slices/resource/hooks');
|
||||
await revalidateResources();
|
||||
await fetchDocuments({ pageOnly: true });
|
||||
await fetchDocuments();
|
||||
},
|
||||
t,
|
||||
});
|
||||
@@ -109,6 +113,10 @@ const PageExplorerPlaceholder = memo<PageExplorerPlaceholderProps>(
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
await notionImport.handleNotionImport(event);
|
||||
// Fetch documents to update the UI immediately
|
||||
// The hook calls refreshFileList which invalidates SWR cache,
|
||||
// but we need to explicitly fetch to update the zustand store
|
||||
await fetchDocuments();
|
||||
};
|
||||
|
||||
const handleCreateDocument = async (content: string, title: string) => {
|
||||
@@ -119,7 +127,7 @@ const PageExplorerPlaceholder = memo<PageExplorerPlaceholderProps>(
|
||||
}
|
||||
|
||||
// For markdown uploads with content, use optimistic pattern similar to createNewPage
|
||||
const tempPageId = createOptimisticDocument(title);
|
||||
const tempPageId = createOptimisticPage(title);
|
||||
// Set selected page to temp ID immediately (with URL update disabled for temp IDs)
|
||||
setSelectedPageId(tempPageId, false);
|
||||
|
||||
@@ -151,13 +159,13 @@ const PageExplorerPlaceholder = memo<PageExplorerPlaceholderProps>(
|
||||
};
|
||||
|
||||
// Replace optimistic with real
|
||||
replaceTempDocumentWithReal(tempPageId, realPage);
|
||||
replaceTempPageWithReal(tempPageId, realPage);
|
||||
// Update selected page ID and URL to the real page
|
||||
setSelectedPageId(newDoc.id);
|
||||
} catch (error) {
|
||||
console.error('Failed to create page:', error);
|
||||
// Remove temp document on error
|
||||
useFileStore.getState().removeTempDocument(tempPageId);
|
||||
usePageStore.getState().removeTempPage(tempPageId);
|
||||
setSelectedPageId(null);
|
||||
throw error;
|
||||
}
|
||||
@@ -179,7 +187,7 @@ const PageExplorerPlaceholder = memo<PageExplorerPlaceholderProps>(
|
||||
const fileName = file.name.replace(/\.(pdf|docx)$/i, '');
|
||||
|
||||
// Create optimistic document but don't select it yet
|
||||
const tempPageId = createOptimisticDocument(fileName);
|
||||
const tempPageId = createOptimisticPage(fileName);
|
||||
|
||||
try {
|
||||
// Upload file to server
|
||||
@@ -219,7 +227,7 @@ const PageExplorerPlaceholder = memo<PageExplorerPlaceholderProps>(
|
||||
};
|
||||
|
||||
// Replace optimistic with real document in the store
|
||||
replaceTempDocumentWithReal(tempPageId, realPage);
|
||||
replaceTempPageWithReal(tempPageId, realPage);
|
||||
|
||||
// Update selected page ID in store (with full ID including prefix)
|
||||
setSelectedPageId(parsedDocument.id, false);
|
||||
@@ -231,7 +239,7 @@ const PageExplorerPlaceholder = memo<PageExplorerPlaceholderProps>(
|
||||
} catch (error) {
|
||||
console.error('Failed to upload and parse file:', error);
|
||||
// Remove temp document on error
|
||||
useFileStore.getState().removeTempDocument(tempPageId);
|
||||
usePageStore.getState().removeTempPage(tempPageId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
import { memo, useCallback } from 'react';
|
||||
|
||||
import { PageEditor } from '@/features/PageEditor';
|
||||
import { useFileStore } from '@/store/file';
|
||||
|
||||
import PageExplorerPlaceholder from './PageExplorerPlaceholder';
|
||||
import { pageSelectors, usePageStore } from '@/store/page';
|
||||
|
||||
interface PageExplorerProps {
|
||||
// Current opened page id
|
||||
pageId?: string;
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -18,67 +15,37 @@ interface PageExplorerProps {
|
||||
* Work together with a sidebar src/app/[variants]/(main)/page/_layout/Body/index.tsx
|
||||
*/
|
||||
const PageExplorer = memo<PageExplorerProps>(({ pageId }) => {
|
||||
const [
|
||||
selectedPageId,
|
||||
setSelectedPageId,
|
||||
pages,
|
||||
fetchDocuments,
|
||||
fetchDocumentDetail,
|
||||
isDocumentListLoading,
|
||||
] = useFileStore((s) => [
|
||||
s.selectedPageId,
|
||||
s.setSelectedPageId,
|
||||
s.getOptimisticDocuments(), // Call inside selector to subscribe to changes
|
||||
s.fetchDocuments,
|
||||
s.fetchDocumentDetail,
|
||||
s.isDocumentListLoading,
|
||||
]);
|
||||
const updatePageOptimistically = usePageStore((s) => s.updatePageOptimistically);
|
||||
|
||||
// Track previous pageId to detect actual URL changes (start undefined to run on first load)
|
||||
const prevPageIdRef = useRef<string | undefined>(undefined);
|
||||
// Get document title and emoji from PageStore
|
||||
const document = usePageStore(pageSelectors.getDocumentById(pageId));
|
||||
const title = document?.title;
|
||||
const emoji = document?.metadata?.emoji as string | undefined;
|
||||
|
||||
// Fetch documents on mount
|
||||
useEffect(() => {
|
||||
fetchDocuments({ pageOnly: true });
|
||||
}, [fetchDocuments]);
|
||||
// Optimistic update handlers for title and emoji
|
||||
const handleTitleChange = useCallback(
|
||||
(newTitle: string) => {
|
||||
updatePageOptimistically(pageId, { title: newTitle });
|
||||
},
|
||||
[pageId, updatePageOptimistically],
|
||||
);
|
||||
|
||||
// Check if pageId is valid (not undefined or "docs_undefined")
|
||||
const isValidPageId = pageId && !pageId.includes('undefined');
|
||||
const handleEmojiChange = useCallback(
|
||||
(newEmoji: string | undefined) => {
|
||||
updatePageOptimistically(pageId, { emoji: newEmoji });
|
||||
},
|
||||
[pageId, updatePageOptimistically],
|
||||
);
|
||||
|
||||
// When pageId prop changes (from URL navigation), update selected page and fetch details
|
||||
// Use ref to only sync when pageId actually changes, avoiding conflicts with sidebar selection
|
||||
useEffect(() => {
|
||||
if (isValidPageId && pageId !== prevPageIdRef.current) {
|
||||
prevPageIdRef.current = pageId;
|
||||
setSelectedPageId(pageId, false);
|
||||
// Fetch the document detail to ensure it's loaded in the local map
|
||||
fetchDocumentDetail(pageId);
|
||||
} else if (!isValidPageId && prevPageIdRef.current !== undefined) {
|
||||
// When navigating to /page without a doc id, clear the selection
|
||||
prevPageIdRef.current = undefined;
|
||||
setSelectedPageId(null, false);
|
||||
}
|
||||
}, [pageId, isValidPageId, setSelectedPageId, fetchDocumentDetail]);
|
||||
|
||||
// Prioritize selectedPageId from store for immediate updates when clicking from sidebar
|
||||
// Only show placeholder when both selectedPageId is null AND pageId is invalid
|
||||
const currentPageId = selectedPageId || (isValidPageId ? pageId : undefined);
|
||||
|
||||
// Check if the current page exists in the pages list
|
||||
const currentPageExists = currentPageId && pages.some((page) => page.id === currentPageId);
|
||||
|
||||
// When we have a pageId from URL but document list is not yet loaded (empty list or still loading),
|
||||
// proceed to show the editor instead of placeholder. The editor handles its own loading state.
|
||||
// This prevents the placeholder flash on page refresh.
|
||||
const isWaitingForDocuments = pages.length === 0 || isDocumentListLoading;
|
||||
const shouldShowEditor =
|
||||
currentPageId && (currentPageExists || (isValidPageId && isWaitingForDocuments));
|
||||
|
||||
if (!shouldShowEditor) {
|
||||
return <PageExplorerPlaceholder hasPages={pages?.length > 0} />;
|
||||
}
|
||||
|
||||
return <PageEditor pageId={currentPageId} />;
|
||||
return (
|
||||
<PageEditor
|
||||
emoji={emoji}
|
||||
onEmojiChange={handleEmojiChange}
|
||||
onTitleChange={handleTitleChange}
|
||||
pageId={pageId}
|
||||
title={title}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default PageExplorer;
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { type PortalImpl } from '../type';
|
||||
import Body from './Body';
|
||||
import Title from './Title';
|
||||
import { useEnable } from './useEnable';
|
||||
|
||||
export const Artifacts: PortalImpl = {
|
||||
Body,
|
||||
Title,
|
||||
useEnable,
|
||||
};
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { chatPortalSelectors } from '@/store/chat/selectors';
|
||||
|
||||
export const useEnable = () => useChatStore(chatPortalSelectors.showArtifactUI);
|
||||
@@ -2,15 +2,16 @@
|
||||
|
||||
import { memo } from 'react';
|
||||
|
||||
import AutoSaveHintBase from '@/components/Editor/AutoSaveHint';
|
||||
|
||||
import { useDocumentEditorStore } from './store';
|
||||
import { AutoSaveHint as SharedAutoSaveHint } from '@/features/EditorCanvas';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { chatPortalSelectors } from '@/store/chat/selectors';
|
||||
|
||||
const AutoSaveHint = memo(() => {
|
||||
const saveStatus = useDocumentEditorStore((s) => s.saveStatus);
|
||||
const lastUpdatedTime = useDocumentEditorStore((s) => s.lastUpdatedTime);
|
||||
const documentId = useChatStore(chatPortalSelectors.portalDocumentId);
|
||||
|
||||
return <AutoSaveHintBase lastUpdatedTime={lastUpdatedTime} saveStatus={saveStatus} />;
|
||||
if (!documentId) return null;
|
||||
|
||||
return <SharedAutoSaveHint documentId={documentId} />;
|
||||
});
|
||||
|
||||
export default AutoSaveHint;
|
||||
|
||||
@@ -5,14 +5,13 @@ import { createStaticStyles } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
|
||||
import EditorCanvas from './EditorCanvas';
|
||||
import Title from './Title';
|
||||
import TodoList from './TodoList';
|
||||
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
content: css`
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
padding-inline: 12px;
|
||||
padding-inline: 16px;
|
||||
`,
|
||||
todoContainer: css`
|
||||
flex-shrink: 0;
|
||||
@@ -25,7 +24,6 @@ const DocumentBody = memo(() => {
|
||||
return (
|
||||
<Flexbox flex={1} height={'100%'} style={{ overflow: 'hidden' }}>
|
||||
<div className={styles.content}>
|
||||
<Title />
|
||||
<EditorCanvas />
|
||||
</div>
|
||||
<div className={styles.todoContainer}>
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEditor } from '@lobehub/editor/react';
|
||||
import { type ReactNode, memo } from 'react';
|
||||
|
||||
import StoreUpdater from './StoreUpdater';
|
||||
import { DocumentEditorProvider as Provider, createStore } from './store';
|
||||
|
||||
interface DocumentEditorProviderProps {
|
||||
children: ReactNode;
|
||||
documentId: string | undefined;
|
||||
topicId: string | undefined;
|
||||
}
|
||||
|
||||
export const DocumentEditorProvider = memo<DocumentEditorProviderProps>(
|
||||
({ children, documentId, topicId }) => {
|
||||
const editor = useEditor();
|
||||
|
||||
return (
|
||||
<Provider
|
||||
createStore={() =>
|
||||
createStore({
|
||||
documentId,
|
||||
editor,
|
||||
topicId,
|
||||
})
|
||||
}
|
||||
>
|
||||
<StoreUpdater documentId={documentId} topicId={topicId} />
|
||||
{children}
|
||||
</Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,61 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
ReactCodePlugin,
|
||||
ReactCodemirrorPlugin,
|
||||
ReactHRPlugin,
|
||||
ReactImagePlugin,
|
||||
ReactLinkPlugin,
|
||||
ReactListPlugin,
|
||||
ReactMathPlugin,
|
||||
ReactTablePlugin,
|
||||
} from '@lobehub/editor';
|
||||
import { Editor } from '@lobehub/editor/react';
|
||||
import { useEditor } from '@lobehub/editor/react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useDocumentEditorStore } from './store';
|
||||
import { EditorCanvas as SharedEditorCanvas } from '@/features/EditorCanvas';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { chatPortalSelectors } from '@/store/chat/selectors';
|
||||
|
||||
const EditorCanvas = memo(() => {
|
||||
const { t } = useTranslation('file');
|
||||
const editor = useEditor();
|
||||
|
||||
const editor = useDocumentEditorStore((s) => s.editor);
|
||||
const handleContentChange = useDocumentEditorStore((s) => s.handleContentChange);
|
||||
const documentId = useChatStore(chatPortalSelectors.portalDocumentId);
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<Editor
|
||||
content={''}
|
||||
editor={editor}
|
||||
lineEmptyPlaceholder={t('pageEditor.editorPlaceholder')}
|
||||
onTextChange={handleContentChange}
|
||||
placeholder={t('pageEditor.editorPlaceholder')}
|
||||
plugins={[
|
||||
ReactListPlugin,
|
||||
ReactCodePlugin,
|
||||
ReactCodemirrorPlugin,
|
||||
ReactHRPlugin,
|
||||
ReactLinkPlugin,
|
||||
ReactTablePlugin,
|
||||
ReactMathPlugin,
|
||||
Editor.withProps(ReactImagePlugin, {
|
||||
defaultBlockImage: true,
|
||||
}),
|
||||
]}
|
||||
style={{
|
||||
paddingBottom: 64,
|
||||
}}
|
||||
type={'text'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return <SharedEditorCanvas documentId={documentId} editor={editor} sourceType="notebook" />;
|
||||
});
|
||||
|
||||
export default EditorCanvas;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { ActionIcon, Button, Flexbox, Text } from '@lobehub/ui';
|
||||
import { Button, Flexbox, Text } from '@lobehub/ui';
|
||||
import { cx } from 'antd-style';
|
||||
import { ArrowLeft, ExternalLink } from 'lucide-react';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -22,13 +22,17 @@ const Header = () => {
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [topicId, documentId, closeDocument] = useChatStore((s) => [
|
||||
const [topicId, documentId] = useChatStore((s) => [
|
||||
s.activeTopicId,
|
||||
chatPortalSelectors.portalDocumentId(s),
|
||||
s.closeDocument,
|
||||
]);
|
||||
|
||||
const document = useNotebookStore(notebookSelectors.getDocumentById(topicId, documentId));
|
||||
const [useFetchDocuments, title, fileType] = useNotebookStore((s) => [
|
||||
s.useFetchDocuments,
|
||||
notebookSelectors.getDocumentById(topicId, documentId)(s)?.title,
|
||||
notebookSelectors.getDocumentById(topicId, documentId)(s)?.fileType,
|
||||
]);
|
||||
useFetchDocuments(topicId);
|
||||
|
||||
const handleOpenInPageEditor = async () => {
|
||||
if (!documentId) return;
|
||||
@@ -49,19 +53,18 @@ const Header = () => {
|
||||
}
|
||||
};
|
||||
|
||||
if (!document) return null;
|
||||
if (!title) return null;
|
||||
|
||||
return (
|
||||
<Flexbox align={'center'} flex={1} gap={12} horizontal justify={'space-between'} width={'100%'}>
|
||||
<Flexbox align={'center'} gap={4} horizontal>
|
||||
<ActionIcon icon={ArrowLeft} onClick={closeDocument} size={'small'} />
|
||||
<Flexbox flex={1}>
|
||||
<Text className={cx(oneLineEllipsis)} type={'secondary'}>
|
||||
{document.title}
|
||||
{title}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
<Flexbox align={'center'} gap={8} horizontal>
|
||||
<AutoSaveHint />
|
||||
{document.fileType !== 'agent/plan' && (
|
||||
{fileType !== 'agent/plan' && (
|
||||
<Button
|
||||
icon={<ExternalLink size={14} />}
|
||||
loading={loading}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import debug from 'debug';
|
||||
import { memo, useEffect, useRef, useState } from 'react';
|
||||
import { createStoreUpdater } from 'zustand-utils';
|
||||
|
||||
import { useNotebookStore } from '@/store/notebook';
|
||||
import { notebookSelectors } from '@/store/notebook/selectors';
|
||||
|
||||
import { useDocumentEditorStore, useDocumentEditorStoreApi } from './store';
|
||||
|
||||
const log = debug('portal:document-store-updater');
|
||||
|
||||
interface StoreUpdaterProps {
|
||||
documentId: string | undefined;
|
||||
topicId: string | undefined;
|
||||
}
|
||||
|
||||
const StoreUpdater = memo<StoreUpdaterProps>(({ documentId, topicId }) => {
|
||||
const storeApi = useDocumentEditorStoreApi();
|
||||
const useStoreUpdater = createStoreUpdater(storeApi);
|
||||
|
||||
const editor = useDocumentEditorStore((s) => s.editor);
|
||||
|
||||
const document = useNotebookStore(notebookSelectors.getDocumentById(topicId, documentId));
|
||||
|
||||
const [editorInit, setEditorInit] = useState(false);
|
||||
const [contentInit, setContentInit] = useState(false);
|
||||
const lastLoadedDocIdRef = useRef<string | undefined>(undefined);
|
||||
|
||||
// Update store with props
|
||||
useStoreUpdater('documentId', documentId);
|
||||
useStoreUpdater('topicId', topicId);
|
||||
|
||||
// Load content into editor when document changes
|
||||
useEffect(() => {
|
||||
if (!editorInit || !editor || !document) return;
|
||||
|
||||
// Skip if already initialized for this document
|
||||
if (contentInit && lastLoadedDocIdRef.current === documentId) return;
|
||||
|
||||
// Reset content init when document changes
|
||||
if (lastLoadedDocIdRef.current !== documentId) {
|
||||
setContentInit(false);
|
||||
}
|
||||
|
||||
queueMicrotask(() => {
|
||||
try {
|
||||
log('Loading content for document:', documentId);
|
||||
|
||||
const content = document.content || '';
|
||||
|
||||
// Set state before setDocument to ensure lastSavedContent is correct
|
||||
// when handleContentChange is triggered
|
||||
storeApi.setState({
|
||||
currentTitle: document.title || '',
|
||||
lastSavedContent: content,
|
||||
});
|
||||
|
||||
editor.setDocument('markdown', content || ' ');
|
||||
|
||||
lastLoadedDocIdRef.current = documentId;
|
||||
setContentInit(true);
|
||||
} catch (error) {
|
||||
log('Failed to load editor content:', error);
|
||||
}
|
||||
});
|
||||
}, [editorInit, editor, document, documentId, contentInit, storeApi]);
|
||||
|
||||
// Track editor initialization
|
||||
useEffect(() => {
|
||||
if (editor && !editorInit) {
|
||||
setEditorInit(true);
|
||||
}
|
||||
}, [editor, editorInit]);
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
export default StoreUpdater;
|
||||
@@ -1,54 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Flexbox, TextArea } from '@lobehub/ui';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useDocumentEditorStore } from './store';
|
||||
|
||||
const Title = memo(() => {
|
||||
const { t } = useTranslation('file');
|
||||
|
||||
const currentTitle = useDocumentEditorStore((s) => s.currentTitle);
|
||||
const setCurrentTitle = useDocumentEditorStore((s) => s.setCurrentTitle);
|
||||
const handleTitleSubmit = useDocumentEditorStore((s) => s.handleTitleSubmit);
|
||||
|
||||
return (
|
||||
<Flexbox
|
||||
gap={16}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
paddingBlock={16}
|
||||
style={{
|
||||
cursor: 'default',
|
||||
}}
|
||||
>
|
||||
<TextArea
|
||||
autoSize={{ minRows: 1 }}
|
||||
onChange={(e) => {
|
||||
setCurrentTitle(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleTitleSubmit();
|
||||
}
|
||||
}}
|
||||
placeholder={t('pageEditor.titlePlaceholder')}
|
||||
style={{
|
||||
fontSize: 24,
|
||||
fontWeight: 600,
|
||||
padding: 0,
|
||||
resize: 'none',
|
||||
width: '100%',
|
||||
}}
|
||||
value={currentTitle}
|
||||
variant={'borderless'}
|
||||
/>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
export default Title;
|
||||
@@ -6,11 +6,11 @@ import { ChevronDown, ChevronUp, ListTodo } from 'lucide-react';
|
||||
import { memo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { chatPortalSelectors } from '@/store/chat/selectors';
|
||||
import { useNotebookStore } from '@/store/notebook';
|
||||
import { notebookSelectors } from '@/store/notebook/selectors';
|
||||
|
||||
import { useDocumentEditorStore } from './store';
|
||||
|
||||
interface TodoItem {
|
||||
completed: boolean;
|
||||
text: string;
|
||||
@@ -106,8 +106,10 @@ const TodoList = memo(() => {
|
||||
const { t } = useTranslation('portal');
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const documentId = useDocumentEditorStore((s) => s.documentId);
|
||||
const topicId = useDocumentEditorStore((s) => s.topicId);
|
||||
const [topicId, documentId] = useChatStore((s) => [
|
||||
s.activeTopicId,
|
||||
chatPortalSelectors.portalDocumentId(s),
|
||||
]);
|
||||
|
||||
const document = useNotebookStore(notebookSelectors.getDocumentById(topicId, documentId));
|
||||
|
||||
|
||||
@@ -1,25 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { EditorProvider } from '@lobehub/editor/react';
|
||||
import { type PropsWithChildren, memo } from 'react';
|
||||
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { chatPortalSelectors } from '@/store/chat/selectors';
|
||||
|
||||
import { DocumentEditorProvider } from './DocumentEditorProvider';
|
||||
|
||||
const Wrapper = memo<PropsWithChildren>(({ children }) => {
|
||||
const [topicId, documentId] = useChatStore((s) => [
|
||||
s.activeTopicId,
|
||||
chatPortalSelectors.portalDocumentId(s),
|
||||
]);
|
||||
const documentId = useChatStore(chatPortalSelectors.portalDocumentId);
|
||||
|
||||
if (!documentId) return null;
|
||||
|
||||
return (
|
||||
<DocumentEditorProvider documentId={documentId} topicId={topicId}>
|
||||
{children}
|
||||
</DocumentEditorProvider>
|
||||
);
|
||||
return <EditorProvider>{children}</EditorProvider>;
|
||||
});
|
||||
|
||||
export default Wrapper;
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { type PortalImpl } from '../type';
|
||||
import Body from './Body';
|
||||
import Header from './Header';
|
||||
import { useEnable } from './useEnable';
|
||||
import Wrapper from './Wrapper';
|
||||
|
||||
export const Document: PortalImpl = {
|
||||
Body,
|
||||
Title: Header,
|
||||
Wrapper,
|
||||
useEnable,
|
||||
};
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { EDITOR_DEBOUNCE_TIME, EDITOR_MAX_WAIT } from '@lobechat/const';
|
||||
import debug from 'debug';
|
||||
import { debounce } from 'es-toolkit/compat';
|
||||
import { type StateCreator } from 'zustand';
|
||||
|
||||
import { useNotebookStore } from '@/store/notebook';
|
||||
|
||||
import { type DocumentEditorState, initialDocumentEditorState } from './initialState';
|
||||
|
||||
const log = debug('portal:document-editor');
|
||||
|
||||
export interface DocumentEditorAction {
|
||||
flushSave: () => void;
|
||||
handleContentChange: () => void;
|
||||
handleTitleSubmit: () => Promise<void>;
|
||||
performSave: () => Promise<void>;
|
||||
setCurrentTitle: (title: string) => void;
|
||||
}
|
||||
|
||||
export type DocumentEditorStore = DocumentEditorState & DocumentEditorAction;
|
||||
|
||||
const createDebouncedSave = (get: () => DocumentEditorStore) =>
|
||||
debounce(
|
||||
async () => {
|
||||
try {
|
||||
await get().performSave();
|
||||
} catch (error) {
|
||||
log('Failed to auto-save:', error);
|
||||
}
|
||||
},
|
||||
EDITOR_DEBOUNCE_TIME,
|
||||
{ leading: false, maxWait: EDITOR_MAX_WAIT, trailing: true },
|
||||
);
|
||||
|
||||
export const createDocumentEditorStore: (
|
||||
initState?: Partial<DocumentEditorState>,
|
||||
) => StateCreator<DocumentEditorStore> = (initState) => (set, get) => {
|
||||
const debouncedSave = createDebouncedSave(get);
|
||||
|
||||
return {
|
||||
...initialDocumentEditorState,
|
||||
...initState,
|
||||
|
||||
flushSave: () => {
|
||||
debouncedSave.flush();
|
||||
},
|
||||
|
||||
handleContentChange: () => {
|
||||
const { editor, lastSavedContent } = get();
|
||||
if (!editor) return;
|
||||
|
||||
try {
|
||||
const markdownContent = (editor.getDocument('markdown') as unknown as string) || '';
|
||||
const contentChanged = markdownContent !== lastSavedContent;
|
||||
|
||||
set({ isDirty: contentChanged });
|
||||
|
||||
if (contentChanged) {
|
||||
debouncedSave();
|
||||
}
|
||||
} catch (error) {
|
||||
log('Failed to update content:', error);
|
||||
}
|
||||
},
|
||||
|
||||
handleTitleSubmit: async () => {
|
||||
const { performSave, editor } = get();
|
||||
await performSave();
|
||||
editor?.focus();
|
||||
},
|
||||
|
||||
performSave: async () => {
|
||||
const { editor, documentId, topicId, isDirty, currentTitle } = get();
|
||||
|
||||
if (!editor || !documentId || !topicId) return;
|
||||
|
||||
if (!isDirty) return;
|
||||
|
||||
set({ saveStatus: 'saving' });
|
||||
|
||||
try {
|
||||
const currentContent = (editor.getDocument('markdown') as unknown as string) || '';
|
||||
|
||||
await useNotebookStore.getState().updateDocument(
|
||||
{
|
||||
content: currentContent,
|
||||
id: documentId,
|
||||
title: currentTitle || undefined,
|
||||
},
|
||||
topicId,
|
||||
);
|
||||
|
||||
set({
|
||||
isDirty: false,
|
||||
lastSavedContent: currentContent,
|
||||
lastUpdatedTime: new Date(),
|
||||
saveStatus: 'saved',
|
||||
});
|
||||
|
||||
log('Document saved successfully:', documentId);
|
||||
} catch (error) {
|
||||
log('Failed to save document:', error);
|
||||
set({ saveStatus: 'idle' });
|
||||
}
|
||||
},
|
||||
|
||||
setCurrentTitle: (title: string) => {
|
||||
set({ currentTitle: title, isDirty: true });
|
||||
debouncedSave();
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { type StoreApiWithSelector } from '@lobechat/types';
|
||||
import { createContext } from 'zustand-utils';
|
||||
import { subscribeWithSelector } from 'zustand/middleware';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
import { createWithEqualityFn } from 'zustand/traditional';
|
||||
|
||||
import { type DocumentEditorStore, createDocumentEditorStore } from './action';
|
||||
import { type DocumentEditorState } from './initialState';
|
||||
|
||||
export type { DocumentEditorState } from './initialState';
|
||||
|
||||
export const createStore = (initState?: Partial<DocumentEditorState>) =>
|
||||
createWithEqualityFn(subscribeWithSelector(createDocumentEditorStore(initState)), shallow);
|
||||
|
||||
export const {
|
||||
useStore: useDocumentEditorStore,
|
||||
useStoreApi: useDocumentEditorStoreApi,
|
||||
Provider: DocumentEditorProvider,
|
||||
} = createContext<StoreApiWithSelector<DocumentEditorStore>>();
|
||||
@@ -1,24 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { type IEditor } from '@lobehub/editor';
|
||||
|
||||
export interface DocumentEditorState {
|
||||
currentTitle: string;
|
||||
documentId: string | undefined;
|
||||
editor?: IEditor;
|
||||
isDirty: boolean;
|
||||
lastSavedContent: string;
|
||||
lastUpdatedTime: Date | null;
|
||||
saveStatus: 'idle' | 'saving' | 'saved';
|
||||
topicId: string | undefined;
|
||||
}
|
||||
|
||||
export const initialDocumentEditorState: DocumentEditorState = {
|
||||
currentTitle: '',
|
||||
documentId: undefined,
|
||||
isDirty: false,
|
||||
lastSavedContent: '',
|
||||
lastUpdatedTime: null,
|
||||
saveStatus: 'idle',
|
||||
topicId: undefined,
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { chatPortalSelectors } from '@/store/chat/selectors';
|
||||
|
||||
export const useEnable = () => {
|
||||
return useChatStore(chatPortalSelectors.showDocument);
|
||||
};
|
||||
@@ -1,10 +1,8 @@
|
||||
import { type PortalImpl } from '../type';
|
||||
import Body from './Body';
|
||||
import Title from './Title';
|
||||
import { useEnable } from './useEnable';
|
||||
|
||||
export const FilePreview: PortalImpl = {
|
||||
Body,
|
||||
Title,
|
||||
useEnable,
|
||||
};
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { chatPortalSelectors } from '@/store/chat/selectors';
|
||||
|
||||
export const useEnable = () => {
|
||||
return useChatStore(chatPortalSelectors.showFilePreview);
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
import { useAgentGroupStore } from '@/store/agentGroup';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
|
||||
export const useEnable = () => useAgentGroupStore((s) => !!s.activeThreadAgentId);
|
||||
|
||||
export const onClose = () => {
|
||||
useAgentGroupStore.setState({ activeThreadAgentId: '' });
|
||||
useChatStore.getState().togglePortal(false);
|
||||
};
|
||||
@@ -2,12 +2,9 @@ import { type PortalImpl } from '../type';
|
||||
import Body from './Body';
|
||||
import Header from './Header';
|
||||
import Title from './Title';
|
||||
import { onClose, useEnable } from './hook';
|
||||
|
||||
export const GroupThread: PortalImpl = {
|
||||
Body,
|
||||
Header,
|
||||
Title,
|
||||
onClose,
|
||||
useEnable,
|
||||
};
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { type PortalImpl } from '../type';
|
||||
import Body from './Body';
|
||||
import Title from './Title';
|
||||
import { useEnable } from './useEnable';
|
||||
|
||||
export const MessageDetail: PortalImpl = {
|
||||
Body,
|
||||
Title,
|
||||
useEnable,
|
||||
};
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { chatPortalSelectors } from '@/store/chat/selectors';
|
||||
|
||||
export const useEnable = () => useChatStore(chatPortalSelectors.showMessageDetail);
|
||||
@@ -1,10 +1,8 @@
|
||||
import { type PortalImpl } from '../type';
|
||||
import Body from './Body';
|
||||
import Title from './Title';
|
||||
import { useEnable } from './useEnable';
|
||||
|
||||
export const Notebook: PortalImpl = {
|
||||
Body,
|
||||
Title,
|
||||
useEnable,
|
||||
};
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { chatPortalSelectors } from '@/store/chat/selectors';
|
||||
|
||||
export const useEnable = () => {
|
||||
return useChatStore(chatPortalSelectors.showNotebook);
|
||||
};
|
||||
@@ -1,10 +1,8 @@
|
||||
import { type PortalImpl } from '../type';
|
||||
import Body from './Body';
|
||||
import Title from './Title';
|
||||
import { useEnable } from './useEnable';
|
||||
|
||||
export const Plugins: PortalImpl = {
|
||||
Body,
|
||||
Title,
|
||||
useEnable,
|
||||
};
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { chatPortalSelectors } from '@/store/chat/selectors';
|
||||
|
||||
export const useEnable = () => {
|
||||
return useChatStore(chatPortalSelectors.showPluginUI);
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { portalThreadSelectors } from '@/store/chat/selectors';
|
||||
|
||||
export const useEnable = () => useChatStore(portalThreadSelectors.showThread);
|
||||
|
||||
export const onClose = () => {
|
||||
useChatStore.setState({ portalThreadId: undefined });
|
||||
};
|
||||
@@ -1,12 +1,9 @@
|
||||
import { type PortalImpl } from '../type';
|
||||
import Chat from './Chat';
|
||||
import Header from './Header';
|
||||
import { onClose, useEnable } from './hook';
|
||||
|
||||
export const Thread: PortalImpl = {
|
||||
Body: Chat,
|
||||
Header,
|
||||
Title: () => null,
|
||||
onClose,
|
||||
useEnable,
|
||||
};
|
||||
|
||||
@@ -1,24 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { DESKTOP_HEADER_ICON_SIZE } from '@lobechat/const';
|
||||
import { ActionIcon } from '@lobehub/ui';
|
||||
import { PanelRightCloseIcon } from 'lucide-react';
|
||||
import { ActionIcon, Flexbox } from '@lobehub/ui';
|
||||
import { ArrowLeft, PanelRightCloseIcon } from 'lucide-react';
|
||||
import { type ReactNode, memo } from 'react';
|
||||
|
||||
import NavHeader from '@/features/NavHeader';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { chatPortalSelectors } from '@/store/chat/selectors';
|
||||
|
||||
const Header = memo<{ title: ReactNode }>(({ title }) => {
|
||||
const [toggleInspector] = useChatStore((s) => [s.togglePortal]);
|
||||
const [canGoBack, goBack, togglePortal] = useChatStore((s) => [
|
||||
chatPortalSelectors.canGoBack(s),
|
||||
s.goBack,
|
||||
s.togglePortal,
|
||||
]);
|
||||
|
||||
return (
|
||||
<NavHeader
|
||||
left={title}
|
||||
left={
|
||||
<Flexbox align="center" gap={4} horizontal>
|
||||
{canGoBack && (
|
||||
<ActionIcon icon={ArrowLeft} onClick={goBack} size={DESKTOP_HEADER_ICON_SIZE} />
|
||||
)}
|
||||
{title}
|
||||
</Flexbox>
|
||||
}
|
||||
right={
|
||||
<ActionIcon
|
||||
icon={PanelRightCloseIcon}
|
||||
onClick={() => {
|
||||
toggleInspector(false);
|
||||
togglePortal(false);
|
||||
}}
|
||||
size={DESKTOP_HEADER_ICON_SIZE}
|
||||
/>
|
||||
@@ -27,7 +39,7 @@ const Header = memo<{ title: ReactNode }>(({ title }) => {
|
||||
style={{ paddingBlock: 8, paddingInline: 8 }}
|
||||
styles={{
|
||||
left: {
|
||||
marginLeft: 6,
|
||||
marginLeft: canGoBack ? 0 : 6,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
import React, { Fragment, memo } from 'react';
|
||||
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { chatPortalSelectors } from '@/store/chat/selectors';
|
||||
import { PortalViewType } from '@/store/chat/slices/portal/initialState';
|
||||
|
||||
import { Artifacts } from './Artifacts';
|
||||
import { Document } from './Document';
|
||||
import { FilePreview } from './FilePreview';
|
||||
@@ -14,72 +18,27 @@ import { Thread } from './Thread';
|
||||
import Header from './components/Header';
|
||||
import { type PortalImpl } from './type';
|
||||
|
||||
// Keep GroupThread before Thread so group DM threads take precedence when enabled
|
||||
// Document should be before Notebook so detail view takes precedence
|
||||
const items: PortalImpl[] = [
|
||||
GroupThread,
|
||||
Thread,
|
||||
MessageDetail,
|
||||
Artifacts,
|
||||
Plugins,
|
||||
FilePreview,
|
||||
Document,
|
||||
Notebook,
|
||||
];
|
||||
// View type to component mapping
|
||||
const VIEW_COMPONENTS: Record<PortalViewType, PortalImpl> = {
|
||||
[PortalViewType.Home]: {
|
||||
Body: HomeBody,
|
||||
Title: HomeTitle,
|
||||
},
|
||||
[PortalViewType.Artifact]: Artifacts,
|
||||
[PortalViewType.Document]: Document,
|
||||
[PortalViewType.Notebook]: Notebook,
|
||||
[PortalViewType.FilePreview]: FilePreview,
|
||||
[PortalViewType.MessageDetail]: MessageDetail,
|
||||
[PortalViewType.ToolUI]: Plugins,
|
||||
[PortalViewType.Thread]: Thread,
|
||||
[PortalViewType.GroupThread]: GroupThread,
|
||||
};
|
||||
|
||||
export const PortalTitle = memo(() => {
|
||||
const enabledList: boolean[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const enabled = item.useEnable();
|
||||
enabledList.push(enabled);
|
||||
}
|
||||
|
||||
for (const [i, element] of enabledList.entries()) {
|
||||
const Title = items[i].Title;
|
||||
if (element) {
|
||||
return <Title />;
|
||||
}
|
||||
}
|
||||
|
||||
return <HomeTitle />;
|
||||
});
|
||||
|
||||
export const PortalHeader = memo(() => {
|
||||
const enabledList: boolean[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const enabled = item.useEnable();
|
||||
enabledList.push(enabled);
|
||||
}
|
||||
|
||||
for (const [i, element] of enabledList.entries()) {
|
||||
const Header = items[i].Header;
|
||||
if (element && Header) {
|
||||
return <Header />;
|
||||
}
|
||||
}
|
||||
|
||||
return <Header title={<PortalTitle />} />;
|
||||
});
|
||||
|
||||
const PortalBody = memo(() => {
|
||||
const enabledList: boolean[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const enabled = item.useEnable();
|
||||
enabledList.push(enabled);
|
||||
}
|
||||
|
||||
for (const [i, element] of enabledList.entries()) {
|
||||
const Body = items[i].Body;
|
||||
if (element) {
|
||||
return <Body />;
|
||||
}
|
||||
}
|
||||
|
||||
return <HomeBody />;
|
||||
});
|
||||
// Default Home component
|
||||
const HomeImpl: PortalImpl = {
|
||||
Body: HomeBody,
|
||||
Title: HomeTitle,
|
||||
};
|
||||
|
||||
interface PortalContentProps {
|
||||
renderBody?: (body: React.ReactNode) => React.ReactNode;
|
||||
@@ -87,37 +46,18 @@ interface PortalContentProps {
|
||||
|
||||
/**
|
||||
* Portal content with Wrapper support
|
||||
* When an enabled item has a Wrapper, it wraps both Header and Body
|
||||
* Uses the view stack to determine which component to render
|
||||
*/
|
||||
const PortalContent = memo<PortalContentProps>(({ renderBody }) => {
|
||||
const enabledList: boolean[] = [];
|
||||
export const PortalContent = memo<PortalContentProps>(({ renderBody }) => {
|
||||
const viewType = useChatStore(chatPortalSelectors.currentViewType);
|
||||
const ViewImpl = viewType ? VIEW_COMPONENTS[viewType] : HomeImpl;
|
||||
|
||||
for (const item of items) {
|
||||
const enabled = item.useEnable();
|
||||
enabledList.push(enabled);
|
||||
}
|
||||
|
||||
// Find the first enabled item
|
||||
let enabledIndex = -1;
|
||||
for (const [i, element] of enabledList.entries()) {
|
||||
if (element) {
|
||||
enabledIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Get components for the enabled item
|
||||
const enabledItem = enabledIndex >= 0 ? items[enabledIndex] : null;
|
||||
const Wrapper = enabledItem?.Wrapper || Fragment;
|
||||
const CustomHeader = enabledItem?.Header;
|
||||
const Body = enabledItem?.Body || HomeBody;
|
||||
|
||||
const headerContent = CustomHeader ? (
|
||||
<CustomHeader />
|
||||
) : (
|
||||
<Header title={enabledItem?.Title ? <enabledItem.Title /> : <HomeTitle />} />
|
||||
);
|
||||
const Wrapper = ViewImpl?.Wrapper || Fragment;
|
||||
const CustomHeader = ViewImpl?.Header;
|
||||
const Body = ViewImpl?.Body || HomeBody;
|
||||
const Title = ViewImpl?.Title || HomeTitle;
|
||||
|
||||
const headerContent = CustomHeader ? <CustomHeader /> : <Header title={<Title />} />;
|
||||
const bodyContent = <Body />;
|
||||
|
||||
return (
|
||||
@@ -127,6 +67,3 @@ const PortalContent = memo<PortalContentProps>(({ renderBody }) => {
|
||||
</Wrapper>
|
||||
);
|
||||
});
|
||||
|
||||
export { PortalContent };
|
||||
export default PortalBody;
|
||||
|
||||
@@ -5,6 +5,4 @@ export interface PortalImpl {
|
||||
Header?: FC;
|
||||
Title: FC;
|
||||
Wrapper?: FC<PropsWithChildren>;
|
||||
onClose?: () => void;
|
||||
useEnable: () => boolean;
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ export default {
|
||||
'builtins.lobe-local-system.inspector.rename.result':
|
||||
'<old>{{oldName}}</old> → <new>{{newName}}</new>',
|
||||
'builtins.lobe-local-system.title': 'Local System',
|
||||
'builtins.lobe-notebook.actions.collapse': 'Collapse',
|
||||
'builtins.lobe-notebook.actions.copy': 'Copy',
|
||||
'builtins.lobe-notebook.actions.creating': 'Creating document...',
|
||||
'builtins.lobe-notebook.actions.edit': 'Edit',
|
||||
|
||||
@@ -3,19 +3,219 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useChatStore } from '@/store/chat';
|
||||
|
||||
import { PortalViewType } from './initialState';
|
||||
|
||||
vi.mock('zustand/traditional');
|
||||
|
||||
describe('chatDockSlice', () => {
|
||||
describe('pushPortalView', () => {
|
||||
it('should push a new view onto the stack and open portal', () => {
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
|
||||
expect(result.current.portalStack).toEqual([]);
|
||||
expect(result.current.showPortal).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.pushPortalView({ type: PortalViewType.Notebook });
|
||||
});
|
||||
|
||||
expect(result.current.portalStack).toHaveLength(1);
|
||||
expect(result.current.portalStack[0]).toEqual({ type: PortalViewType.Notebook });
|
||||
expect(result.current.showPortal).toBe(true);
|
||||
});
|
||||
|
||||
it('should replace top view when pushing same type', () => {
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
|
||||
act(() => {
|
||||
result.current.pushPortalView({ type: PortalViewType.Document, documentId: 'doc-1' });
|
||||
});
|
||||
|
||||
expect(result.current.portalStack).toHaveLength(1);
|
||||
|
||||
act(() => {
|
||||
result.current.pushPortalView({ type: PortalViewType.Document, documentId: 'doc-2' });
|
||||
});
|
||||
|
||||
expect(result.current.portalStack).toHaveLength(1);
|
||||
expect(result.current.portalStack[0]).toEqual({
|
||||
type: PortalViewType.Document,
|
||||
documentId: 'doc-2',
|
||||
});
|
||||
});
|
||||
|
||||
it('should stack different view types', () => {
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
|
||||
act(() => {
|
||||
result.current.pushPortalView({ type: PortalViewType.Notebook });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.pushPortalView({ type: PortalViewType.Document, documentId: 'doc-1' });
|
||||
});
|
||||
|
||||
expect(result.current.portalStack).toHaveLength(2);
|
||||
expect(result.current.portalStack[0]).toEqual({ type: PortalViewType.Notebook });
|
||||
expect(result.current.portalStack[1]).toEqual({
|
||||
type: PortalViewType.Document,
|
||||
documentId: 'doc-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('popPortalView', () => {
|
||||
it('should pop the top view and close portal when stack is empty', () => {
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
|
||||
act(() => {
|
||||
result.current.pushPortalView({ type: PortalViewType.Notebook });
|
||||
});
|
||||
|
||||
expect(result.current.showPortal).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.popPortalView();
|
||||
});
|
||||
|
||||
expect(result.current.portalStack).toHaveLength(0);
|
||||
expect(result.current.showPortal).toBe(false);
|
||||
});
|
||||
|
||||
it('should pop top view and keep portal open when more views exist', () => {
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
|
||||
act(() => {
|
||||
result.current.pushPortalView({ type: PortalViewType.Notebook });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.pushPortalView({ type: PortalViewType.Document, documentId: 'doc-1' });
|
||||
});
|
||||
|
||||
expect(result.current.portalStack).toHaveLength(2);
|
||||
|
||||
act(() => {
|
||||
result.current.popPortalView();
|
||||
});
|
||||
|
||||
expect(result.current.portalStack).toHaveLength(1);
|
||||
expect(result.current.portalStack[0]).toEqual({ type: PortalViewType.Notebook });
|
||||
expect(result.current.showPortal).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('replacePortalView', () => {
|
||||
it('should replace top view with new view', () => {
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
|
||||
act(() => {
|
||||
result.current.pushPortalView({ type: PortalViewType.Notebook });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.replacePortalView({ type: PortalViewType.Document, documentId: 'doc-1' });
|
||||
});
|
||||
|
||||
expect(result.current.portalStack).toHaveLength(1);
|
||||
expect(result.current.portalStack[0]).toEqual({
|
||||
type: PortalViewType.Document,
|
||||
documentId: 'doc-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should push view when stack is empty', () => {
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
|
||||
act(() => {
|
||||
result.current.replacePortalView({ type: PortalViewType.Notebook });
|
||||
});
|
||||
|
||||
expect(result.current.portalStack).toHaveLength(1);
|
||||
expect(result.current.portalStack[0]).toEqual({ type: PortalViewType.Notebook });
|
||||
expect(result.current.showPortal).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearPortalStack', () => {
|
||||
it('should clear all views and close portal', () => {
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
|
||||
act(() => {
|
||||
result.current.pushPortalView({ type: PortalViewType.Notebook });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.pushPortalView({ type: PortalViewType.Document, documentId: 'doc-1' });
|
||||
});
|
||||
|
||||
expect(result.current.portalStack).toHaveLength(2);
|
||||
|
||||
act(() => {
|
||||
result.current.clearPortalStack();
|
||||
});
|
||||
|
||||
expect(result.current.portalStack).toHaveLength(0);
|
||||
expect(result.current.showPortal).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('goBack', () => {
|
||||
it('should pop top view when stack has multiple views', () => {
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
|
||||
act(() => {
|
||||
result.current.pushPortalView({ type: PortalViewType.Notebook });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.pushPortalView({ type: PortalViewType.Document, documentId: 'doc-1' });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.goBack();
|
||||
});
|
||||
|
||||
expect(result.current.portalStack).toHaveLength(1);
|
||||
expect(result.current.portalStack[0]).toEqual({ type: PortalViewType.Notebook });
|
||||
});
|
||||
});
|
||||
|
||||
describe('goHome', () => {
|
||||
it('should replace stack with home view', () => {
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
|
||||
act(() => {
|
||||
result.current.pushPortalView({ type: PortalViewType.Notebook });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.pushPortalView({ type: PortalViewType.Document, documentId: 'doc-1' });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.goHome();
|
||||
});
|
||||
|
||||
expect(result.current.portalStack).toHaveLength(1);
|
||||
expect(result.current.portalStack[0]).toEqual({ type: PortalViewType.Home });
|
||||
expect(result.current.showPortal).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('closeToolUI', () => {
|
||||
it('should set dockToolMessage to undefined', () => {
|
||||
it('should pop ToolUI view from stack', () => {
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
|
||||
act(() => {
|
||||
result.current.openToolUI('test-id', 'test-identifier');
|
||||
});
|
||||
|
||||
expect(result.current.portalToolMessage).toEqual({
|
||||
id: 'test-id',
|
||||
expect(result.current.portalStack).toHaveLength(1);
|
||||
expect(result.current.portalStack[0]).toEqual({
|
||||
type: PortalViewType.ToolUI,
|
||||
messageId: 'test-id',
|
||||
identifier: 'test-identifier',
|
||||
});
|
||||
|
||||
@@ -23,12 +223,13 @@ describe('chatDockSlice', () => {
|
||||
result.current.closeToolUI();
|
||||
});
|
||||
|
||||
expect(result.current.portalToolMessage).toBeUndefined();
|
||||
expect(result.current.portalStack).toHaveLength(0);
|
||||
expect(result.current.showPortal).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('openToolUI', () => {
|
||||
it('should set dockToolMessage and open dock if it is closed', () => {
|
||||
it('should push ToolUI view and open portal', () => {
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
|
||||
expect(result.current.showPortal).toBe(false);
|
||||
@@ -37,29 +238,31 @@ describe('chatDockSlice', () => {
|
||||
result.current.openToolUI('test-id', 'test-identifier');
|
||||
});
|
||||
|
||||
expect(result.current.portalToolMessage).toEqual({
|
||||
id: 'test-id',
|
||||
expect(result.current.portalStack).toHaveLength(1);
|
||||
expect(result.current.portalStack[0]).toEqual({
|
||||
type: PortalViewType.ToolUI,
|
||||
messageId: 'test-id',
|
||||
identifier: 'test-identifier',
|
||||
});
|
||||
expect(result.current.showPortal).toBe(true);
|
||||
});
|
||||
|
||||
it('should not change dock state if it is already open', () => {
|
||||
it('should replace same type view on stack', () => {
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
|
||||
act(() => {
|
||||
result.current.togglePortal(true);
|
||||
result.current.openToolUI('test-id-1', 'identifier-1');
|
||||
});
|
||||
|
||||
expect(result.current.showPortal).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.openToolUI('test-id', 'test-identifier');
|
||||
result.current.openToolUI('test-id-2', 'identifier-2');
|
||||
});
|
||||
|
||||
expect(result.current.portalToolMessage).toEqual({
|
||||
id: 'test-id',
|
||||
identifier: 'test-identifier',
|
||||
expect(result.current.portalStack).toHaveLength(1);
|
||||
expect(result.current.portalStack[0]).toEqual({
|
||||
type: PortalViewType.ToolUI,
|
||||
messageId: 'test-id-2',
|
||||
identifier: 'identifier-2',
|
||||
});
|
||||
expect(result.current.showPortal).toBe(true);
|
||||
});
|
||||
|
||||
@@ -3,94 +3,247 @@ import { type StateCreator } from 'zustand/vanilla';
|
||||
import { type ChatStore } from '@/store/chat/store';
|
||||
import { type PortalArtifact } from '@/types/artifact';
|
||||
|
||||
import { type PortalFile } from './initialState';
|
||||
import { type PortalFile, type PortalViewData, PortalViewType } from './initialState';
|
||||
|
||||
export interface ChatPortalAction {
|
||||
// ============== Core Stack Operations ==============
|
||||
clearPortalStack: () => void;
|
||||
// ============== Convenience Methods ==============
|
||||
closeArtifact: () => void;
|
||||
closeDocument: () => void;
|
||||
closeFilePreview: () => void;
|
||||
closeMessageDetail: () => void;
|
||||
closeNotebook: () => void;
|
||||
|
||||
closeToolUI: () => void;
|
||||
goBack: () => void;
|
||||
goHome: () => void;
|
||||
openArtifact: (artifact: PortalArtifact) => void;
|
||||
openDocument: (documentId: string) => void;
|
||||
openFilePreview: (portal: PortalFile) => void;
|
||||
openFilePreview: (file: PortalFile) => void;
|
||||
openMessageDetail: (messageId: string) => void;
|
||||
openNotebook: () => void;
|
||||
openToolUI: (messageId: string, identifier: string) => void;
|
||||
popPortalView: () => void;
|
||||
pushPortalView: (view: PortalViewData) => void;
|
||||
replacePortalView: (view: PortalViewData) => void;
|
||||
toggleNotebook: (open?: boolean) => void;
|
||||
togglePortal: (open?: boolean) => void;
|
||||
}
|
||||
|
||||
// Helper to get current view type from stack
|
||||
const getCurrentViewType = (portalStack: PortalViewData[]): PortalViewType | null => {
|
||||
const top = portalStack.at(-1);
|
||||
return top?.type ?? null;
|
||||
};
|
||||
|
||||
export const chatPortalSlice: StateCreator<
|
||||
ChatStore,
|
||||
[['zustand/devtools', never]],
|
||||
[],
|
||||
ChatPortalAction
|
||||
> = (set, get) => ({
|
||||
closeArtifact: () => {
|
||||
get().togglePortal(false);
|
||||
set({ portalArtifact: undefined }, false, 'closeArtifact');
|
||||
},
|
||||
closeDocument: () => {
|
||||
set({ portalDocumentId: undefined }, false, 'closeDocument');
|
||||
},
|
||||
closeFilePreview: () => {
|
||||
set({ portalFile: undefined }, false, 'closeFilePreview');
|
||||
},
|
||||
closeMessageDetail: () => {
|
||||
set({ portalMessageDetail: undefined }, false, 'openMessageDetail');
|
||||
},
|
||||
closeNotebook: () => {
|
||||
set({ showNotebook: false }, false, 'closeNotebook');
|
||||
},
|
||||
closeToolUI: () => {
|
||||
set({ portalToolMessage: undefined }, false, 'closeToolUI');
|
||||
},
|
||||
openArtifact: (artifact) => {
|
||||
get().togglePortal(true);
|
||||
|
||||
set({ portalArtifact: artifact }, false, 'openArtifact');
|
||||
},
|
||||
openDocument: (documentId) => {
|
||||
get().togglePortal(true);
|
||||
|
||||
set({ portalDocumentId: documentId, showNotebook: true }, false, 'openDocument');
|
||||
},
|
||||
openFilePreview: (portal) => {
|
||||
get().togglePortal(true);
|
||||
|
||||
set({ portalFile: portal }, false, 'openFilePreview');
|
||||
},
|
||||
openMessageDetail: (messageId) => {
|
||||
get().togglePortal(true);
|
||||
|
||||
set({ portalMessageDetail: messageId }, false, 'openMessageDetail');
|
||||
clearPortalStack: () => {
|
||||
set({ portalStack: [], showPortal: false }, false, 'clearPortalStack');
|
||||
},
|
||||
|
||||
openNotebook: () => {
|
||||
get().togglePortal(true);
|
||||
|
||||
set({ showNotebook: true }, false, 'openNotebook');
|
||||
closeArtifact: () => {
|
||||
const { portalStack } = get();
|
||||
if (getCurrentViewType(portalStack) === PortalViewType.Artifact) {
|
||||
get().popPortalView();
|
||||
}
|
||||
},
|
||||
|
||||
openToolUI: (id, identifier) => {
|
||||
get().togglePortal(true);
|
||||
|
||||
set({ portalToolMessage: { id, identifier } }, false, 'openToolUI');
|
||||
closeDocument: () => {
|
||||
const { portalStack } = get();
|
||||
if (getCurrentViewType(portalStack) === PortalViewType.Document) {
|
||||
get().popPortalView();
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
closeFilePreview: () => {
|
||||
const { portalStack } = get();
|
||||
if (getCurrentViewType(portalStack) === PortalViewType.FilePreview) {
|
||||
get().popPortalView();
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
closeMessageDetail: () => {
|
||||
const { portalStack } = get();
|
||||
if (getCurrentViewType(portalStack) === PortalViewType.MessageDetail) {
|
||||
get().popPortalView();
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
closeNotebook: () => {
|
||||
const { portalStack } = get();
|
||||
if (getCurrentViewType(portalStack) === PortalViewType.Notebook) {
|
||||
get().popPortalView();
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
|
||||
|
||||
closeToolUI: () => {
|
||||
const { portalStack } = get();
|
||||
if (getCurrentViewType(portalStack) === PortalViewType.ToolUI) {
|
||||
get().popPortalView();
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
|
||||
goBack: () => {
|
||||
get().popPortalView();
|
||||
},
|
||||
|
||||
|
||||
|
||||
goHome: () => {
|
||||
set(
|
||||
{
|
||||
portalStack: [{ type: PortalViewType.Home }],
|
||||
showPortal: true,
|
||||
},
|
||||
false,
|
||||
'goHome',
|
||||
);
|
||||
},
|
||||
|
||||
|
||||
|
||||
// ============== Convenience Methods (using stack operations) ==============
|
||||
openArtifact: (artifact) => {
|
||||
get().pushPortalView({ artifact, type: PortalViewType.Artifact });
|
||||
},
|
||||
|
||||
|
||||
|
||||
|
||||
openDocument: (documentId) => {
|
||||
get().pushPortalView({ documentId, type: PortalViewType.Document });
|
||||
},
|
||||
|
||||
|
||||
|
||||
|
||||
openFilePreview: (file) => {
|
||||
get().pushPortalView({ file, type: PortalViewType.FilePreview });
|
||||
},
|
||||
|
||||
|
||||
|
||||
openMessageDetail: (messageId) => {
|
||||
get().pushPortalView({ messageId, type: PortalViewType.MessageDetail });
|
||||
},
|
||||
|
||||
|
||||
openNotebook: () => {
|
||||
get().pushPortalView({ type: PortalViewType.Notebook });
|
||||
},
|
||||
|
||||
|
||||
openToolUI: (messageId, identifier) => {
|
||||
get().pushPortalView({ identifier, messageId, type: PortalViewType.ToolUI });
|
||||
},
|
||||
|
||||
|
||||
popPortalView: () => {
|
||||
const { portalStack } = get();
|
||||
|
||||
if (portalStack.length <= 1) {
|
||||
// Stack empty or only one item, clear stack and close portal
|
||||
set({ portalStack: [], showPortal: false }, false, 'popPortalView/close');
|
||||
} else {
|
||||
set({ portalStack: portalStack.slice(0, -1) }, false, 'popPortalView');
|
||||
}
|
||||
},
|
||||
|
||||
// ============== Core Stack Operations ==============
|
||||
pushPortalView: (view) => {
|
||||
const { portalStack } = get();
|
||||
const top = portalStack.at(-1);
|
||||
|
||||
// If top of stack is same type, replace instead of push (avoid duplicates)
|
||||
if (top?.type === view.type) {
|
||||
set(
|
||||
{
|
||||
portalStack: [...portalStack.slice(0, -1), view],
|
||||
showPortal: true,
|
||||
},
|
||||
false,
|
||||
'pushPortalView/replace',
|
||||
);
|
||||
} else {
|
||||
set(
|
||||
{
|
||||
portalStack: [...portalStack, view],
|
||||
showPortal: true,
|
||||
},
|
||||
false,
|
||||
'pushPortalView',
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
replacePortalView: (view) => {
|
||||
const { portalStack } = get();
|
||||
|
||||
if (portalStack.length === 0) {
|
||||
set({ portalStack: [view], showPortal: true }, false, 'replacePortalView/push');
|
||||
} else {
|
||||
set(
|
||||
{
|
||||
portalStack: [...portalStack.slice(0, -1), view],
|
||||
showPortal: true,
|
||||
},
|
||||
false,
|
||||
'replacePortalView',
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
toggleNotebook: (open) => {
|
||||
const showNotebook = open === undefined ? !get().showNotebook : open;
|
||||
const { portalStack } = get();
|
||||
const isCurrentlyNotebook = getCurrentViewType(portalStack) === PortalViewType.Notebook;
|
||||
const shouldOpen = open ?? !isCurrentlyNotebook;
|
||||
|
||||
get().togglePortal(showNotebook);
|
||||
set({ showNotebook }, false, 'toggleNotebook');
|
||||
if (shouldOpen) {
|
||||
get().openNotebook();
|
||||
} else {
|
||||
get().closeNotebook();
|
||||
}
|
||||
},
|
||||
|
||||
togglePortal: (open) => {
|
||||
const showInspector = open === undefined ? !get().showPortal : open;
|
||||
set({ showPortal: showInspector }, false, 'toggleInspector');
|
||||
const nextOpen = open === undefined ? !get().showPortal : open;
|
||||
|
||||
if (!nextOpen) {
|
||||
// When closing, clear the stack
|
||||
set({ portalStack: [], showPortal: false }, false, 'togglePortal/close');
|
||||
} else {
|
||||
// When opening, if stack is empty, push Home view
|
||||
const { portalStack } = get();
|
||||
if (portalStack.length === 0) {
|
||||
set(
|
||||
{
|
||||
portalStack: [{ type: PortalViewType.Home }],
|
||||
showPortal: true,
|
||||
},
|
||||
false,
|
||||
'togglePortal/openHome',
|
||||
);
|
||||
} else {
|
||||
set({ showPortal: true }, false, 'togglePortal/open');
|
||||
}
|
||||
}
|
||||
},
|
||||
// updateArtifactContent: (content) => {
|
||||
// set({ portalArtifact: content }, false, 'updateArtifactContent');
|
||||
// },
|
||||
});
|
||||
|
||||
@@ -5,25 +5,64 @@ export enum ArtifactDisplayMode {
|
||||
Preview = 'preview',
|
||||
}
|
||||
|
||||
// ============== Portal View Stack Types ==============
|
||||
|
||||
export enum PortalViewType {
|
||||
Home = 'home',
|
||||
Artifact = 'artifact',
|
||||
Document = 'document',
|
||||
Notebook = 'notebook',
|
||||
FilePreview = 'filePreview',
|
||||
MessageDetail = 'messageDetail',
|
||||
ToolUI = 'toolUI',
|
||||
Thread = 'thread',
|
||||
GroupThread = 'groupThread',
|
||||
}
|
||||
|
||||
export interface PortalFile {
|
||||
chunkId?: string;
|
||||
chunkText?: string;
|
||||
fileId: string;
|
||||
}
|
||||
|
||||
export type PortalViewData =
|
||||
| { type: PortalViewType.Home }
|
||||
| { type: PortalViewType.Artifact; artifact: PortalArtifact }
|
||||
| { type: PortalViewType.Document; documentId: string }
|
||||
| { type: PortalViewType.Notebook }
|
||||
| { type: PortalViewType.FilePreview; file: PortalFile }
|
||||
| { type: PortalViewType.MessageDetail; messageId: string }
|
||||
| { type: PortalViewType.ToolUI; messageId: string; identifier: string }
|
||||
| { type: PortalViewType.Thread; threadId?: string; startMessageId?: string }
|
||||
| { type: PortalViewType.GroupThread; agentId: string };
|
||||
|
||||
// ============== Portal State ==============
|
||||
|
||||
export interface ChatPortalState {
|
||||
portalArtifact?: PortalArtifact;
|
||||
portalArtifactDisplayMode?: ArtifactDisplayMode;
|
||||
portalDocumentId?: string;
|
||||
portalFile?: PortalFile;
|
||||
portalMessageDetail?: string;
|
||||
portalThreadId?: string;
|
||||
portalToolMessage?: { id: string; identifier: string };
|
||||
showNotebook?: boolean;
|
||||
portalArtifactDisplayMode: ArtifactDisplayMode;
|
||||
portalStack: PortalViewData[];
|
||||
showPortal: boolean;
|
||||
|
||||
// Legacy fields (kept for backward compatibility during migration)
|
||||
// TODO: Remove after Phase 3 migration complete
|
||||
/** @deprecated Use portalStack instead */
|
||||
portalArtifact?: PortalArtifact;
|
||||
/** @deprecated Use portalStack instead */
|
||||
portalDocumentId?: string;
|
||||
/** @deprecated Use portalStack instead */
|
||||
portalFile?: PortalFile;
|
||||
/** @deprecated Use portalStack instead */
|
||||
portalMessageDetail?: string;
|
||||
/** @deprecated Use portalStack instead */
|
||||
portalThreadId?: string;
|
||||
/** @deprecated Use portalStack instead */
|
||||
portalToolMessage?: { id: string; identifier: string };
|
||||
/** @deprecated Use portalStack instead */
|
||||
showNotebook?: boolean;
|
||||
}
|
||||
|
||||
export const initialChatPortalState: ChatPortalState = {
|
||||
portalArtifactDisplayMode: ArtifactDisplayMode.Preview,
|
||||
portalStack: [],
|
||||
showPortal: false,
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { ChatStoreState } from '@/store/chat';
|
||||
|
||||
import { PortalViewType } from './initialState';
|
||||
import { chatPortalSelectors } from './selectors';
|
||||
|
||||
describe('chatDockSelectors', () => {
|
||||
@@ -10,6 +11,7 @@ describe('chatDockSelectors', () => {
|
||||
const state = {
|
||||
showPortal: false,
|
||||
portalToolMessage: undefined,
|
||||
portalStack: [],
|
||||
dbMessagesMap: {},
|
||||
activeAgentId: 'test-id',
|
||||
activeTopicId: undefined,
|
||||
@@ -19,6 +21,82 @@ describe('chatDockSelectors', () => {
|
||||
return state;
|
||||
};
|
||||
|
||||
describe('currentView', () => {
|
||||
it('should return null when stack is empty', () => {
|
||||
expect(chatPortalSelectors.currentView(createState())).toBeNull();
|
||||
});
|
||||
|
||||
it('should return the top view from stack', () => {
|
||||
const state = createState({
|
||||
portalStack: [
|
||||
{ type: PortalViewType.Notebook },
|
||||
{ type: PortalViewType.Document, documentId: 'doc-1' },
|
||||
],
|
||||
});
|
||||
expect(chatPortalSelectors.currentView(state)).toEqual({
|
||||
type: PortalViewType.Document,
|
||||
documentId: 'doc-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('currentViewType', () => {
|
||||
it('should return null when stack is empty', () => {
|
||||
expect(chatPortalSelectors.currentViewType(createState())).toBeNull();
|
||||
});
|
||||
|
||||
it('should return the type of top view', () => {
|
||||
const state = createState({
|
||||
portalStack: [{ type: PortalViewType.Notebook }],
|
||||
});
|
||||
expect(chatPortalSelectors.currentViewType(state)).toBe(PortalViewType.Notebook);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canGoBack', () => {
|
||||
it('should return false when stack has 0 or 1 views', () => {
|
||||
expect(chatPortalSelectors.canGoBack(createState())).toBe(false);
|
||||
expect(
|
||||
chatPortalSelectors.canGoBack(
|
||||
createState({ portalStack: [{ type: PortalViewType.Notebook }] }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when stack has more than 1 view', () => {
|
||||
const state = createState({
|
||||
portalStack: [
|
||||
{ type: PortalViewType.Notebook },
|
||||
{ type: PortalViewType.Document, documentId: 'doc-1' },
|
||||
],
|
||||
});
|
||||
expect(chatPortalSelectors.canGoBack(state)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('showArtifactUI', () => {
|
||||
it('should return false when current view is not Artifact', () => {
|
||||
expect(chatPortalSelectors.showArtifactUI(createState())).toBe(false);
|
||||
expect(
|
||||
chatPortalSelectors.showArtifactUI(
|
||||
createState({ portalStack: [{ type: PortalViewType.Notebook }] }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when current view is Artifact', () => {
|
||||
const state = createState({
|
||||
portalStack: [
|
||||
{
|
||||
type: PortalViewType.Artifact,
|
||||
artifact: { id: 'test', title: 'Test', type: 'text' },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(chatPortalSelectors.showArtifactUI(state)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('showDock', () => {
|
||||
it('should return the showDock state', () => {
|
||||
expect(chatPortalSelectors.showPortal(createState({ showPortal: true }))).toBe(true);
|
||||
@@ -27,12 +105,16 @@ describe('chatDockSelectors', () => {
|
||||
});
|
||||
|
||||
describe('toolUIMessageId', () => {
|
||||
it('should return undefined when dockToolMessage is not set', () => {
|
||||
it('should return undefined when no ToolUI view on stack', () => {
|
||||
expect(chatPortalSelectors.toolMessageId(createState())).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return the id when dockToolMessage is set', () => {
|
||||
const state = createState({ portalToolMessage: { id: 'test-id', identifier: 'test' } });
|
||||
it('should return the messageId when ToolUI view is on stack', () => {
|
||||
const state = createState({
|
||||
portalStack: [
|
||||
{ type: PortalViewType.ToolUI, messageId: 'test-id', identifier: 'test' },
|
||||
],
|
||||
});
|
||||
expect(chatPortalSelectors.toolMessageId(state)).toBe('test-id');
|
||||
});
|
||||
});
|
||||
@@ -40,7 +122,9 @@ describe('chatDockSelectors', () => {
|
||||
describe('isMessageToolUIOpen', () => {
|
||||
it('should return false when id does not match or showDock is false', () => {
|
||||
const state = createState({
|
||||
portalToolMessage: { id: 'test-id', identifier: 'test' },
|
||||
portalStack: [
|
||||
{ type: PortalViewType.ToolUI, messageId: 'test-id', identifier: 'test' },
|
||||
],
|
||||
showPortal: false,
|
||||
});
|
||||
expect(chatPortalSelectors.isPluginUIOpen('test-id')(state)).toBe(false);
|
||||
@@ -49,7 +133,9 @@ describe('chatDockSelectors', () => {
|
||||
|
||||
it('should return true when id matches and showDock is true', () => {
|
||||
const state = createState({
|
||||
portalToolMessage: { id: 'test-id', identifier: 'test' },
|
||||
portalStack: [
|
||||
{ type: PortalViewType.ToolUI, messageId: 'test-id', identifier: 'test' },
|
||||
],
|
||||
showPortal: true,
|
||||
});
|
||||
expect(chatPortalSelectors.isPluginUIOpen('test-id')(state)).toBe(true);
|
||||
@@ -57,45 +143,61 @@ describe('chatDockSelectors', () => {
|
||||
});
|
||||
|
||||
describe('showToolUI', () => {
|
||||
it('should return false when dockToolMessage is not set', () => {
|
||||
it('should return false when no ToolUI view on stack', () => {
|
||||
expect(chatPortalSelectors.showPluginUI(createState())).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when dockToolMessage is set', () => {
|
||||
const state = createState({ portalToolMessage: { id: 'test-id', identifier: 'test' } });
|
||||
it('should return true when ToolUI view is on stack', () => {
|
||||
const state = createState({
|
||||
portalStack: [
|
||||
{ type: PortalViewType.ToolUI, messageId: 'test-id', identifier: 'test' },
|
||||
],
|
||||
});
|
||||
expect(chatPortalSelectors.showPluginUI(state)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toolUIIdentifier', () => {
|
||||
it('should return undefined when dockToolMessage is not set', () => {
|
||||
it('should return undefined when no ToolUI view on stack', () => {
|
||||
expect(chatPortalSelectors.toolUIIdentifier(createState())).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return the identifier when dockToolMessage is set', () => {
|
||||
const state = createState({ portalToolMessage: { id: 'test-id', identifier: 'test' } });
|
||||
it('should return the identifier when ToolUI view is on stack', () => {
|
||||
const state = createState({
|
||||
portalStack: [
|
||||
{ type: PortalViewType.ToolUI, messageId: 'test-id', identifier: 'test' },
|
||||
],
|
||||
});
|
||||
expect(chatPortalSelectors.toolUIIdentifier(state)).toBe('test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('showFilePreview', () => {
|
||||
it('should return false when portalFile is not set', () => {
|
||||
it('should return false when no FilePreview view on stack', () => {
|
||||
expect(chatPortalSelectors.showFilePreview(createState())).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when portalFile is set', () => {
|
||||
const state = createState({ portalFile: { fileId: 'file-id', chunkText: 'chunk' } });
|
||||
it('should return true when FilePreview view is on stack', () => {
|
||||
const state = createState({
|
||||
portalStack: [
|
||||
{ type: PortalViewType.FilePreview, file: { fileId: 'file-id', chunkText: 'chunk' } },
|
||||
],
|
||||
});
|
||||
expect(chatPortalSelectors.showFilePreview(state)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('previewFileId', () => {
|
||||
it('should return undefined when portalFile is not set', () => {
|
||||
it('should return undefined when no FilePreview view on stack', () => {
|
||||
expect(chatPortalSelectors.previewFileId(createState())).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return the fileId when portalFile is set', () => {
|
||||
const state = createState({ portalFile: { fileId: 'file-id', chunkText: 'chunk' } });
|
||||
it('should return the fileId when FilePreview view is on stack', () => {
|
||||
const state = createState({
|
||||
portalStack: [
|
||||
{ type: PortalViewType.FilePreview, file: { fileId: 'file-id', chunkText: 'chunk' } },
|
||||
],
|
||||
});
|
||||
expect(chatPortalSelectors.previewFileId(state)).toBe('file-id');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,35 +1,66 @@
|
||||
import { ARTIFACT_TAG_CLOSED_REGEX, ARTIFACT_TAG_REGEX } from '@/const/plugin';
|
||||
import type { ChatStoreState } from '@/store/chat';
|
||||
import { type PortalArtifact } from '@/types/artifact';
|
||||
|
||||
import { dbMessageSelectors } from '../message/selectors';
|
||||
import { type PortalFile, type PortalViewData, PortalViewType } from './initialState';
|
||||
|
||||
// ============== Core Stack Selectors ==============
|
||||
|
||||
const currentView = (s: ChatStoreState): PortalViewData | null => {
|
||||
const { portalStack } = s;
|
||||
return portalStack[portalStack.length - 1] ?? null;
|
||||
};
|
||||
|
||||
const currentViewType = (s: ChatStoreState): PortalViewType | null => {
|
||||
return currentView(s)?.type ?? null;
|
||||
};
|
||||
|
||||
const canGoBack = (s: ChatStoreState): boolean => {
|
||||
return s.portalStack.length > 1;
|
||||
};
|
||||
|
||||
const stackDepth = (s: ChatStoreState): number => {
|
||||
return s.portalStack.length;
|
||||
};
|
||||
|
||||
const showPortal = (s: ChatStoreState) => s.showPortal;
|
||||
|
||||
const showMessageDetail = (s: ChatStoreState) => !!s.portalMessageDetail;
|
||||
const messageDetailId = (s: ChatStoreState) => s.portalMessageDetail;
|
||||
// ============== View Type Guards ==============
|
||||
|
||||
const showPluginUI = (s: ChatStoreState) => !!s.portalToolMessage;
|
||||
const showArtifactUI = (s: ChatStoreState) => currentViewType(s) === PortalViewType.Artifact;
|
||||
const showDocument = (s: ChatStoreState) => currentViewType(s) === PortalViewType.Document;
|
||||
const showNotebook = (s: ChatStoreState) => currentViewType(s) === PortalViewType.Notebook;
|
||||
const showFilePreview = (s: ChatStoreState) => currentViewType(s) === PortalViewType.FilePreview;
|
||||
const showMessageDetail = (s: ChatStoreState) =>
|
||||
currentViewType(s) === PortalViewType.MessageDetail;
|
||||
const showPluginUI = (s: ChatStoreState) => currentViewType(s) === PortalViewType.ToolUI;
|
||||
|
||||
const toolMessageId = (s: ChatStoreState) => s.portalToolMessage?.id;
|
||||
const isPluginUIOpen = (id: string) => (s: ChatStoreState) =>
|
||||
toolMessageId(s) === id && showPortal(s);
|
||||
const toolUIIdentifier = (s: ChatStoreState) => s.portalToolMessage?.identifier;
|
||||
// ============== Data Extractors ==============
|
||||
|
||||
const showFilePreview = (s: ChatStoreState) => !!s.portalFile;
|
||||
const previewFileId = (s: ChatStoreState) => s.portalFile?.fileId;
|
||||
const chunkText = (s: ChatStoreState) => s.portalFile?.chunkText;
|
||||
// Helper to extract data from current view
|
||||
const getViewData = <T extends PortalViewType>(
|
||||
s: ChatStoreState,
|
||||
type: T,
|
||||
): Extract<PortalViewData, { type: T }> | null => {
|
||||
const view = currentView(s);
|
||||
if (view?.type === type) {
|
||||
return view as Extract<PortalViewData, { type: T }>;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const showNotebook = (s: ChatStoreState) => !!s.showNotebook;
|
||||
// Artifact selectors
|
||||
const currentArtifact = (s: ChatStoreState): PortalArtifact | undefined => {
|
||||
const view = getViewData(s, PortalViewType.Artifact);
|
||||
return view?.artifact;
|
||||
};
|
||||
|
||||
const showDocument = (s: ChatStoreState) => !!s.portalDocumentId;
|
||||
const portalDocumentId = (s: ChatStoreState) => s.portalDocumentId;
|
||||
|
||||
const showArtifactUI = (s: ChatStoreState) => !!s.portalArtifact;
|
||||
const artifactTitle = (s: ChatStoreState) => s.portalArtifact?.title;
|
||||
const artifactIdentifier = (s: ChatStoreState) => s.portalArtifact?.identifier || '';
|
||||
const artifactMessageId = (s: ChatStoreState) => s.portalArtifact?.id;
|
||||
const artifactType = (s: ChatStoreState) => s.portalArtifact?.type;
|
||||
const artifactCodeLanguage = (s: ChatStoreState) => s.portalArtifact?.language;
|
||||
const artifactTitle = (s: ChatStoreState) => currentArtifact(s)?.title;
|
||||
const artifactIdentifier = (s: ChatStoreState) => currentArtifact(s)?.identifier || '';
|
||||
const artifactMessageId = (s: ChatStoreState) => currentArtifact(s)?.id;
|
||||
const artifactType = (s: ChatStoreState) => currentArtifact(s)?.type;
|
||||
const artifactCodeLanguage = (s: ChatStoreState) => currentArtifact(s)?.language;
|
||||
|
||||
const artifactMessageContent = (id: string) => (s: ChatStoreState) => {
|
||||
const message = dbMessageSelectors.getDbMessageById(id)(s);
|
||||
@@ -54,37 +85,87 @@ const isArtifactTagClosed = (id: string) => (s: ChatStoreState) => {
|
||||
return ARTIFACT_TAG_CLOSED_REGEX.test(content || '');
|
||||
};
|
||||
|
||||
// Document selectors
|
||||
const portalDocumentId = (s: ChatStoreState): string | undefined => {
|
||||
const view = getViewData(s, PortalViewType.Document);
|
||||
return view?.documentId;
|
||||
};
|
||||
|
||||
// File Preview selectors
|
||||
const currentFile = (s: ChatStoreState): PortalFile | undefined => {
|
||||
const view = getViewData(s, PortalViewType.FilePreview);
|
||||
return view?.file;
|
||||
};
|
||||
|
||||
const previewFileId = (s: ChatStoreState) => currentFile(s)?.fileId;
|
||||
const chunkText = (s: ChatStoreState) => currentFile(s)?.chunkText;
|
||||
|
||||
// Message Detail selectors
|
||||
const messageDetailId = (s: ChatStoreState): string | undefined => {
|
||||
const view = getViewData(s, PortalViewType.MessageDetail);
|
||||
return view?.messageId;
|
||||
};
|
||||
|
||||
// Tool UI / Plugin selectors
|
||||
const currentToolUI = (
|
||||
s: ChatStoreState,
|
||||
): { messageId: string; identifier: string } | undefined => {
|
||||
const view = getViewData(s, PortalViewType.ToolUI);
|
||||
if (view) {
|
||||
return { messageId: view.messageId, identifier: view.identifier };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const toolMessageId = (s: ChatStoreState) => currentToolUI(s)?.messageId;
|
||||
const toolUIIdentifier = (s: ChatStoreState) => currentToolUI(s)?.identifier;
|
||||
const isPluginUIOpen = (id: string) => (s: ChatStoreState) =>
|
||||
toolMessageId(s) === id && showPortal(s);
|
||||
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix, typescript-sort-keys/interface */
|
||||
export const chatPortalSelectors = {
|
||||
isPluginUIOpen,
|
||||
|
||||
previewFileId,
|
||||
showFilePreview,
|
||||
chunkText,
|
||||
|
||||
messageDetailId,
|
||||
showMessageDetail,
|
||||
|
||||
showNotebook,
|
||||
|
||||
showDocument,
|
||||
portalDocumentId,
|
||||
|
||||
showPluginUI,
|
||||
// Core stack selectors
|
||||
currentView,
|
||||
currentViewType,
|
||||
canGoBack,
|
||||
stackDepth,
|
||||
showPortal,
|
||||
|
||||
toolMessageId,
|
||||
toolUIIdentifier,
|
||||
|
||||
// View type guards
|
||||
showArtifactUI,
|
||||
showDocument,
|
||||
showNotebook,
|
||||
showFilePreview,
|
||||
showMessageDetail,
|
||||
showPluginUI,
|
||||
|
||||
// Artifact data
|
||||
currentArtifact,
|
||||
artifactTitle,
|
||||
artifactIdentifier,
|
||||
artifactMessageId,
|
||||
artifactType,
|
||||
artifactCodeLanguage,
|
||||
artifactCode,
|
||||
artifactMessageContent,
|
||||
artifactCodeLanguage,
|
||||
isArtifactTagClosed,
|
||||
|
||||
// Document data
|
||||
portalDocumentId,
|
||||
|
||||
// File preview data
|
||||
currentFile,
|
||||
previewFileId,
|
||||
chunkText,
|
||||
|
||||
// Message detail data
|
||||
messageDetailId,
|
||||
|
||||
// Tool UI data
|
||||
currentToolUI,
|
||||
toolMessageId,
|
||||
toolUIIdentifier,
|
||||
isPluginUIOpen,
|
||||
};
|
||||
|
||||
export * from './selectors/thread';
|
||||
|
||||
@@ -1,17 +1,58 @@
|
||||
import type { ChatStoreState } from '@/store/chat';
|
||||
|
||||
const showThread = (s: ChatStoreState) => !!s.threadStartMessageId || !!s.portalThreadId;
|
||||
import { type PortalViewData, PortalViewType } from '../initialState';
|
||||
|
||||
// Helper to get current view
|
||||
const getCurrentView = (s: ChatStoreState): PortalViewData | null => {
|
||||
const { portalStack } = s;
|
||||
return portalStack[portalStack.length - 1] ?? null;
|
||||
};
|
||||
|
||||
// Check if current view is Thread
|
||||
const showThread = (s: ChatStoreState) => {
|
||||
const view = getCurrentView(s);
|
||||
if (view?.type === PortalViewType.Thread) {
|
||||
return true;
|
||||
}
|
||||
// Also check legacy threadStartMessageId for backward compatibility during transition
|
||||
return !!s.threadStartMessageId;
|
||||
};
|
||||
|
||||
const newThreadMode = (s: ChatStoreState) => s.newThreadMode;
|
||||
|
||||
const portalCurrentThread = (s: ChatStoreState) => {
|
||||
if (!s.portalThreadId || !s.activeTopicId) return;
|
||||
// Get current thread data from stack
|
||||
const currentThreadView = (s: ChatStoreState) => {
|
||||
const view = getCurrentView(s);
|
||||
if (view?.type === PortalViewType.Thread) {
|
||||
return view;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (s.threadMaps[s.activeTopicId] || []).find((t) => t.id === s.portalThreadId);
|
||||
// Get thread ID - from stack or legacy field
|
||||
const portalThreadId = (s: ChatStoreState): string | undefined => {
|
||||
const threadView = currentThreadView(s);
|
||||
return threadView?.threadId ?? s.portalThreadId;
|
||||
};
|
||||
|
||||
// Get start message ID - from stack or legacy field
|
||||
const threadStartMessageId = (s: ChatStoreState): string | undefined => {
|
||||
const threadView = currentThreadView(s);
|
||||
return threadView?.startMessageId ?? s.threadStartMessageId ?? undefined;
|
||||
};
|
||||
|
||||
const portalCurrentThread = (s: ChatStoreState) => {
|
||||
const threadId = portalThreadId(s);
|
||||
if (!threadId || !s.activeTopicId) return;
|
||||
|
||||
return (s.threadMaps[s.activeTopicId] || []).find((t) => t.id === threadId);
|
||||
};
|
||||
|
||||
export const portalThreadSelectors = {
|
||||
currentThreadView,
|
||||
newThreadMode,
|
||||
portalCurrentThread,
|
||||
portalThreadId,
|
||||
showThread,
|
||||
threadStartMessageId,
|
||||
};
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
// Selectors
|
||||
export { editorSelectors } from './slices/editor';
|
||||
export { notebookSelectors } from './slices/notebook';
|
||||
|
||||
// Store
|
||||
export type { DocumentAction, DocumentState, DocumentStore } from './store';
|
||||
export type { DocumentState, DocumentStore, DocumentStoreAction } from './store';
|
||||
export { getDocumentStoreState, useDocumentStore } from './store';
|
||||
|
||||
// Re-export slice types
|
||||
export type { EditorAction, EditorState } from './slices/editor';
|
||||
export type { NotebookAction, NotebookState } from './slices/notebook';
|
||||
// Re-export document slice types
|
||||
export type {
|
||||
DocumentAction,
|
||||
InitDocumentParams,
|
||||
UseFetchDocumentOptions,
|
||||
} from './slices/document';
|
||||
|
||||
// Re-export editor slice types
|
||||
export type {
|
||||
DocumentSourceType,
|
||||
EditorAction,
|
||||
EditorContentState,
|
||||
EditorState,
|
||||
SaveMetadata,
|
||||
} from './slices/editor';
|
||||
export { createInitialEditorContentState } from './slices/editor';
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
'use client';
|
||||
|
||||
import { EDITOR_DEBOUNCE_TIME, EDITOR_MAX_WAIT } from '@lobechat/const';
|
||||
import { type DocumentItem } from '@lobechat/database/schemas';
|
||||
import { type IEditor } from '@lobehub/editor';
|
||||
import { debounce } from 'es-toolkit/compat';
|
||||
import type { SWRResponse } from 'swr';
|
||||
import { type StateCreator } from 'zustand/vanilla';
|
||||
|
||||
import { useClientDataSWRWithSync } from '@/libs/swr';
|
||||
import { documentService } from '@/services/document';
|
||||
import { setNamespace } from '@/utils/storeDebug';
|
||||
|
||||
import type { DocumentStore } from '../../store';
|
||||
import { type DocumentSourceType } from '../editor/initialState';
|
||||
|
||||
const n = setNamespace('document/document');
|
||||
|
||||
/**
|
||||
* Parameters for initializing a document with editor
|
||||
*/
|
||||
export interface InitDocumentParams {
|
||||
/**
|
||||
* Whether auto-save is enabled. Defaults to true.
|
||||
* Set to false if the consumer handles saving themselves.
|
||||
*/
|
||||
autoSave?: boolean;
|
||||
content?: string | null;
|
||||
documentId: string;
|
||||
editor: IEditor;
|
||||
editorData?: unknown;
|
||||
sourceType: DocumentSourceType;
|
||||
topicId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for useFetchDocument hook
|
||||
*/
|
||||
export interface UseFetchDocumentOptions {
|
||||
/**
|
||||
* Whether auto-save is enabled. Defaults to true.
|
||||
*/
|
||||
autoSave?: boolean;
|
||||
/**
|
||||
* Editor instance to load content into
|
||||
*/
|
||||
editor?: IEditor;
|
||||
/**
|
||||
* Source type for the document. Defaults to 'page'.
|
||||
*/
|
||||
sourceType?: DocumentSourceType;
|
||||
}
|
||||
|
||||
export interface DocumentAction {
|
||||
/**
|
||||
* Close a document and remove it from state
|
||||
*/
|
||||
closeDocument: (documentId: string) => void;
|
||||
/**
|
||||
* Flush any pending debounced save for a document
|
||||
*/
|
||||
flushSave: (documentId?: string) => void;
|
||||
/**
|
||||
* Initialize a document with editor - stores state only.
|
||||
* Content is loaded into editor via onEditorInit when Editor component is ready.
|
||||
*/
|
||||
initDocumentWithEditor: (params: InitDocumentParams) => void;
|
||||
/**
|
||||
* Trigger a debounced save for the specified document
|
||||
*/
|
||||
triggerDebouncedSave: (documentId: string) => void;
|
||||
/**
|
||||
* SWR hook to fetch document and initialize in DocumentStore
|
||||
*/
|
||||
useFetchDocument: (
|
||||
documentId: string | undefined,
|
||||
options?: UseFetchDocumentOptions,
|
||||
) => SWRResponse<DocumentItem | null>;
|
||||
}
|
||||
|
||||
export const createDocumentSlice: StateCreator<
|
||||
DocumentStore,
|
||||
[['zustand/devtools', never]],
|
||||
[],
|
||||
DocumentAction
|
||||
> = (set, get) => {
|
||||
// Store debounced save functions per document - inside store closure so `get` is always correct
|
||||
const debouncedSaves = new Map<string, ReturnType<typeof debounce>>();
|
||||
|
||||
const getOrCreateDebouncedSave = (documentId: string) => {
|
||||
if (!debouncedSaves.has(documentId)) {
|
||||
const debouncedFn = debounce(
|
||||
async () => {
|
||||
try {
|
||||
await get().performSave(documentId);
|
||||
} catch (error) {
|
||||
console.error('[DocumentStore] Failed to auto-save:', error);
|
||||
}
|
||||
},
|
||||
EDITOR_DEBOUNCE_TIME,
|
||||
{ leading: false, maxWait: EDITOR_MAX_WAIT, trailing: true },
|
||||
);
|
||||
debouncedSaves.set(documentId, debouncedFn);
|
||||
}
|
||||
return debouncedSaves.get(documentId)!;
|
||||
};
|
||||
|
||||
const cleanupDebouncedSave = (documentId: string) => {
|
||||
const fn = debouncedSaves.get(documentId);
|
||||
if (fn) {
|
||||
fn.cancel();
|
||||
debouncedSaves.delete(documentId);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
closeDocument: (documentId) => {
|
||||
// Flush any pending saves before closing
|
||||
const save = debouncedSaves.get(documentId);
|
||||
if (save) {
|
||||
save.flush();
|
||||
cleanupDebouncedSave(documentId);
|
||||
}
|
||||
|
||||
const { activeDocumentId, internal_dispatchDocument } = get();
|
||||
|
||||
// Delete document via reducer
|
||||
internal_dispatchDocument({ id: documentId, type: 'deleteDocument' });
|
||||
|
||||
// Update activeDocumentId if needed
|
||||
if (activeDocumentId === documentId) {
|
||||
set({ activeDocumentId: undefined }, false, n('closeDocument:clearActive'));
|
||||
}
|
||||
},
|
||||
|
||||
flushSave: (documentId) => {
|
||||
const id = documentId || get().activeDocumentId;
|
||||
if (id) {
|
||||
const save = debouncedSaves.get(id);
|
||||
save?.flush();
|
||||
}
|
||||
},
|
||||
|
||||
initDocumentWithEditor: (params) => {
|
||||
const { documentId, sourceType, content, editorData, topicId, autoSave, editor } = params;
|
||||
|
||||
const { internal_dispatchDocument } = get();
|
||||
|
||||
// Add or update document via reducer
|
||||
internal_dispatchDocument({
|
||||
id: documentId,
|
||||
type: 'addDocument',
|
||||
value: {
|
||||
autoSave,
|
||||
content: content ?? undefined,
|
||||
editorData,
|
||||
lastSavedContent: content ?? undefined,
|
||||
sourceType,
|
||||
topicId,
|
||||
},
|
||||
});
|
||||
|
||||
// Update activeDocumentId and editor
|
||||
set({ activeDocumentId: documentId, editor }, false, n('initDocumentWithEditor:setActive'));
|
||||
},
|
||||
|
||||
triggerDebouncedSave: (documentId) => {
|
||||
const save = getOrCreateDebouncedSave(documentId);
|
||||
save();
|
||||
},
|
||||
|
||||
useFetchDocument: (documentId, options = {}) => {
|
||||
const { autoSave = true, editor, sourceType = 'page' } = options;
|
||||
const swrKey = documentId && editor ? ['document/editor', documentId] : null;
|
||||
|
||||
return useClientDataSWRWithSync<DocumentItem | null>(
|
||||
swrKey,
|
||||
async () => {
|
||||
// documentId is guaranteed to be defined when swrKey is not null
|
||||
const document = await documentService.getDocumentById(documentId!);
|
||||
if (!document) {
|
||||
console.warn(`[useFetchDocument] Document not found: ${documentId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return document;
|
||||
},
|
||||
{
|
||||
focusThrottleInterval: 20_000,
|
||||
onData: (document) => {
|
||||
// Both documentId and editor are guaranteed to be defined when this callback is called
|
||||
if (!document || !documentId || !editor) return;
|
||||
|
||||
// Initialize document with editor
|
||||
get().initDocumentWithEditor({
|
||||
autoSave,
|
||||
content: document.content,
|
||||
documentId,
|
||||
editor,
|
||||
editorData: document.editorData,
|
||||
sourceType,
|
||||
});
|
||||
},
|
||||
revalidateOnFocus: true,
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
createDocumentSlice,
|
||||
type DocumentAction,
|
||||
type InitDocumentParams,
|
||||
type UseFetchDocumentOptions,
|
||||
} from './action';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user