forked from bai/curriculum-project-hub
feat(filelib-web): 文件编辑改为模态框并集成 CodeMirror 语法高亮
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* CodeMirror 6 编辑器封装。自动根据文件扩展名选择语法高亮。
|
||||
* 只在浏览器 mount 后创建 EditorView(CodeMirror 依赖 DOM)。
|
||||
*/
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { EditorView, basicSetup } from "codemirror";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { markdown } from "@codemirror/lang-markdown";
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { html } from "@codemirror/lang-html";
|
||||
import { css } from "@codemirror/lang-css";
|
||||
import type { Extension } from "@codemirror/state";
|
||||
|
||||
let { value = "", readonly = false, filename = "", onchange }: {
|
||||
value?: string;
|
||||
readonly?: boolean;
|
||||
filename?: string;
|
||||
onchange?: (value: string) => void;
|
||||
} = $props();
|
||||
|
||||
let container = $state<HTMLDivElement | null>(null);
|
||||
let view: EditorView | null = null;
|
||||
|
||||
/** 根据文件名后缀选语言扩展 */
|
||||
function langExtension(name: string): Extension[] {
|
||||
const ext = name.split(".").pop()?.toLowerCase() ?? "";
|
||||
switch (ext) {
|
||||
case "md":
|
||||
case "markdown":
|
||||
return [markdown()];
|
||||
case "js":
|
||||
case "mjs":
|
||||
case "cjs":
|
||||
return [javascript()];
|
||||
case "ts":
|
||||
case "mts":
|
||||
case "cts":
|
||||
return [javascript({ typescript: true })];
|
||||
case "jsx":
|
||||
return [javascript({ jsx: true })];
|
||||
case "tsx":
|
||||
return [javascript({ jsx: true, typescript: true })];
|
||||
case "json":
|
||||
case "jsonc":
|
||||
return [json()];
|
||||
case "html":
|
||||
case "htm":
|
||||
case "svelte":
|
||||
case "vue":
|
||||
return [html()];
|
||||
case "css":
|
||||
case "scss":
|
||||
return [css()];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!container) return;
|
||||
const extensions: Extension[] = [
|
||||
basicSetup,
|
||||
...langExtension(filename),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
onchange?.(update.state.doc.toString());
|
||||
}
|
||||
}),
|
||||
];
|
||||
if (readonly) extensions.push(EditorState.readOnly.of(true));
|
||||
|
||||
view = new EditorView({
|
||||
state: EditorState.create({ doc: value, extensions }),
|
||||
parent: container,
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
view?.destroy();
|
||||
view = null;
|
||||
});
|
||||
|
||||
// 外部 value 变化时(如冲突载入最新),替换编辑器内容。
|
||||
$effect(() => {
|
||||
if (view && view.state.doc.toString() !== value) {
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: value },
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={container} class="code-editor-wrapper"></div>
|
||||
|
||||
<style>
|
||||
.code-editor-wrapper {
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.code-editor-wrapper :global(.cm-editor) {
|
||||
height: 100%;
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
}
|
||||
.code-editor-wrapper :global(.cm-editor.cm-focused) {
|
||||
outline: none;
|
||||
}
|
||||
.code-editor-wrapper :global(.cm-scroller) {
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +1,22 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* 文件编辑器(模态框形式)。打开后加载文件内容并使用 CodeMirror 编辑,
|
||||
* 支持语法高亮、版本冲突处理、历史查看与删除。
|
||||
*/
|
||||
import { api, ApiError } from "./api.js";
|
||||
import { toastOk, toastErr, toast } from "./stores.js";
|
||||
import type { FileContent, VersionInfo, Role } from "./types.js";
|
||||
import Modal from "./Modal.svelte";
|
||||
import Icon from "./Icon.svelte";
|
||||
import CodeEditor from "./CodeEditor.svelte";
|
||||
|
||||
let { projectId, path, role, onchanged, onclose }: { projectId: string; path: string; role: Role; onchanged: () => void; onclose?: () => void } = $props();
|
||||
let { projectId, path, role, onchanged, onclose }: {
|
||||
projectId: string;
|
||||
path: string;
|
||||
role: Role;
|
||||
onchanged: () => void;
|
||||
onclose: () => void;
|
||||
} = $props();
|
||||
|
||||
let file = $state<FileContent | null>(null);
|
||||
let draft = $state("");
|
||||
@@ -15,6 +26,7 @@
|
||||
let history = $state<VersionInfo[]>([]);
|
||||
|
||||
const canEdit = $derived(role !== "VIEW");
|
||||
const filename = $derived(path.split("/").pop() ?? "");
|
||||
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
@@ -81,6 +93,7 @@
|
||||
toastOk("已删除");
|
||||
file = null;
|
||||
onchanged();
|
||||
onclose();
|
||||
} catch (e) {
|
||||
toastErr(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
@@ -104,50 +117,67 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loadError}
|
||||
<div class="panel text-xs text-danger">{loadError}</div>
|
||||
{:else if file}
|
||||
<div class="panel">
|
||||
<div class="mb-2.5 flex items-center justify-between">
|
||||
<span class="file-meta">{file.path} @ {file.version}</span>
|
||||
<!-- 主编辑器模态框:宽屏 overlay -->
|
||||
<div
|
||||
class="fixed inset-0 z-40 flex items-center justify-center bg-black/30 p-4"
|
||||
role="presentation"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) onclose(); }}
|
||||
>
|
||||
<div class="flex h-[85vh] w-full max-w-[900px] flex-col rounded-2xl border border-line-soft bg-panel shadow-[0_4px_20px_rgba(26,26,24,.07)]">
|
||||
<!-- 顶栏 -->
|
||||
<div class="flex shrink-0 items-center justify-between border-b border-line-soft px-6 py-4">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<span class="text-[15px] font-semibold text-ink truncate">{filename}</span>
|
||||
{#if file}
|
||||
<span class="file-meta">@ {file.version}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<a class="btn" href="/database/api/projects/{projectId}/file/raw?path={encodeURIComponent(file.path)}" download>下载</a>
|
||||
<button class="btn" onclick={openHistory}><Icon name="clock" size={13} /> 历史</button>
|
||||
{#if canEdit}
|
||||
<button class="btn btn-danger" onclick={remove}><Icon name="trash" size={13} /> 删除文件</button>
|
||||
{/if}
|
||||
{#if onclose}
|
||||
<button class="btn !px-2.5" onclick={onclose} title="关闭预览" aria-label="关闭预览">✕</button>
|
||||
{#if file}
|
||||
<a class="btn" href="/database/api/projects/{projectId}/file/raw?path={encodeURIComponent(file.path)}" download>
|
||||
<Icon name="download" size={13} /> 下载
|
||||
</a>
|
||||
<button class="btn" onclick={openHistory}><Icon name="clock" size={13} /> 历史</button>
|
||||
{#if canEdit}
|
||||
<button class="btn btn-danger" onclick={remove}><Icon name="trash" size={13} /> 删除</button>
|
||||
{/if}
|
||||
{/if}
|
||||
<button class="btn !px-2.5" onclick={onclose} title="关闭" aria-label="关闭编辑器">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if file.encoding === "base64"}
|
||||
<div class="quiet">二进制文件({file.size} B),不支持在线编辑</div>
|
||||
{:else}
|
||||
<textarea rows="14" class="textarea !leading-7" bind:value={draft} readonly={!canEdit}></textarea>
|
||||
{/if}
|
||||
<!-- 编辑器主体 -->
|
||||
<div class="flex-1 overflow-y-auto px-6 py-4">
|
||||
{#if loadError}
|
||||
<div class="text-xs text-danger">{loadError}</div>
|
||||
{:else if file === null}
|
||||
<div class="quiet">加载中…</div>
|
||||
{:else if file.encoding === "base64"}
|
||||
<div class="quiet">二进制文件({file.size} B),不支持在线编辑</div>
|
||||
{:else}
|
||||
<CodeEditor value={draft} filename={path} readonly={!canEdit} onchange={(v) => (draft = v)} />
|
||||
{/if}
|
||||
|
||||
{#if canEdit && file.encoding !== "base64"}
|
||||
<div class="mt-3 flex justify-end">
|
||||
{#if conflict}
|
||||
<div class="mt-4 rounded-xl border border-[#E8E2C8] bg-[#FCFBF4] p-4">
|
||||
<div class="mb-2 text-[13px] font-semibold text-[#6E6329]">冲突:他人已提交 {conflict.currentVersion},差异如下</div>
|
||||
<pre class="diff rounded-lg border border-line-soft bg-panel p-3">{@html renderDiff(conflict.diff)}</pre>
|
||||
<div class="mt-2 text-[11.5px] text-[#8A8059]">请人工合并后重新提交(基版已更新为 {conflict.currentVersion})</div>
|
||||
<div class="mt-2 flex justify-end">
|
||||
<button class="btn" onclick={acceptLatest}>载入最新内容</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 底栏 -->
|
||||
{#if canEdit && file && file.encoding !== "base64"}
|
||||
<div class="flex shrink-0 justify-end border-t border-line-soft px-6 py-3">
|
||||
<button class="btn btn-primary" onclick={save}>提交修改</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if conflict}
|
||||
<div class="mt-3.5 rounded-xl border border-[#E8E2C8] bg-[#FCFBF4] p-4">
|
||||
<div class="mb-2 text-[13px] font-semibold text-[#6E6329]">冲突:他人已提交 {conflict.currentVersion},差异如下(你的基版 → 最新版)</div>
|
||||
<pre class="diff rounded-lg border border-line-soft bg-panel p-3">{@html renderDiff(conflict.diff)}</pre>
|
||||
<div class="mt-2 text-[11.5px] text-[#8A8059]">请人工合并后,以最新内容为全文重新提交(基版将更新为 {conflict.currentVersion})</div>
|
||||
<div class="mt-2 flex justify-end">
|
||||
<button class="btn" onclick={acceptLatest}>载入最新内容</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="panel quiet">加载中…</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showHistory}
|
||||
<Modal title="版本历史" onclose={() => (showHistory = false)}>
|
||||
|
||||
@@ -109,17 +109,15 @@
|
||||
<NodeDetailPanel />
|
||||
</main>
|
||||
|
||||
<!-- 右侧:文件预览/编辑栏(选中文件时出现) -->
|
||||
<!-- 文件编辑器(模态框) -->
|
||||
{#if $selectedFilePath && $currentNode?.kind === "PROJECT"}
|
||||
<section class="flex w-[46%] min-w-[420px] shrink-0 flex-col overflow-y-auto border-l border-line-soft bg-bg p-4">
|
||||
<FileEditor
|
||||
projectId={$currentNode.id}
|
||||
path={$selectedFilePath}
|
||||
role={$currentNode.role}
|
||||
onchanged={bumpFiles}
|
||||
onclose={clearSelectedFile}
|
||||
/>
|
||||
</section>
|
||||
<FileEditor
|
||||
projectId={$currentNode.id}
|
||||
path={$selectedFilePath}
|
||||
role={$currentNode.role}
|
||||
onchanged={bumpFiles}
|
||||
onclose={clearSelectedFile}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user