Feat: Enable the wiki's Markdown editor to navigate to a new Markdown file when a link is clicked. (#17019)

This commit is contained in:
balibabu
2026-07-17 11:22:18 +08:00
committed by GitHub
parent f69ed74670
commit 677960716e
12 changed files with 688 additions and 194 deletions

View File

@@ -7,12 +7,14 @@ interface MarkdownEditorProps {
content: string;
onChange?: (content: string) => void;
readOnly?: boolean;
onWikiLinkClick?: (pageType: 'concept' | 'entity', slug: string) => void;
}
export default function MarkdownEditor({
content,
onChange,
readOnly = false,
onWikiLinkClick,
}: MarkdownEditorProps) {
const [showSource, setShowSource] = useState(false);
const [rawContent, setRawContent] = useState(content);
@@ -61,6 +63,7 @@ export default function MarkdownEditor({
placeholder={readOnly ? '' : 'Start writing...'}
onToggleSource={toggleSource}
showSource={showSource}
onWikiLinkClick={onWikiLinkClick}
/>
</div>
<div

View File

@@ -591,15 +591,17 @@ export const useFetchArtifactTopicList = (
};
};
export function useFetchArtifactPage(artifact: IArtifact | null) {
export function useFetchArtifactPage(
artifact: IArtifact | null,
enabled = true,
) {
const knowledgeBaseId = useKnowledgeBaseId();
const pageType = artifact?.page_type ?? '';
const slug = artifact?.slug ?? '';
const { data, isFetching: loading } = useQuery<IArtifactPage | null>({
queryKey: ArtifactKeys.detail(knowledgeBaseId, pageType, slug),
enabled: !!knowledgeBaseId && !!artifact && !!pageType && !!slug,
gcTime: 0,
enabled: !!knowledgeBaseId && !!artifact && !!pageType && !!slug && enabled,
queryFn: async () => {
const { data } = await getArtifactPage(knowledgeBaseId, pageType, slug);
return data?.data ?? null;

View File

@@ -40,6 +40,7 @@ interface Props {
placeholder?: string;
onToggleSource?: () => void;
showSource?: boolean;
onWikiLinkClick?: (pageType: 'concept' | 'entity', slug: string) => void;
}
function InitialContentPlugin({
@@ -133,6 +134,7 @@ function ChangeListener({
import FloatingSelectionToolbar from './plugins/floating-selection-toolbar';
import TableActionsPlugin from './plugins/table-actions-plugin';
import ToolbarPlugin from './plugins/toolbar-plugin';
import { WikiLinkClickPlugin } from './plugins/wiki-link-click-plugin';
export default function LexicalEditor({
content,
@@ -141,6 +143,7 @@ export default function LexicalEditor({
placeholder = 'Start writing...',
onToggleSource,
showSource = false,
onWikiLinkClick,
}: Props): JSX.Element {
const transformers = useMemo(() => CORE_TRANSFORMERS, []);
@@ -187,6 +190,7 @@ export default function LexicalEditor({
<MarkdownShortcutPlugin transformers={transformers} />
<ListPlugin />
<LinkPlugin />
<WikiLinkClickPlugin onWikiLinkClick={onWikiLinkClick} />
<HashtagPlugin />
<TablePlugin />
<TableActionsPlugin />

View File

@@ -0,0 +1,50 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import {
parseWikiLinkHref,
type WikiPageType,
} from '@/pages/dataset/compilation/utils/parse-wiki-link';
import { useEffect } from 'react';
type WikiLinkClickPluginProps = {
onWikiLinkClick?: (pageType: WikiPageType, slug: string) => void;
};
function findAnchorElement(
target: EventTarget | null,
): HTMLAnchorElement | null {
if (!(target instanceof HTMLElement)) return null;
return target.closest('a[href]') as HTMLAnchorElement | null;
}
export function WikiLinkClickPlugin({
onWikiLinkClick,
}: WikiLinkClickPluginProps) {
const [editor] = useLexicalComposerContext();
useEffect(() => {
if (!onWikiLinkClick) return;
const rootElement = editor.getRootElement();
if (!rootElement) return;
const handleClick = (event: MouseEvent) => {
const anchor = findAnchorElement(event.target);
if (!anchor) return;
const href = anchor.getAttribute('href');
if (!href) return;
const parsed = parseWikiLinkHref(href);
if (!parsed) return;
event.preventDefault();
event.stopPropagation();
onWikiLinkClick(parsed.pageType, parsed.slug);
};
rootElement.addEventListener('click', handleClick);
return () => rootElement.removeEventListener('click', handleClick);
}, [editor, onWikiLinkClick]);
return null;
}

View File

@@ -0,0 +1,219 @@
import { useFetchDocumentsByIds } from '@/hooks/use-document-request';
import {
useFetchArtifactPage,
useFetchWikiCommit,
} from '@/hooks/use-knowledge-request';
import { Docagg } from '@/interfaces/database/chat';
import { IArtifact, IWikiCommit } from '@/interfaces/database/dataset';
import { downloadMarkdownFile } from '@/utils/file-util';
import { useCallback, useEffect, useMemo } from 'react';
import type { WikiPageType } from '../utils/parse-wiki-link';
import { useCommitArtifact } from './use-commit-artifact';
import { useWikiEditor } from './use-wiki-editor';
import { useWikiLinkNavigation } from './use-wiki-link-navigation';
type UseWikiDetailContentOptions = {
selectedArtifact: IArtifact | null;
selectedVersion: IWikiCommit | null;
onSelectVersion: (version: IWikiCommit | null) => void;
onSelectArtifact: (artifact: IArtifact) => void;
};
export function useWikiDetailContent({
selectedArtifact,
selectedVersion,
onSelectVersion,
onSelectArtifact,
}: UseWikiDetailContentOptions) {
const isVersionView = !!selectedVersion;
const { data: pageData, loading: pageLoading } = useFetchArtifactPage(
isVersionView ? null : selectedArtifact,
);
const { data: commitDetail, loading: commitLoading } = useFetchWikiCommit(
selectedVersion?.id ?? null,
);
const {
currentEntry,
previousEntry,
canGoBack,
push,
goBack,
reset,
updateCurrentTitle,
} = useWikiLinkNavigation();
// Keep the current stack entry's title in sync with pageData.
useEffect(() => {
if (!currentEntry || !pageData) return;
if (currentEntry.slug !== pageData.slug) return;
updateCurrentTitle(pageData.title);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentEntry?.slug, pageData?.slug, pageData?.title, updateCurrentTitle]);
// When selectedArtifact changes from the left panel (not from our own
// wiki-link navigation), reset the stack. Wiki-link clicks call push()
// before onSelectArtifact(), so currentEntry.slug already matches by the
// time this effect runs and we bail out.
useEffect(() => {
if (isVersionView || !selectedArtifact) return;
if (currentEntry?.slug === selectedArtifact.slug) return;
reset({
slug: selectedArtifact.slug,
title: selectedArtifact.title,
pageType: selectedArtifact.page_type ?? '',
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
isVersionView,
selectedArtifact?.slug,
selectedArtifact?.title,
selectedArtifact?.page_type,
currentEntry?.slug,
reset,
]);
const content = isVersionView
? (commitDetail?.content_after ?? '')
: (pageData?.content_md_rendered ?? '');
const title =
currentEntry?.title ||
pageData?.title ||
selectedArtifact?.title ||
currentEntry?.slug ||
'';
const displayedArtifact = currentEntry
? { slug: currentEntry.slug, title: currentEntry.title, page_type: currentEntry.pageType }
: selectedArtifact;
const previousEntryTitle = previousEntry?.title || previousEntry?.slug;
const editorKey = isVersionView
? `${selectedArtifact?.slug}@${selectedVersion?.id}`
: (selectedArtifact?.slug ?? '');
const handleMarkdownLinkClick = useCallback(
(pageType: WikiPageType, slug: string) => {
if (isVersionView) return;
if (currentEntry?.slug === slug && currentEntry?.pageType === pageType)
return;
// Sync the current entry's title from pageData before pushing a new
// entry, so the title is preserved when this entry becomes the
// "previous" entry for the back button.
if (
currentEntry &&
pageData &&
pageData.slug === currentEntry.slug &&
pageData.title
) {
updateCurrentTitle(pageData.title);
}
push({ slug, title: '', pageType });
onSelectArtifact({ slug, page_type: pageType, title: '' });
},
[
push,
onSelectArtifact,
isVersionView,
currentEntry,
pageData,
updateCurrentTitle,
],
);
const handleBack = useCallback(() => {
if (!previousEntry) return;
goBack();
onSelectArtifact({
slug: previousEntry.slug,
page_type: previousEntry.pageType,
title: previousEntry.title,
});
}, [goBack, previousEntry, onSelectArtifact]);
const {
editedContent,
isDirty,
handleContentChange,
handleCancelEdit,
handleMarkAsSaved,
} = useWikiEditor({
content,
artifactSlug: editorKey,
});
const handleCommitSuccess = useCallback(() => {
handleMarkAsSaved();
if (isVersionView) {
onSelectVersion(null);
}
}, [handleMarkAsSaved, isVersionView, onSelectVersion]);
const { isOpen, open, close, form, handleConfirm, isUpdating } =
useCommitArtifact({
editedContent,
pageType: currentEntry?.pageType ?? selectedArtifact?.page_type ?? '',
slug: currentEntry?.slug ?? selectedArtifact?.slug ?? '',
onSuccess: handleCommitSuccess,
});
const { documents } = useFetchDocumentsByIds(
pageData?.source_doc_ids ?? [],
);
const referenceDocuments = useMemo<Docagg[]>(() => {
return documents.map(
(doc): Docagg => ({
doc_id: doc.id,
doc_name: doc.name,
count: 0,
}),
);
}, [documents]);
const handleExport = useCallback(() => {
const filename = `${title ?? 'document'}.md`;
downloadMarkdownFile(editedContent, filename);
}, [title, editedContent]);
const loading = isVersionView ? commitLoading : pageLoading;
return {
isVersionView,
title,
displayedArtifact,
displayedContent: content,
displayedPageType: currentEntry?.pageType ?? pageData?.page_type ?? '',
displayedSlug: currentEntry?.slug ?? pageData?.slug ?? '',
selectedArtifact,
selectedVersion,
commitDetail,
canGoBack,
previousEntry,
previousEntryTitle,
linkNavLoading: false,
loading,
editedContent,
isDirty,
referenceDocuments,
isOpen,
open,
close,
form,
handleConfirm,
isUpdating,
handleCancelEdit,
handleContentChange,
handleMarkdownLinkClick,
handleBack,
handleExport,
onSelectVersion,
};
}

View File

@@ -0,0 +1,64 @@
import { useCallback, useState } from 'react';
export type NavEntry = {
slug: string;
title: string;
pageType: string;
};
interface NavState {
stack: NavEntry[];
currentIndex: number;
}
export function useWikiLinkNavigation() {
const [state, setState] = useState<NavState>({
stack: [],
currentIndex: -1,
});
const push = useCallback((entry: NavEntry) => {
setState((prev) => ({
stack: [...prev.stack.slice(0, prev.currentIndex + 1), entry],
currentIndex: prev.currentIndex + 1,
}));
}, []);
const goBack = useCallback(() => {
setState((prev) => ({
...prev,
currentIndex: Math.max(0, prev.currentIndex - 1),
}));
}, []);
const reset = useCallback((entry: NavEntry) => {
setState({ stack: [entry], currentIndex: 0 });
}, []);
const updateCurrentTitle = useCallback((title: string) => {
setState((prev) => {
if (prev.currentIndex < 0) return prev;
const entry = prev.stack[prev.currentIndex];
if (!entry || entry.title === title) return prev;
const newStack = [...prev.stack];
newStack[prev.currentIndex] = { ...entry, title };
return { ...prev, stack: newStack };
});
}, []);
const currentEntry =
state.currentIndex >= 0 ? state.stack[state.currentIndex] : null;
const previousEntry =
state.currentIndex > 0 ? state.stack[state.currentIndex - 1] : null;
const canGoBack = state.currentIndex > 0;
return {
currentEntry,
previousEntry,
canGoBack,
push,
goBack,
reset,
updateCurrentTitle,
};
}

View File

@@ -153,6 +153,7 @@ export default function Compilation() {
selectedArtifact={selectedArtifact}
selectedVersion={selectedVersion}
onSelectVersion={selectVersion}
onSelectArtifact={handleSelectArtifact}
/>
</ResizablePanel>
</ResizablePanelGroup>

View File

@@ -0,0 +1,50 @@
export type WikiPageType = 'concept' | 'entity';
/**
* Parse an internal wiki link href into pageType and slug.
*
* Supports the common rendered forms:
* artifact/{datasetId}/{pageType}/{slug}
* /artifact/{datasetId}/{pageType}/{slug}
* {pageType}/{slug}
* /{pageType}/{slug}
*
* Only entity/ and concept/ links are considered wiki navigation links.
*/
export function parseWikiLinkHref(
href: string,
): { pageType: WikiPageType; slug: string } | null {
const normalized = href.trim();
if (!normalized) return null;
// Prefer the artifact/{datasetId}/{pageType}/{slug} form.
const artifactMatch = normalized.match(
/(?:^|\/)artifact\/[^/]+\/(entity|concept)\/([^/\s"']+)/,
);
if (artifactMatch) {
return {
pageType: artifactMatch[1] as WikiPageType,
slug: artifactMatch[2],
};
}
// Fallback to a plain {pageType}/{slug} form.
const simpleMatch = normalized.match(
/(?:^|\/)(entity|concept)\/([^/\s"']+)/,
);
if (simpleMatch) {
return {
pageType: simpleMatch[1] as WikiPageType,
slug: simpleMatch[2],
};
}
return null;
}
/**
* Check whether a link href looks like an internal wiki navigation link.
*/
export function isWikiLinkHref(href?: string): boolean {
return Boolean(href && parseWikiLinkHref(href));
}

View File

@@ -1,221 +1,97 @@
import Empty from '@/components/empty/empty';
import MarkdownEditor from '@/components/markdown-editor';
import { ReferenceDocumentList } from '@/components/next-message-item/reference-document-list';
import { Button } from '@/components/ui/button';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@/components/ui/resizable';
import { Spin } from '@/components/ui/spin';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useFetchDocumentsByIds } from '@/hooks/use-document-request';
import {
useFetchArtifactPage,
useFetchWikiCommit,
} from '@/hooks/use-knowledge-request';
import { Docagg } from '@/interfaces/database/chat';
import { IArtifact, IWikiCommit } from '@/interfaces/database/dataset';
import { downloadMarkdownFile } from '@/utils/file-util';
import { Download } from 'lucide-react';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useCommitArtifact } from './hooks/use-commit-artifact';
import { useWikiEditor } from './hooks/use-wiki-editor';
import { VersionHistorySheet } from './version-history-sheet';
import Empty from '@/components/empty/empty';
import { IArtifact, IWikiCommit } from '@/interfaces/database/dataset';
import { useWikiDetailContent } from './hooks/use-wiki-detail-content';
import { WikiCommitModal } from './wiki-commit-modal';
import { WikiVersionDiffPanel } from './wiki-version-diff-panel';
import { WikiDetailEditorPanel } from './wiki-detail-editor-panel';
import { WikiDetailHeader } from './wiki-detail-header';
import { WikiDetailToolbar } from './wiki-detail-toolbar';
type WikiDetailContentProps = {
selectedArtifact: IArtifact | null;
selectedVersion: IWikiCommit | null;
onSelectVersion: (version: IWikiCommit | null) => void;
onSelectArtifact: (artifact: IArtifact) => void;
};
export function WikiDetailContent({
selectedArtifact,
selectedVersion,
onSelectVersion,
onSelectArtifact,
}: WikiDetailContentProps) {
const { t } = useTranslation();
const isVersionView = !!selectedVersion;
const { data: pageData, loading: pageLoading } = useFetchArtifactPage(
isVersionView ? null : selectedArtifact,
);
const { data: commitDetail, loading: commitLoading } = useFetchWikiCommit(
selectedVersion?.id ?? null,
);
const title = pageData?.title ?? selectedArtifact?.title;
const pageType = isVersionView
? selectedArtifact?.page_type
: pageData?.page_type;
const slug = isVersionView ? selectedArtifact?.slug : pageData?.slug;
const content = isVersionView
? (commitDetail?.content_after ?? '')
: (pageData?.content_md_rendered ?? '');
const sourceDocIds = isVersionView ? [] : (pageData?.source_doc_ids ?? []);
const editorKey = isVersionView
? `${selectedArtifact?.slug}@${selectedVersion?.id}`
: selectedArtifact?.slug;
const {
isVersionView,
title,
displayedArtifact,
commitDetail,
canGoBack,
previousEntryTitle,
linkNavLoading,
loading,
editedContent,
displayedContent,
referenceDocuments,
isDirty,
handleContentChange,
isOpen,
open,
close,
form,
handleConfirm,
isUpdating,
handleCancelEdit,
handleMarkAsSaved,
} = useWikiEditor({
content,
artifactSlug: editorKey,
handleContentChange,
handleMarkdownLinkClick,
handleBack,
handleExport,
} = useWikiDetailContent({
selectedArtifact,
selectedVersion,
onSelectVersion,
onSelectArtifact,
});
const handleCommitSuccess = useCallback(() => {
handleMarkAsSaved();
if (isVersionView) {
onSelectVersion(null);
}
}, [handleMarkAsSaved, isVersionView, onSelectVersion]);
const { isOpen, open, close, form, handleConfirm, isUpdating } =
useCommitArtifact({
editedContent,
pageType: pageType ?? '',
slug: slug ?? '',
onSuccess: handleCommitSuccess,
});
const { documents } = useFetchDocumentsByIds(sourceDocIds);
const referenceDocuments = useMemo<Docagg[]>(() => {
return documents.map(
(doc): Docagg => ({
doc_id: doc.id,
doc_name: doc.name,
count: 0,
}),
);
}, [documents]);
const handleExport = useCallback(() => {
const filename = `${title ?? 'document'}.md`;
downloadMarkdownFile(editedContent, filename);
}, [title, editedContent]);
const renderToolbarButtons = () => {
if (isDirty) {
return (
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleCancelEdit}
>
{t('common.cancel')}
</Button>
<Button type="button" size="sm" onClick={open}>
{t('knowledgeDetails.commit')}
</Button>
</div>
);
}
return (
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={handleExport}
>
<Download className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('knowledgeDetails.export')}</TooltipContent>
</Tooltip>
<VersionHistorySheet
selectedArtifact={selectedArtifact}
selectedVersion={selectedVersion}
onSelectVersion={onSelectVersion}
/>
</div>
);
};
const loading = isVersionView ? commitLoading : pageLoading;
const toolbar = (
<WikiDetailToolbar
isDirty={isDirty}
selectedArtifact={selectedArtifact}
selectedVersion={selectedVersion}
onCancelEdit={handleCancelEdit}
onCommitClick={open}
onExport={handleExport}
onSelectVersion={onSelectVersion}
/>
);
return (
<section className="size-full min-w-0 flex flex-col">
{selectedArtifact ? (
<>
<header className="shrink-0 px-8 pt-8 pb-4">
<div className="flex items-start justify-between">
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-semibold text-text-primary">
{title ?? selectedArtifact.title}
</h1>
<div className="flex items-center gap-2">
{isVersionView && commitDetail && (
<span className="text-sm text-accent-primary bg-accent-primary-5 px-2 py-0.5 rounded">
{commitDetail.title}
</span>
)}
</div>
</div>
<WikiDetailHeader
title={title}
displayedArtifact={displayedArtifact}
commitDetail={commitDetail}
isVersionView={isVersionView}
toolbar={toolbar}
canGoBack={canGoBack}
previousEntryTitle={previousEntryTitle}
linkNavLoading={linkNavLoading}
onBack={handleBack}
/>
{renderToolbarButtons()}
</div>
</header>
<div className="flex-1 min-h-0 flex flex-col border-t border-border-button">
<ResizablePanelGroup direction="horizontal" className="flex-1">
<ResizablePanel minSize={30}>
<div className="h-full min-w-0 overflow-y-auto px-8 pb-8 flex flex-col">
{loading && !content ? (
<div className="py-8 flex justify-center">
<Spin size="large" />
</div>
) : (
<MarkdownEditor
content={editedContent}
onChange={handleContentChange}
/>
)}
{referenceDocuments.length > 0 && (
<div className="mt-8">
<h3 className="text-sm font-medium text-text-secondary mb-3">
{t('knowledgeDetails.sourceDocuments')}
</h3>
<ReferenceDocumentList list={referenceDocuments} />
</div>
)}
</div>
</ResizablePanel>
{isVersionView && commitDetail && (
<>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={30} minSize={20}>
<WikiVersionDiffPanel
diff={commitDetail.diff}
title={commitDetail.comments || commitDetail.title}
/>
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
</div>
<WikiDetailEditorPanel
loading={loading}
editedContent={editedContent}
displayedContent={displayedContent}
referenceDocuments={referenceDocuments}
isVersionView={isVersionView}
commitDetail={commitDetail}
onContentChange={handleContentChange}
onWikiLinkClick={handleMarkdownLinkClick}
/>
<WikiCommitModal
open={isOpen}

View File

@@ -0,0 +1,82 @@
import { useTranslation } from 'react-i18next';
import MarkdownEditor from '@/components/markdown-editor';
import { ReferenceDocumentList } from '@/components/next-message-item/reference-document-list';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@/components/ui/resizable';
import { Spin } from '@/components/ui/spin';
import type { Docagg } from '@/interfaces/database/chat';
import type { IWikiCommitDetail } from '@/interfaces/database/dataset';
import type { WikiPageType } from './utils/parse-wiki-link';
import { WikiVersionDiffPanel } from './wiki-version-diff-panel';
type WikiDetailEditorPanelProps = {
loading: boolean;
editedContent: string;
displayedContent: string | undefined;
referenceDocuments: Docagg[];
isVersionView: boolean;
commitDetail: IWikiCommitDetail | null | undefined;
onContentChange: (value: string) => void;
onWikiLinkClick: (pageType: WikiPageType, slug: string) => void;
};
export function WikiDetailEditorPanel({
loading,
editedContent,
displayedContent,
referenceDocuments,
isVersionView,
commitDetail,
onContentChange,
onWikiLinkClick,
}: WikiDetailEditorPanelProps) {
const { t } = useTranslation();
return (
<div className="flex-1 min-h-0 flex flex-col border-t border-border-button">
<ResizablePanelGroup direction="horizontal" className="flex-1">
<ResizablePanel minSize={30}>
<div className="h-full min-w-0 overflow-y-auto px-8 pb-8 flex flex-col">
{loading && !displayedContent ? (
<div className="py-8 flex justify-center">
<Spin size="large" />
</div>
) : (
<MarkdownEditor
content={editedContent}
onChange={onContentChange}
onWikiLinkClick={onWikiLinkClick}
/>
)}
{referenceDocuments.length > 0 && (
<div className="mt-8">
<h3 className="text-sm font-medium text-text-secondary mb-3">
{t('knowledgeDetails.sourceDocuments')}
</h3>
<ReferenceDocumentList list={referenceDocuments} />
</div>
)}
</div>
</ResizablePanel>
{isVersionView && commitDetail && (
<>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={30} minSize={20}>
<WikiVersionDiffPanel
diff={commitDetail.diff}
title={commitDetail.comments || commitDetail.title}
/>
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
</div>
);
}

View File

@@ -0,0 +1,68 @@
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import type { IArtifact, IWikiCommit } from '@/interfaces/database/dataset';
import { MoveLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
type WikiDetailHeaderProps = {
title: string | undefined;
displayedArtifact: IArtifact | null | undefined;
commitDetail: IWikiCommit | null | undefined;
isVersionView: boolean;
toolbar: ReactNode;
canGoBack: boolean;
previousEntryTitle: string | undefined;
linkNavLoading: boolean;
onBack: () => void;
};
export function WikiDetailHeader({
title,
displayedArtifact,
commitDetail,
isVersionView,
toolbar,
canGoBack,
previousEntryTitle,
linkNavLoading,
onBack,
}: WikiDetailHeaderProps) {
const { t } = useTranslation();
return (
<header className="shrink-0 px-5 pt-2 pb-4">
{canGoBack && (
<div className="shrink-0 pt-2 pb-2">
<Button
variant="ghost"
size="sm"
onClick={onBack}
disabled={linkNavLoading}
className="p-0 bg-transparent hover:bg-transparent text-text-secondary"
>
<MoveLeft className="size-4 mr-1" />
{previousEntryTitle ?? t('common.back')}
</Button>
</div>
)}
<div className="flex items-start justify-between">
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-semibold text-text-primary">
{title ?? displayedArtifact?.title}
</h1>
<div className="flex items-center gap-2">
{isVersionView && commitDetail && (
<span className="text-sm text-accent-primary bg-accent-primary-5 px-2 py-0.5 rounded">
{commitDetail.title}
</span>
)}
</div>
</div>
{toolbar}
</div>
</header>
);
}

View File

@@ -0,0 +1,75 @@
import type { IArtifact, IWikiCommit } from '@/interfaces/database/dataset';
import { Download } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { VersionHistorySheet } from './version-history-sheet';
type WikiDetailToolbarProps = {
isDirty: boolean;
selectedArtifact: IArtifact | null;
selectedVersion: IWikiCommit | null;
onCancelEdit: () => void;
onCommitClick: () => void;
onExport: () => void;
onSelectVersion: (version: IWikiCommit | null) => void;
};
export function WikiDetailToolbar({
isDirty,
selectedArtifact,
selectedVersion,
onCancelEdit,
onCommitClick,
onExport,
onSelectVersion,
}: WikiDetailToolbarProps) {
const { t } = useTranslation();
if (isDirty) {
return (
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={onCancelEdit}
>
{t('common.cancel')}
</Button>
<Button type="button" size="sm" onClick={onCommitClick}>
{t('knowledgeDetails.commit')}
</Button>
</div>
);
}
return (
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={onExport}
>
<Download className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('knowledgeDetails.export')}</TooltipContent>
</Tooltip>
<VersionHistorySheet
selectedArtifact={selectedArtifact}
selectedVersion={selectedVersion}
onSelectVersion={onSelectVersion}
/>
</div>
);
}