♻️ 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:
Arvin Xu
2026-01-10 18:20:59 +08:00
committed by GitHub
parent 866eba73b2
commit 88721ebd5d
136 changed files with 6037 additions and 2729 deletions
+1
View File
@@ -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",
+1
View File
@@ -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', () => {
+15 -7
View File
@@ -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;
+1 -1
View File
@@ -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');
});
});
});
@@ -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);
@@ -11,4 +11,3 @@ const Title = memo(() => {
Title.displayName = 'PageTitle';
export default Title;
+43 -1
View File
@@ -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 />
</>
);
};
+3 -7
View File
@@ -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>
</>
);
+1 -1
View File
@@ -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;
@@ -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;
+148
View File
@@ -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;
}
}
+245
View File
@@ -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;
@@ -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);
}
};
+9
View File
@@ -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';
-68
View File
@@ -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;
+14 -111
View File
@@ -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>
+15 -18
View File
@@ -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={
+12 -9
View File
@@ -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,
+45 -21
View File
@@ -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>
);
};
+13 -1
View File
@@ -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>
+2 -2
View File
@@ -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;
+35 -308
View File
@@ -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;
+101 -193
View File
@@ -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();
},
};
};
+16 -21
View File
@@ -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,
};
+3 -4
View File
@@ -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;
}
}
+30 -63
View File
@@ -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;
-2
View File
@@ -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;
+1 -3
View File
@@ -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>
);
},
);
+7 -50
View File
@@ -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;
+13 -10
View File
@@ -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;
-54
View File
@@ -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 -4
View File
@@ -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));
+3 -11
View File
@@ -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;
-2
View File
@@ -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);
};
-2
View File
@@ -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);
};
-9
View File
@@ -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);
};
-3
View File
@@ -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);
-2
View File
@@ -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);
};
-2
View File
@@ -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,
};
-6
View File
@@ -1,6 +0,0 @@
import { useChatStore } from '@/store/chat';
import { chatPortalSelectors } from '@/store/chat/selectors';
export const useEnable = () => {
return useChatStore(chatPortalSelectors.showPluginUI);
};
-8
View File
@@ -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 });
};
-3
View File
@@ -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,
};
+18 -6
View File
@@ -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,
},
}}
/>
+33 -96
View File
@@ -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;
-2
View File
@@ -5,6 +5,4 @@ export interface PortalImpl {
Header?: FC;
Title: FC;
Wrapper?: FC<PropsWithChildren>;
onClose?: () => void;
useEnable: () => boolean;
}
+1
View File
@@ -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',
+218 -15
View File
@@ -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);
});
+205 -52
View File
@@ -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');
// },
});
+47 -8
View File
@@ -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,
};
+119 -17
View File
@@ -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');
});
});
+120 -39
View File
@@ -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,
};
+17 -5
View File
@@ -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