mirror of
https://github.com/lordmathis/lemma.git
synced 2025-11-05 23:44:22 +00:00
Migrate edito components
This commit is contained in:
@@ -2,10 +2,21 @@ import React from 'react';
|
|||||||
import { Text, Center } from '@mantine/core';
|
import { Text, Center } from '@mantine/core';
|
||||||
import Editor from './Editor';
|
import Editor from './Editor';
|
||||||
import MarkdownPreview from './MarkdownPreview';
|
import MarkdownPreview from './MarkdownPreview';
|
||||||
import { getFileUrl } from '../../api/git';
|
import { getFileUrl, isImageFile } from '../../utils/fileHelpers';
|
||||||
import { isImageFile } from '../../utils/fileHelpers';
|
import { useWorkspace } from '@/contexts/WorkspaceContext';
|
||||||
|
|
||||||
const ContentView = ({
|
type ViewTab = 'source' | 'preview';
|
||||||
|
|
||||||
|
interface ContentViewProps {
|
||||||
|
activeTab: ViewTab;
|
||||||
|
selectedFile: string | null;
|
||||||
|
content: string;
|
||||||
|
handleContentChange: (content: string) => void;
|
||||||
|
handleSave: (filePath: string, content: string) => Promise<boolean>;
|
||||||
|
handleFileSelect: (filePath: string | null) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ContentView: React.FC<ContentViewProps> = ({
|
||||||
activeTab,
|
activeTab,
|
||||||
selectedFile,
|
selectedFile,
|
||||||
content,
|
content,
|
||||||
@@ -13,10 +24,21 @@ const ContentView = ({
|
|||||||
handleSave,
|
handleSave,
|
||||||
handleFileSelect,
|
handleFileSelect,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
if (!currentWorkspace) {
|
||||||
|
return (
|
||||||
|
<Center style={{ height: '100%' }}>
|
||||||
|
<Text size="xl" fw={500}>
|
||||||
|
No workspace selected.
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!selectedFile) {
|
if (!selectedFile) {
|
||||||
return (
|
return (
|
||||||
<Center style={{ height: '100%' }}>
|
<Center style={{ height: '100%' }}>
|
||||||
<Text size="xl" weight={500}>
|
<Text size="xl" fw={500}>
|
||||||
No file selected.
|
No file selected.
|
||||||
</Text>
|
</Text>
|
||||||
</Center>
|
</Center>
|
||||||
@@ -27,7 +49,7 @@ const ContentView = ({
|
|||||||
return (
|
return (
|
||||||
<Center className="image-preview">
|
<Center className="image-preview">
|
||||||
<img
|
<img
|
||||||
src={getFileUrl(selectedFile)}
|
src={getFileUrl(currentWorkspace.name, selectedFile)}
|
||||||
alt={selectedFile}
|
alt={selectedFile}
|
||||||
style={{
|
style={{
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
@@ -7,13 +7,25 @@ import { defaultKeymap } from '@codemirror/commands';
|
|||||||
import { oneDark } from '@codemirror/theme-one-dark';
|
import { oneDark } from '@codemirror/theme-one-dark';
|
||||||
import { useWorkspace } from '../../contexts/WorkspaceContext';
|
import { useWorkspace } from '../../contexts/WorkspaceContext';
|
||||||
|
|
||||||
const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => {
|
interface EditorProps {
|
||||||
|
content: string;
|
||||||
|
handleContentChange: (content: string) => void;
|
||||||
|
handleSave: (filePath: string, content: string) => Promise<boolean>;
|
||||||
|
selectedFile: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Editor: React.FC<EditorProps> = ({
|
||||||
|
content,
|
||||||
|
handleContentChange,
|
||||||
|
handleSave,
|
||||||
|
selectedFile,
|
||||||
|
}) => {
|
||||||
const { colorScheme } = useWorkspace();
|
const { colorScheme } = useWorkspace();
|
||||||
const editorRef = useRef();
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
const viewRef = useRef();
|
const viewRef = useRef<EditorView | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleEditorSave = (view) => {
|
const handleEditorSave = (view: EditorView): boolean => {
|
||||||
handleSave(selectedFile, view.state.doc.toString());
|
handleSave(selectedFile, view.state.doc.toString());
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
@@ -36,6 +48,8 @@ const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!editorRef.current) return;
|
||||||
|
|
||||||
const state = EditorState.create({
|
const state = EditorState.create({
|
||||||
doc: content,
|
doc: content,
|
||||||
extensions: [
|
extensions: [
|
||||||
@@ -69,8 +83,9 @@ const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
view.destroy();
|
view.destroy();
|
||||||
|
viewRef.current = null;
|
||||||
};
|
};
|
||||||
}, [colorScheme, handleContentChange]);
|
}, [colorScheme, handleContentChange, handleSave, selectedFile]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (viewRef.current && content !== viewRef.current.state.doc.toString()) {
|
if (viewRef.current && content !== viewRef.current.state.doc.toString()) {
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
|
||||||
import { unified } from 'unified';
|
|
||||||
import remarkParse from 'remark-parse';
|
|
||||||
import remarkMath from 'remark-math';
|
|
||||||
import remarkRehype from 'remark-rehype';
|
|
||||||
import rehypeMathjax from 'rehype-mathjax';
|
|
||||||
import rehypeReact from 'rehype-react';
|
|
||||||
import rehypePrism from 'rehype-prism';
|
|
||||||
import * as prod from 'react/jsx-runtime';
|
|
||||||
import { notifications } from '@mantine/notifications';
|
|
||||||
import { remarkWikiLinks } from '../../utils/remarkWikiLinks';
|
|
||||||
import { useWorkspace } from '../../contexts/WorkspaceContext';
|
|
||||||
|
|
||||||
const MarkdownPreview = ({ content, handleFileSelect }) => {
|
|
||||||
const [processedContent, setProcessedContent] = useState(null);
|
|
||||||
const baseUrl = window.API_BASE_URL;
|
|
||||||
const { currentWorkspace } = useWorkspace();
|
|
||||||
|
|
||||||
const handleLinkClick = (e, href) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (href.startsWith(`${baseUrl}/internal/`)) {
|
|
||||||
// For existing files, extract the path and directly select it
|
|
||||||
const [filePath] = decodeURIComponent(
|
|
||||||
href.replace(`${baseUrl}/internal/`, '')
|
|
||||||
).split('#');
|
|
||||||
handleFileSelect(filePath);
|
|
||||||
} else if (href.startsWith(`${baseUrl}/notfound/`)) {
|
|
||||||
// For non-existent files, show a notification
|
|
||||||
const fileName = decodeURIComponent(
|
|
||||||
href.replace(`${baseUrl}/notfound/`, '')
|
|
||||||
);
|
|
||||||
notifications.show({
|
|
||||||
title: 'File Not Found',
|
|
||||||
message: `The file "${fileName}" does not exist.`,
|
|
||||||
color: 'red',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const processor = useMemo(
|
|
||||||
() =>
|
|
||||||
unified()
|
|
||||||
.use(remarkParse)
|
|
||||||
.use(remarkWikiLinks, currentWorkspace?.name)
|
|
||||||
.use(remarkMath)
|
|
||||||
.use(remarkRehype)
|
|
||||||
.use(rehypeMathjax)
|
|
||||||
.use(rehypePrism)
|
|
||||||
.use(rehypeReact, {
|
|
||||||
production: true,
|
|
||||||
jsx: prod.jsx,
|
|
||||||
jsxs: prod.jsxs,
|
|
||||||
Fragment: prod.Fragment,
|
|
||||||
components: {
|
|
||||||
img: ({ src, alt, ...props }) => (
|
|
||||||
<img
|
|
||||||
src={src}
|
|
||||||
alt={alt}
|
|
||||||
onError={(event) => {
|
|
||||||
console.error('Failed to load image:', event.target.src);
|
|
||||||
event.target.alt = 'Failed to load image';
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
a: ({ href, children, ...props }) => (
|
|
||||||
<a
|
|
||||||
href={href}
|
|
||||||
onClick={(e) => handleLinkClick(e, href)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</a>
|
|
||||||
),
|
|
||||||
code: ({ children, className, ...props }) => {
|
|
||||||
const language = className
|
|
||||||
? className.replace('language-', '')
|
|
||||||
: null;
|
|
||||||
return (
|
|
||||||
<pre className={className}>
|
|
||||||
<code {...props}>{children}</code>
|
|
||||||
</pre>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[baseUrl, handleFileSelect, currentWorkspace?.name]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const processContent = async () => {
|
|
||||||
if (!currentWorkspace) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await processor.process(content);
|
|
||||||
setProcessedContent(result.result);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error processing markdown:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
processContent();
|
|
||||||
}, [content, processor, currentWorkspace]);
|
|
||||||
|
|
||||||
return <div className="markdown-preview">{processedContent}</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MarkdownPreview;
|
|
||||||
141
app/src/components/editor/MarkdownPreview.tsx
Normal file
141
app/src/components/editor/MarkdownPreview.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import React, { useState, useEffect, useMemo, ReactNode } from 'react';
|
||||||
|
import { unified } from 'unified';
|
||||||
|
import remarkParse from 'remark-parse';
|
||||||
|
import remarkMath from 'remark-math';
|
||||||
|
import remarkRehype from 'remark-rehype';
|
||||||
|
import rehypeMathjax from 'rehype-mathjax';
|
||||||
|
import rehypeReact from 'rehype-react';
|
||||||
|
import rehypePrism from 'rehype-prism';
|
||||||
|
import * as prod from 'react/jsx-runtime';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import { remarkWikiLinks } from '../../utils/remarkWikiLinks';
|
||||||
|
import { useWorkspace } from '../../contexts/WorkspaceContext';
|
||||||
|
|
||||||
|
interface MarkdownPreviewProps {
|
||||||
|
content: string;
|
||||||
|
handleFileSelect: (filePath: string | null) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MarkdownImageProps {
|
||||||
|
src: string;
|
||||||
|
alt?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MarkdownLinkProps {
|
||||||
|
href: string;
|
||||||
|
children: ReactNode;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MarkdownCodeProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MarkdownPreview: React.FC<MarkdownPreviewProps> = ({
|
||||||
|
content,
|
||||||
|
handleFileSelect,
|
||||||
|
}) => {
|
||||||
|
const [processedContent, setProcessedContent] = useState<ReactNode | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const baseUrl = window.API_BASE_URL;
|
||||||
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
|
||||||
|
const handleLinkClick = (
|
||||||
|
e: React.MouseEvent<HTMLAnchorElement>,
|
||||||
|
href: string
|
||||||
|
): void => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (href.startsWith(`${baseUrl}/internal/`)) {
|
||||||
|
// For existing files, extract the path and directly select it
|
||||||
|
const [filePath] = decodeURIComponent(
|
||||||
|
href.replace(`${baseUrl}/internal/`, '')
|
||||||
|
).split('#');
|
||||||
|
handleFileSelect(filePath);
|
||||||
|
} else if (href.startsWith(`${baseUrl}/notfound/`)) {
|
||||||
|
// For non-existent files, show a notification
|
||||||
|
const fileName = decodeURIComponent(
|
||||||
|
href.replace(`${baseUrl}/notfound/`, '')
|
||||||
|
);
|
||||||
|
notifications.show({
|
||||||
|
title: 'File Not Found',
|
||||||
|
message: `The file "${fileName}" does not exist.`,
|
||||||
|
color: 'red',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const processor = useMemo(() => {
|
||||||
|
// Only create the processor if we have a workspace name
|
||||||
|
if (!currentWorkspace?.name) {
|
||||||
|
return unified();
|
||||||
|
}
|
||||||
|
|
||||||
|
return unified()
|
||||||
|
.use(remarkParse)
|
||||||
|
.use(remarkWikiLinks, currentWorkspace.name) // Now we know this is defined
|
||||||
|
.use(remarkMath)
|
||||||
|
.use(remarkRehype)
|
||||||
|
.use(rehypeMathjax)
|
||||||
|
.use(rehypePrism)
|
||||||
|
.use(rehypeReact as any, {
|
||||||
|
production: true,
|
||||||
|
jsx: prod.jsx,
|
||||||
|
jsxs: prod.jsxs,
|
||||||
|
Fragment: prod.Fragment,
|
||||||
|
components: {
|
||||||
|
img: ({ src, alt, ...props }: MarkdownImageProps) => (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt || ''}
|
||||||
|
onError={(event) => {
|
||||||
|
console.error('Failed to load image:', event.currentTarget.src);
|
||||||
|
event.currentTarget.alt = 'Failed to load image';
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
a: ({ href, children, ...props }: MarkdownLinkProps) => (
|
||||||
|
<a href={href} onClick={(e) => handleLinkClick(e, href)} {...props}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
code: ({ children, className, ...props }: MarkdownCodeProps) => {
|
||||||
|
const language = className
|
||||||
|
? className.replace('language-', '')
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<pre className={className}>
|
||||||
|
<code {...props}>{children}</code>
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [baseUrl, handleFileSelect, currentWorkspace?.name]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const processContent = async (): Promise<void> => {
|
||||||
|
if (!currentWorkspace) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await processor.process(content);
|
||||||
|
setProcessedContent(result.result as ReactNode);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error processing markdown:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
processContent();
|
||||||
|
}, [content, processor, currentWorkspace]);
|
||||||
|
|
||||||
|
return <div className="markdown-preview">{processedContent}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MarkdownPreview;
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { API_BASE_URL } from '@/types/authApi';
|
||||||
import { IMAGE_EXTENSIONS } from '../types/file';
|
import { IMAGE_EXTENSIONS } from '../types/file';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -8,3 +9,9 @@ import { IMAGE_EXTENSIONS } from '../types/file';
|
|||||||
export const isImageFile = (filePath: string): boolean => {
|
export const isImageFile = (filePath: string): boolean => {
|
||||||
return IMAGE_EXTENSIONS.some((ext) => filePath.toLowerCase().endsWith(ext));
|
return IMAGE_EXTENSIONS.some((ext) => filePath.toLowerCase().endsWith(ext));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getFileUrl = (workspaceName: string, filePath: string) => {
|
||||||
|
return `${API_BASE_URL}/workspaces/${encodeURIComponent(
|
||||||
|
workspaceName
|
||||||
|
)}/files/${encodeURIComponent(filePath)}`;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user