mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-30 12:39:52 +08:00
feat: Add Monaco editor support
This commit is contained in:
12
web/bun.lock
12
web/bun.lock
@@ -6,10 +6,12 @@
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "5.x",
|
||||
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@uiw/react-md-editor": "^4.0.8",
|
||||
"antd": "^5.27.0",
|
||||
"artplayer": "^5.2.5",
|
||||
"date-fns": "^4.1.0",
|
||||
"monaco-editor": "^0.53.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -179,6 +181,10 @@
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.30", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q=="],
|
||||
|
||||
"@monaco-editor/loader": ["@monaco-editor/loader@1.5.0", "", { "dependencies": { "state-local": "^1.0.6" } }, "sha512-hKoGSM+7aAc7eRTRjpqAZucPmoNOC4UUbknb/VNoTkEIkCPhqV8LfbsgM1webRM7S/z21eHEx9Fkwx8Z/C/+Xw=="],
|
||||
|
||||
"@monaco-editor/react": ["@monaco-editor/react@4.7.0", "", { "dependencies": { "@monaco-editor/loader": "^1.5.0" }, "peerDependencies": { "monaco-editor": ">= 0.25.0 < 1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA=="],
|
||||
|
||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||
|
||||
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
||||
@@ -273,6 +279,8 @@
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.1.7", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw=="],
|
||||
|
||||
"@types/trusted-types": ["@types/trusted-types@1.0.6", "", {}, "sha512-230RC8sFeHoT6sSUlRO6a8cAnclO06eeiq1QDfiv2FGCLWFvvERWgwIQD4FWqD9A69BN7Lzee4OXwoMVnnsWDw=="],
|
||||
|
||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.39.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.39.1", "@typescript-eslint/type-utils": "8.39.1", "@typescript-eslint/utils": "8.39.1", "@typescript-eslint/visitor-keys": "8.39.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.39.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-yYegZ5n3Yr6eOcqgj2nJH8cH/ZZgF+l0YIdKILSDjYFRjgYQMgv/lRjV5Z7Up04b9VYUondt8EPMqg7kTWgJ2g=="],
|
||||
@@ -641,6 +649,8 @@
|
||||
|
||||
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
||||
|
||||
"monaco-editor": ["monaco-editor@0.53.0", "", { "dependencies": { "@types/trusted-types": "^1.0.6" } }, "sha512-0WNThgC6CMWNXXBxTbaYYcunj08iB5rnx4/G56UOPeL9UVIUGGHA1GR0EWIh9Ebabj7NpCRawQ5b0hfN1jQmYQ=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
@@ -823,6 +833,8 @@
|
||||
|
||||
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
|
||||
|
||||
"state-local": ["state-local@1.0.7", "", {}, "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="],
|
||||
|
||||
"string-convert": ["string-convert@0.2.1", "", {}, "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A=="],
|
||||
|
||||
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
||||
|
||||
@@ -12,10 +12,12 @@
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "5.x",
|
||||
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@uiw/react-md-editor": "^4.0.8",
|
||||
"antd": "^5.27.0",
|
||||
"artplayer": "^5.2.5",
|
||||
"date-fns": "^4.1.0",
|
||||
"monaco-editor": "^0.53.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
||||
@@ -38,7 +38,11 @@ export const vfsApi = {
|
||||
});
|
||||
return request<DirListing>(`/fs/${encodeURI(trimmed)}?${params}`);
|
||||
},
|
||||
readFile: (path: string) => request<ArrayBuffer>(`/fs/file/${encodeURI(path.replace(/^\/+/, ''))}`),
|
||||
readFile: async (path: string) => {
|
||||
const enc = encodeURI(path.replace(/^\/+/, ''));
|
||||
const resp = await request(`/fs/file/${enc}`, { rawResponse: true });
|
||||
return await (resp as Response).arrayBuffer();
|
||||
},
|
||||
uploadFile: (fullPath: string, file: File | Blob) => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { Layout, Spin, Button, Space, message } from 'antd';
|
||||
import MDEditor from '@uiw/react-md-editor';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import type { AppComponentProps } from '../types';
|
||||
import { vfsApi } from '../../api/vfs';
|
||||
import request from '../../api/client';
|
||||
|
||||
const { Header, Content } = Layout;
|
||||
|
||||
@@ -11,20 +13,66 @@ export const TextEditorApp: React.FC<AppComponentProps> = ({ filePath, entry, on
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [content, setContent] = useState('');
|
||||
const [initialContent, setInitialContent] = useState('');
|
||||
const [truncated, setTruncated] = useState(false);
|
||||
const MAX_PREVIEW_BYTES = 1024 * 1024; // 1MB
|
||||
const isDirty = content !== initialContent;
|
||||
|
||||
// 使用 ref 来持有最新的 onRequestClose 函数,避免它成为 effect 的依赖项
|
||||
const onRequestCloseRef = useRef(onRequestClose);
|
||||
onRequestCloseRef.current = onRequestClose;
|
||||
|
||||
const ext = useMemo(() => entry.name.split('.').pop()?.toLowerCase() || '', [entry.name]);
|
||||
const isMarkdown = ext === 'md' || ext === 'markdown';
|
||||
const monacoLanguage = useMemo(() => {
|
||||
switch (ext) {
|
||||
case 'json':
|
||||
return 'json';
|
||||
case 'js':
|
||||
return 'javascript';
|
||||
case 'ts':
|
||||
return 'typescript';
|
||||
case 'html':
|
||||
return 'html';
|
||||
case 'css':
|
||||
return 'css';
|
||||
case 'py':
|
||||
return 'python';
|
||||
case 'sh':
|
||||
return 'shell';
|
||||
case 'yaml':
|
||||
case 'yml':
|
||||
return 'yaml';
|
||||
case 'xml':
|
||||
return 'xml';
|
||||
case 'txt':
|
||||
case 'log':
|
||||
default:
|
||||
return 'plaintext';
|
||||
}
|
||||
}, [ext]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadFile = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await vfsApi.readFile(filePath);
|
||||
const text = typeof data === 'string' ? data : new TextDecoder().decode(data);
|
||||
setContent(text);
|
||||
setInitialContent(text);
|
||||
setTruncated(false);
|
||||
const shouldTruncate = (entry.size ?? 0) > MAX_PREVIEW_BYTES;
|
||||
if (shouldTruncate) {
|
||||
const enc = encodeURI(filePath.replace(/^\/+/, ''));
|
||||
const resp = await request(`/fs/file/${enc}`, {
|
||||
method: 'GET',
|
||||
headers: { Range: `bytes=0-${MAX_PREVIEW_BYTES - 1}` },
|
||||
rawResponse: true,
|
||||
});
|
||||
const buf = await (resp as Response).arrayBuffer();
|
||||
const text = new TextDecoder().decode(buf);
|
||||
setContent(text);
|
||||
setInitialContent(text);
|
||||
setTruncated(true);
|
||||
} else {
|
||||
const data = await vfsApi.readFile(filePath);
|
||||
const text = typeof data === 'string' ? data : new TextDecoder().decode(data);
|
||||
setContent(text);
|
||||
setInitialContent(text);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(`加载文件失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
onRequestCloseRef.current();
|
||||
@@ -33,9 +81,12 @@ export const TextEditorApp: React.FC<AppComponentProps> = ({ filePath, entry, on
|
||||
}
|
||||
};
|
||||
loadFile();
|
||||
}, [filePath]); // effect 只依赖 filePath,因此只在文件路径变化时执行一次
|
||||
|
||||
}, [filePath, entry.size]);
|
||||
const handleSave = useCallback(async () => {
|
||||
if (truncated) {
|
||||
message.warning('大文件仅预览前 1MB,已禁用保存');
|
||||
return;
|
||||
}
|
||||
if (!isDirty) return;
|
||||
try {
|
||||
setSaving(true);
|
||||
@@ -48,7 +99,7 @@ export const TextEditorApp: React.FC<AppComponentProps> = ({ filePath, entry, on
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [content, filePath, isDirty]);
|
||||
}, [content, filePath, isDirty, truncated]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -77,10 +128,10 @@ export const TextEditorApp: React.FC<AppComponentProps> = ({ filePath, entry, on
|
||||
}}
|
||||
>
|
||||
<span style={{ color: 'var(--ant-color-text, rgba(0,0,0,0.88))' }}>
|
||||
{entry.name} {isDirty && '*'}
|
||||
{entry.name} {isDirty && '*'} {truncated && '(大文件仅预览前 1MB,编辑与保存已禁用)'}
|
||||
</span>
|
||||
<Space>
|
||||
<Button type="primary" size="small" onClick={handleSave} loading={saving} disabled={!isDirty}>
|
||||
<Button type="primary" size="small" onClick={handleSave} loading={saving} disabled={!isDirty || truncated}>
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
@@ -91,12 +142,28 @@ export const TextEditorApp: React.FC<AppComponentProps> = ({ filePath, entry, on
|
||||
<Spin />
|
||||
</div>
|
||||
) : (
|
||||
<MDEditor
|
||||
value={content}
|
||||
onChange={(val) => setContent(val || '')}
|
||||
height="100%"
|
||||
preview="live"
|
||||
/>
|
||||
isMarkdown ? (
|
||||
<MDEditor
|
||||
value={content}
|
||||
onChange={(val) => setContent(val || '')}
|
||||
height="100%"
|
||||
preview={truncated ? 'preview' : 'live'}
|
||||
/>
|
||||
) : (
|
||||
<Editor
|
||||
value={content}
|
||||
onChange={(val) => setContent(val || '')}
|
||||
height="100%"
|
||||
language={monacoLanguage}
|
||||
options={{
|
||||
readOnly: truncated,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
fontSize: 13,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Content>
|
||||
</Layout>
|
||||
|
||||
Reference in New Issue
Block a user