mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-02 02:38:38 +00:00
Refactor Settings, FileTree, GitPanel, Shell, and CodeEditor components (#402)
This commit is contained in:
234
src/components/code-editor/view/CodeEditor.tsx
Normal file
234
src/components/code-editor/view/CodeEditor.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { unifiedMergeView } from '@codemirror/merge';
|
||||
import type { Extension } from '@codemirror/state';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCodeEditorDocument } from '../hooks/useCodeEditorDocument';
|
||||
import { useCodeEditorSettings } from '../hooks/useCodeEditorSettings';
|
||||
import { useEditorKeyboardShortcuts } from '../hooks/useEditorKeyboardShortcuts';
|
||||
import type { CodeEditorFile } from '../types/types';
|
||||
import { createMinimapExtension, createScrollToFirstChunkExtension, getLanguageExtensions } from '../utils/editorExtensions';
|
||||
import { getEditorStyles } from '../utils/editorStyles';
|
||||
import { createEditorToolbarPanelExtension } from '../utils/editorToolbarPanel';
|
||||
import CodeEditorFooter from './subcomponents/CodeEditorFooter';
|
||||
import CodeEditorHeader from './subcomponents/CodeEditorHeader';
|
||||
import CodeEditorLoadingState from './subcomponents/CodeEditorLoadingState';
|
||||
import CodeEditorSurface from './subcomponents/CodeEditorSurface';
|
||||
|
||||
type CodeEditorProps = {
|
||||
file: CodeEditorFile;
|
||||
onClose: () => void;
|
||||
projectPath?: string;
|
||||
isSidebar?: boolean;
|
||||
isExpanded?: boolean;
|
||||
onToggleExpand?: (() => void) | null;
|
||||
onPopOut?: (() => void) | null;
|
||||
};
|
||||
|
||||
export default function CodeEditor({
|
||||
file,
|
||||
onClose,
|
||||
projectPath,
|
||||
isSidebar = false,
|
||||
isExpanded = false,
|
||||
onToggleExpand = null,
|
||||
onPopOut = null,
|
||||
}: CodeEditorProps) {
|
||||
const { t } = useTranslation('codeEditor');
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [showDiff, setShowDiff] = useState(Boolean(file.diffInfo));
|
||||
const [markdownPreview, setMarkdownPreview] = useState(false);
|
||||
|
||||
const {
|
||||
isDarkMode,
|
||||
wordWrap,
|
||||
minimapEnabled,
|
||||
showLineNumbers,
|
||||
fontSize,
|
||||
} = useCodeEditorSettings();
|
||||
|
||||
const {
|
||||
content,
|
||||
setContent,
|
||||
loading,
|
||||
saving,
|
||||
saveSuccess,
|
||||
saveError,
|
||||
handleSave,
|
||||
handleDownload,
|
||||
} = useCodeEditorDocument({
|
||||
file,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
const isMarkdownFile = useMemo(() => {
|
||||
const extension = file.name.split('.').pop()?.toLowerCase();
|
||||
return extension === 'md' || extension === 'markdown';
|
||||
}, [file.name]);
|
||||
|
||||
const minimapExtension = useMemo(
|
||||
() => (
|
||||
createMinimapExtension({
|
||||
file,
|
||||
showDiff,
|
||||
minimapEnabled,
|
||||
isDarkMode,
|
||||
})
|
||||
),
|
||||
[file, isDarkMode, minimapEnabled, showDiff],
|
||||
);
|
||||
|
||||
const scrollToFirstChunkExtension = useMemo(
|
||||
() => createScrollToFirstChunkExtension({ file, showDiff }),
|
||||
[file, showDiff],
|
||||
);
|
||||
|
||||
const toolbarPanelExtension = useMemo(
|
||||
() => (
|
||||
createEditorToolbarPanelExtension({
|
||||
file,
|
||||
showDiff,
|
||||
isSidebar,
|
||||
isExpanded,
|
||||
onToggleDiff: () => setShowDiff((previous) => !previous),
|
||||
onPopOut,
|
||||
onToggleExpand,
|
||||
labels: {
|
||||
changes: t('toolbar.changes'),
|
||||
previousChange: t('toolbar.previousChange'),
|
||||
nextChange: t('toolbar.nextChange'),
|
||||
hideDiff: t('toolbar.hideDiff'),
|
||||
showDiff: t('toolbar.showDiff'),
|
||||
collapse: t('toolbar.collapse'),
|
||||
expand: t('toolbar.expand'),
|
||||
},
|
||||
})
|
||||
),
|
||||
[file, isExpanded, isSidebar, onPopOut, onToggleExpand, showDiff, t],
|
||||
);
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
const allExtensions: Extension[] = [
|
||||
...getLanguageExtensions(file.name),
|
||||
...toolbarPanelExtension,
|
||||
];
|
||||
|
||||
if (file.diffInfo && showDiff && file.diffInfo.old_string !== undefined) {
|
||||
allExtensions.push(
|
||||
unifiedMergeView({
|
||||
original: file.diffInfo.old_string,
|
||||
mergeControls: false,
|
||||
highlightChanges: true,
|
||||
syntaxHighlightDeletions: false,
|
||||
gutter: true,
|
||||
}),
|
||||
);
|
||||
allExtensions.push(...minimapExtension);
|
||||
allExtensions.push(...scrollToFirstChunkExtension);
|
||||
}
|
||||
|
||||
if (wordWrap) {
|
||||
allExtensions.push(EditorView.lineWrapping);
|
||||
}
|
||||
|
||||
return allExtensions;
|
||||
}, [
|
||||
file.diffInfo,
|
||||
file.name,
|
||||
minimapExtension,
|
||||
scrollToFirstChunkExtension,
|
||||
showDiff,
|
||||
toolbarPanelExtension,
|
||||
wordWrap,
|
||||
]);
|
||||
|
||||
useEditorKeyboardShortcuts({
|
||||
onSave: handleSave,
|
||||
onClose,
|
||||
dependency: content,
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<CodeEditorLoadingState
|
||||
isDarkMode={isDarkMode}
|
||||
isSidebar={isSidebar}
|
||||
loadingText={t('loading', { fileName: file.name })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const outerContainerClassName = isSidebar
|
||||
? 'w-full h-full flex flex-col'
|
||||
: `fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center md:p-4 ${isFullscreen ? 'md:p-0' : ''}`;
|
||||
|
||||
const innerContainerClassName = isSidebar
|
||||
? 'bg-background flex flex-col w-full h-full'
|
||||
: `bg-background shadow-2xl flex flex-col w-full h-full md:rounded-lg md:shadow-2xl${
|
||||
isFullscreen ? ' md:w-full md:h-full md:rounded-none' : ' md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]'
|
||||
}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{getEditorStyles(isDarkMode)}</style>
|
||||
<div className={outerContainerClassName}>
|
||||
<div className={innerContainerClassName}>
|
||||
<CodeEditorHeader
|
||||
file={file}
|
||||
isSidebar={isSidebar}
|
||||
isFullscreen={isFullscreen}
|
||||
isMarkdownFile={isMarkdownFile}
|
||||
markdownPreview={markdownPreview}
|
||||
saving={saving}
|
||||
saveSuccess={saveSuccess}
|
||||
onToggleMarkdownPreview={() => setMarkdownPreview((previous) => !previous)}
|
||||
onOpenSettings={() => window.openSettings?.('appearance')}
|
||||
onDownload={handleDownload}
|
||||
onSave={handleSave}
|
||||
onToggleFullscreen={() => setIsFullscreen((previous) => !previous)}
|
||||
onClose={onClose}
|
||||
labels={{
|
||||
showingChanges: t('header.showingChanges'),
|
||||
editMarkdown: t('actions.editMarkdown'),
|
||||
previewMarkdown: t('actions.previewMarkdown'),
|
||||
settings: t('toolbar.settings'),
|
||||
download: t('actions.download'),
|
||||
save: t('actions.save'),
|
||||
saving: t('actions.saving'),
|
||||
saved: t('actions.saved'),
|
||||
fullscreen: t('actions.fullscreen'),
|
||||
exitFullscreen: t('actions.exitFullscreen'),
|
||||
close: t('actions.close'),
|
||||
}}
|
||||
/>
|
||||
|
||||
{saveError && (
|
||||
<div className="px-3 py-1.5 text-xs text-red-700 bg-red-50 border-b border-red-200 dark:bg-red-900/20 dark:text-red-300 dark:border-red-900/40">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<CodeEditorSurface
|
||||
content={content}
|
||||
onChange={setContent}
|
||||
markdownPreview={markdownPreview}
|
||||
isMarkdownFile={isMarkdownFile}
|
||||
isDarkMode={isDarkMode}
|
||||
fontSize={fontSize}
|
||||
showLineNumbers={showLineNumbers}
|
||||
extensions={extensions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CodeEditorFooter
|
||||
content={content}
|
||||
linesLabel={t('footer.lines')}
|
||||
charactersLabel={t('footer.characters')}
|
||||
shortcutsLabel={t('footer.shortcuts')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
85
src/components/code-editor/view/EditorSidebar.tsx
Normal file
85
src/components/code-editor/view/EditorSidebar.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useState } from 'react';
|
||||
import type { MouseEvent, MutableRefObject } from 'react';
|
||||
import type { CodeEditorFile } from '../types/types';
|
||||
import CodeEditor from './CodeEditor';
|
||||
|
||||
type EditorSidebarProps = {
|
||||
editingFile: CodeEditorFile | null;
|
||||
isMobile: boolean;
|
||||
editorExpanded: boolean;
|
||||
editorWidth: number;
|
||||
hasManualWidth: boolean;
|
||||
resizeHandleRef: MutableRefObject<HTMLDivElement | null>;
|
||||
onResizeStart: (event: MouseEvent<HTMLDivElement>) => void;
|
||||
onCloseEditor: () => void;
|
||||
onToggleEditorExpand: () => void;
|
||||
projectPath?: string;
|
||||
fillSpace?: boolean;
|
||||
};
|
||||
|
||||
export default function EditorSidebar({
|
||||
editingFile,
|
||||
isMobile,
|
||||
editorExpanded,
|
||||
editorWidth,
|
||||
hasManualWidth,
|
||||
resizeHandleRef,
|
||||
onResizeStart,
|
||||
onCloseEditor,
|
||||
onToggleEditorExpand,
|
||||
projectPath,
|
||||
fillSpace,
|
||||
}: EditorSidebarProps) {
|
||||
const [poppedOut, setPoppedOut] = useState(false);
|
||||
|
||||
if (!editingFile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isMobile || poppedOut) {
|
||||
return (
|
||||
<CodeEditor
|
||||
file={editingFile}
|
||||
onClose={() => {
|
||||
setPoppedOut(false);
|
||||
onCloseEditor();
|
||||
}}
|
||||
projectPath={projectPath}
|
||||
isSidebar={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// In files tab, fill the remaining width unless user has dragged manually.
|
||||
const useFlexLayout = editorExpanded || (fillSpace && !hasManualWidth);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!editorExpanded && (
|
||||
<div
|
||||
ref={resizeHandleRef}
|
||||
onMouseDown={onResizeStart}
|
||||
className="flex-shrink-0 w-1 bg-gray-200 dark:bg-gray-700 hover:bg-blue-500 dark:hover:bg-blue-600 cursor-col-resize transition-colors relative group"
|
||||
title="Drag to resize"
|
||||
>
|
||||
<div className="absolute inset-y-0 left-1/2 -translate-x-1/2 w-1 bg-blue-500 dark:bg-blue-600 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`flex-shrink-0 border-l border-gray-200 dark:border-gray-700 h-full overflow-hidden ${useFlexLayout ? 'flex-1' : ''}`}
|
||||
style={useFlexLayout ? undefined : { width: `${editorWidth}px` }}
|
||||
>
|
||||
<CodeEditor
|
||||
file={editingFile}
|
||||
onClose={onCloseEditor}
|
||||
projectPath={projectPath}
|
||||
isSidebar
|
||||
isExpanded={editorExpanded}
|
||||
onToggleExpand={onToggleEditorExpand}
|
||||
onPopOut={() => setPoppedOut(true)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
type CodeEditorFooterProps = {
|
||||
content: string;
|
||||
linesLabel: string;
|
||||
charactersLabel: string;
|
||||
shortcutsLabel: string;
|
||||
};
|
||||
|
||||
export default function CodeEditorFooter({
|
||||
content,
|
||||
linesLabel,
|
||||
charactersLabel,
|
||||
shortcutsLabel,
|
||||
}: CodeEditorFooterProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-t border-border bg-muted flex-shrink-0">
|
||||
<div className="flex items-center gap-3 text-xs text-gray-600 dark:text-gray-400">
|
||||
<span>
|
||||
{linesLabel} {content.split('\n').length}
|
||||
</span>
|
||||
<span>
|
||||
{charactersLabel} {content.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">{shortcutsLabel}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Code2, Download, Eye, Maximize2, Minimize2, Save, Settings as SettingsIcon, X } from 'lucide-react';
|
||||
import type { CodeEditorFile } from '../../types/types';
|
||||
|
||||
type CodeEditorHeaderProps = {
|
||||
file: CodeEditorFile;
|
||||
isSidebar: boolean;
|
||||
isFullscreen: boolean;
|
||||
isMarkdownFile: boolean;
|
||||
markdownPreview: boolean;
|
||||
saving: boolean;
|
||||
saveSuccess: boolean;
|
||||
onToggleMarkdownPreview: () => void;
|
||||
onOpenSettings: () => void;
|
||||
onDownload: () => void;
|
||||
onSave: () => void;
|
||||
onToggleFullscreen: () => void;
|
||||
onClose: () => void;
|
||||
labels: {
|
||||
showingChanges: string;
|
||||
editMarkdown: string;
|
||||
previewMarkdown: string;
|
||||
settings: string;
|
||||
download: string;
|
||||
save: string;
|
||||
saving: string;
|
||||
saved: string;
|
||||
fullscreen: string;
|
||||
exitFullscreen: string;
|
||||
close: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default function CodeEditorHeader({
|
||||
file,
|
||||
isSidebar,
|
||||
isFullscreen,
|
||||
isMarkdownFile,
|
||||
markdownPreview,
|
||||
saving,
|
||||
saveSuccess,
|
||||
onToggleMarkdownPreview,
|
||||
onOpenSettings,
|
||||
onDownload,
|
||||
onSave,
|
||||
onToggleFullscreen,
|
||||
onClose,
|
||||
labels,
|
||||
}: CodeEditorHeaderProps) {
|
||||
const saveTitle = saveSuccess ? labels.saved : saving ? labels.saving : labels.save;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border flex-shrink-0 min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white truncate">{file.name}</h3>
|
||||
{file.diffInfo && (
|
||||
<span className="text-[10px] bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-300 px-1.5 py-0.5 rounded whitespace-nowrap">
|
||||
{labels.showingChanges}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{file.path}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5 md:gap-1 flex-shrink-0">
|
||||
{isMarkdownFile && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleMarkdownPreview}
|
||||
className={`p-1.5 rounded-md min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center transition-colors ${
|
||||
markdownPreview
|
||||
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
title={markdownPreview ? labels.editMarkdown : labels.previewMarkdown}
|
||||
>
|
||||
{markdownPreview ? <Code2 className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSettings}
|
||||
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||
title={labels.settings}
|
||||
>
|
||||
<SettingsIcon className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDownload}
|
||||
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||
title={labels.download}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={saving}
|
||||
className={`p-1.5 rounded-md disabled:opacity-50 flex items-center justify-center transition-colors min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 ${
|
||||
saveSuccess
|
||||
? 'text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/30'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
title={saveTitle}
|
||||
>
|
||||
{saveSuccess ? (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<Save className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{!isSidebar && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleFullscreen}
|
||||
className="hidden md:flex p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 items-center justify-center"
|
||||
title={isFullscreen ? labels.exitFullscreen : labels.fullscreen}
|
||||
>
|
||||
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||
title={labels.close}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { getEditorLoadingStyles } from '../../utils/editorStyles';
|
||||
|
||||
type CodeEditorLoadingStateProps = {
|
||||
isDarkMode: boolean;
|
||||
isSidebar: boolean;
|
||||
loadingText: string;
|
||||
};
|
||||
|
||||
export default function CodeEditorLoadingState({
|
||||
isDarkMode,
|
||||
isSidebar,
|
||||
loadingText,
|
||||
}: CodeEditorLoadingStateProps) {
|
||||
return (
|
||||
<>
|
||||
<style>{getEditorLoadingStyles(isDarkMode)}</style>
|
||||
{isSidebar ? (
|
||||
<div className="w-full h-full flex items-center justify-center bg-background">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600" />
|
||||
<span className="text-gray-900 dark:text-white">{loadingText}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center">
|
||||
<div className="code-editor-loading w-full h-full md:rounded-lg md:w-auto md:h-auto p-8 flex items-center justify-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600" />
|
||||
<span className="text-gray-900 dark:text-white">{loadingText}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import type { Extension } from '@codemirror/state';
|
||||
import MarkdownPreview from './markdown/MarkdownPreview';
|
||||
|
||||
type CodeEditorSurfaceProps = {
|
||||
content: string;
|
||||
onChange: (value: string) => void;
|
||||
markdownPreview: boolean;
|
||||
isMarkdownFile: boolean;
|
||||
isDarkMode: boolean;
|
||||
fontSize: number;
|
||||
showLineNumbers: boolean;
|
||||
extensions: Extension[];
|
||||
};
|
||||
|
||||
export default function CodeEditorSurface({
|
||||
content,
|
||||
onChange,
|
||||
markdownPreview,
|
||||
isMarkdownFile,
|
||||
isDarkMode,
|
||||
fontSize,
|
||||
showLineNumbers,
|
||||
extensions,
|
||||
}: CodeEditorSurfaceProps) {
|
||||
if (markdownPreview && isMarkdownFile) {
|
||||
return (
|
||||
<div className="h-full overflow-y-auto bg-white dark:bg-gray-900">
|
||||
<div className="max-w-4xl mx-auto px-8 py-6 prose prose-sm dark:prose-invert prose-headings:font-semibold prose-a:text-blue-600 dark:prose-a:text-blue-400 prose-code:text-sm prose-pre:bg-gray-900 prose-img:rounded-lg max-w-none">
|
||||
<MarkdownPreview content={content} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CodeMirror
|
||||
value={content}
|
||||
onChange={onChange}
|
||||
extensions={extensions}
|
||||
theme={isDarkMode ? oneDark : undefined}
|
||||
height="100%"
|
||||
style={{
|
||||
fontSize: `${fontSize}px`,
|
||||
height: '100%',
|
||||
}}
|
||||
basicSetup={{
|
||||
lineNumbers: showLineNumbers,
|
||||
foldGutter: true,
|
||||
dropCursor: false,
|
||||
allowMultipleSelections: false,
|
||||
indentOnInput: true,
|
||||
bracketMatching: true,
|
||||
closeBrackets: true,
|
||||
autocompletion: true,
|
||||
highlightSelectionMatches: true,
|
||||
searchKeymap: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState } from 'react';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { oneDark as prismOneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { copyTextToClipboard } from '../../../../../utils/clipboard';
|
||||
|
||||
type MarkdownCodeBlockProps = {
|
||||
inline?: boolean;
|
||||
node?: unknown;
|
||||
} & ComponentProps<'code'>;
|
||||
|
||||
export default function MarkdownCodeBlock({
|
||||
inline,
|
||||
className,
|
||||
children,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownCodeBlockProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const rawContent = Array.isArray(children) ? children.join('') : String(children ?? '');
|
||||
const looksMultiline = /[\r\n]/.test(rawContent);
|
||||
const shouldRenderInline = inline || !looksMultiline;
|
||||
|
||||
if (shouldRenderInline) {
|
||||
return (
|
||||
<code
|
||||
className={`font-mono text-[0.9em] px-1.5 py-0.5 rounded-md bg-gray-100 text-gray-900 border border-gray-200 dark:bg-gray-800/60 dark:text-gray-100 dark:border-gray-700 whitespace-pre-wrap break-words ${className || ''}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
const languageMatch = /language-(\w+)/.exec(className || '');
|
||||
const language = languageMatch ? languageMatch[1] : 'text';
|
||||
|
||||
return (
|
||||
<div className="relative group my-2">
|
||||
{language !== 'text' && (
|
||||
<div className="absolute top-2 left-3 z-10 text-xs text-gray-400 font-medium uppercase">{language}</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copyTextToClipboard(rawContent).then((success) => {
|
||||
if (success) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
})}
|
||||
className="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity text-xs px-2 py-1 rounded-md bg-gray-700/80 hover:bg-gray-700 text-white border border-gray-600"
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={prismOneDark}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
borderRadius: '0.5rem',
|
||||
fontSize: '0.875rem',
|
||||
padding: language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem',
|
||||
}}
|
||||
>
|
||||
{rawContent}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { Components } from 'react-markdown';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkMath from 'remark-math';
|
||||
import MarkdownCodeBlock from './MarkdownCodeBlock';
|
||||
|
||||
type MarkdownPreviewProps = {
|
||||
content: string;
|
||||
};
|
||||
|
||||
const markdownPreviewComponents: Components = {
|
||||
code: MarkdownCodeBlock,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-gray-300 dark:border-gray-600 pl-4 italic text-gray-600 dark:text-gray-400 my-2">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} className="text-blue-600 dark:text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto my-2">
|
||||
<table className="min-w-full border-collapse border border-gray-200 dark:border-gray-700">{children}</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) => <thead className="bg-gray-50 dark:bg-gray-800">{children}</thead>,
|
||||
th: ({ children }) => (
|
||||
<th className="px-3 py-2 text-left text-sm font-semibold border border-gray-200 dark:border-gray-700">{children}</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="px-3 py-2 align-top text-sm border border-gray-200 dark:border-gray-700">{children}</td>
|
||||
),
|
||||
};
|
||||
|
||||
export default function MarkdownPreview({ content }: MarkdownPreviewProps) {
|
||||
const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []);
|
||||
const rehypePlugins = useMemo(() => [rehypeKatex], []);
|
||||
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={markdownPreviewComponents}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user