Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,15 @@ import { useDocumentFinalize } from '@features/document/components/session/use-d
import { useExternalDocumentChangeWatch } from '@features/document/components/session/use-external-document-change-watch';
import { useExternalDocumentImport } from '@features/document/components/session/use-external-document-import';
import { LazyDocumentEditor } from '@features/document/components/lazy-document-editor';
import { LazyMarkmapView } from '@features/document/components/lazy-markmap-view';
import { NotePropertiesDialog } from '@features/document/components/note-properties-dialog';
import type { MarkdownEditorHandle } from '@features/editor/markdown-editor';
import backgroundImage from '@/assets/bg.document.png';
import { useI18n } from '@features/i18n';
import { FileText, GitFork } from 'lucide-react';
import { Tooltip } from '@shared/ui/tooltip';

type DocumentViewMode = 'editor' | 'markmap';

export function DocumentContainer({
filePath,
Expand Down Expand Up @@ -130,6 +135,20 @@ export function DocumentContainer({
});
const [propertiesOpen, setPropertiesOpen] = useState(false);
const [propertiesContentSnapshot, setPropertiesContentSnapshot] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<DocumentViewMode>('editor');

const changeViewMode = useCallback((nextMode: DocumentViewMode) => {
if (nextMode === viewMode) return;
if (nextMode === 'markmap') {
const latestContent = flushPendingEditorChanges();
if (latestContent !== null) {
handleChange(latestContent);
setState((prev) => ({ ...prev, fullContent: latestContent }));
}
finalizeMemoRename();
}
setViewMode(nextMode);
}, [finalizeMemoRename, flushPendingEditorChanges, handleChange, setState, viewMode]);

// Publish the external-import api upward so the titlebar (rendered as a
// sibling above the content area) can show the file path and the save
Expand Down Expand Up @@ -356,8 +375,48 @@ export function DocumentContainer({

return (
<div className="document-container h-full w-full min-w-0 flex flex-col bg-transparent relative overflow-hidden">
{state.fullContent && (
<div
className="absolute right-4 top-3 z-30 flex items-center rounded-xl border border-[color-mix(in_oklch,var(--border)_84%,transparent)] bg-[color-mix(in_oklch,var(--card)_86%,transparent)] p-1 shadow-sm backdrop-blur-xl"
role="tablist"
aria-label={t('document.viewMode.label')}
>
<Tooltip content={t('document.viewMode.editor')}>
<button
type="button"
role="tab"
aria-selected={viewMode === 'editor'}
onClick={() => changeViewMode('editor')}
className={`inline-flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] ${
viewMode === 'editor'
? 'bg-[var(--primary)] text-[var(--primary-foreground)] shadow-sm'
: 'text-[var(--muted-foreground)] hover:bg-[var(--accent)] hover:text-[var(--foreground)]'
}`}
>
<FileText className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t('document.viewMode.editor')}</span>
</button>
</Tooltip>
<Tooltip content={t('document.viewMode.markmap')}>
<button
type="button"
role="tab"
aria-selected={viewMode === 'markmap'}
onClick={() => changeViewMode('markmap')}
className={`inline-flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] ${
viewMode === 'markmap'
? 'bg-[var(--primary)] text-[var(--primary-foreground)] shadow-sm'
: 'text-[var(--muted-foreground)] hover:bg-[var(--accent)] hover:text-[var(--foreground)]'
}`}
>
<GitFork className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t('document.viewMode.markmap')}</span>
</button>
</Tooltip>
</div>
)}
<div className="flex-1 min-h-0 overflow-hidden">
{state.fullContent && (
{state.fullContent && viewMode === 'editor' && (
<LazyDocumentEditor
ref={editorHandleRef}
key={documentInstanceKey}
Expand All @@ -380,6 +439,9 @@ export function DocumentContainer({
onToolbarCollapsedChange={onToolbarCollapsedChange}
/>
)}
{state.fullContent && viewMode === 'markmap' && (
<LazyMarkmapView content={state.fullContent} />
)}
</div>
{!isExternalDocument && memoId && (
<NotePropertiesDialog
Expand Down
21 changes: 21 additions & 0 deletions app/flowix-web/features/document/components/lazy-markmap-view.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { lazy, Suspense } from 'react';
import { useI18n } from '@features/i18n';

const LazyMarkmap = lazy(() =>
import('./markmap/markmap-view').then((module) => ({ default: module.MarkmapView })),
);

export function LazyMarkmapView({ content }: { content: string }) {
const { t } = useI18n();
return (
<Suspense
fallback={(
<div className="flex h-full items-center justify-center text-sm text-[var(--muted-foreground)]">
{t('document.markmap.loading')}
</div>
)}
>
<LazyMarkmap content={content} />
</Suspense>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest';
import { buildMarkmapDocument, hasMarkmapContent, type MarkmapDocument, type MarkmapRoot } from './markmap-data';

function blockFor(document: MarkmapDocument, node: MarkmapRoot) {
return document.blocks[node.payload!.blockId];
}

describe('buildMarkmapDocument', () => {
it('keeps frontmatter, headings, text, and nested lists in one document tree', () => {
const document = buildMarkmapDocument(`---
title: Demo
status: active
---
# Product

Product overview text.

## Research
- Interviews
- Customers
- Competitors

## Delivery
- Desktop app
`);

expect(blockFor(document, document.root).title).toBe('Product');
expect(document.root.children.map((node) => blockFor(document, node).kind)).toEqual([
'frontmatter',
'paragraph',
'heading',
'heading',
]);
const research = document.root.children.find((node) => blockFor(document, node).title === 'Research')!;
expect(research.children.map((node) => blockFor(document, node).title)).toEqual([
'Interviews',
'Competitors',
]);
expect(research.children[0].children.map((node) => blockFor(document, node).title)).toEqual([
'Customers',
]);
expect(hasMarkmapContent(document)).toBe(true);
});

it('supports prose-only documents instead of treating them as empty', () => {
const document = buildMarkmapDocument('A paragraph without headings or lists.');
const paragraphs = Object.values(document.blocks).filter((block) => block.kind === 'paragraph');

expect(paragraphs).toHaveLength(1);
expect(paragraphs[0].markdown).toBe('A paragraph without headings or lists.');
expect(hasMarkmapContent(document)).toBe(true);
});

it('keeps a truly empty document in the empty state', () => {
expect(hasMarkmapContent(buildMarkmapDocument(''))).toBe(false);
});

it('recognizes Mermaid diagrams and AI thread cards as rich blocks', () => {
const document = buildMarkmapDocument(`# Architecture

\`\`\`mermaid
flowchart TD
User --> Flowix
\`\`\`

::agent-thread-card{instanceId="agent-inst-1" threadId="thread-1" title="Review architecture" agentType="codex" agentRoleMemoId="" agentRoleName="Architect" collapsed="false" inputDraft="Follow%20up"}
`);
const blocks = Object.values(document.blocks);
const diagram = blocks.find((block) => block.kind === 'mermaid');
const agent = blocks.find((block) => block.kind === 'agent');

expect(diagram).toMatchObject({ language: 'mermaid', title: 'Flowchart' });
expect(diagram?.markdown).toContain('User --> Flowix');
expect(agent?.agent).toEqual({
instanceId: 'agent-inst-1',
threadId: 'thread-1',
title: 'Review architecture',
agentType: 'codex',
agentRoleName: 'Architect',
inputDraft: 'Follow up',
});
});

it('escapes raw HTML in map node labels', () => {
const document = buildMarkmapDocument('# Safe\n\n<img src=x onerror=alert(1)>');
const paragraphNode = document.root.children[0];

expect(paragraphNode.content).toContain('&lt;img');
expect(paragraphNode.content).not.toContain('<img');
expect(paragraphNode.content).not.toContain('onerror="');
});

it('wraps long node content in a readable, focusable preview target', () => {
const document = buildMarkmapDocument('# 文档\n\n这是一段很长的正文内容,用来验证导图节点会显示摘要,并可以打开完整内容预览。');
const paragraphNode = document.root.children[0];

expect(paragraphNode.content).toContain('document-markmap-node-content--paragraph');
expect(paragraphNode.content).toContain('role="button"');
expect(paragraphNode.content).toContain('tabindex="0"');
expect(document.root.content).toContain('document-markmap-heading-text');
});
});
Loading