This commit is contained in:
2026-07-02 00:47:03 +08:00
parent e4efa4b54d
commit 82a64787c3
12 changed files with 568 additions and 127 deletions

View File

@@ -1,5 +1,7 @@
<script setup lang="ts">
import { ref } from "vue";
import { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core";
import MarkdownEditor from "./components/MarkdownEditor.vue";
import DirectorySidebar from "./components/DirectorySidebar.vue";
import OutlinePanel from "./components/OutlinePanel.vue";
@@ -8,9 +10,22 @@ const content = ref("");
const wordCount = ref(0);
const charCount = ref(0);
// File state
const currentFilePath = ref<string | null>(null);
const saveStatus = ref("");
// Panel state
const showOutline = ref(true);
// Folder state
interface DirEntry {
name: string;
path: string;
is_dir: boolean;
}
const folderPath = ref("");
const dirEntries = ref<DirEntry[]>([]);
function onUpdate(html: string) {
content.value = html;
// Strip HTML tags for counting
@@ -32,60 +47,99 @@ function onHeadingClick(heading: { text: string; level: number }) {
}
}
}
async function loadDir(path: string) {
folderPath.value = path;
try {
dirEntries.value = await invoke<DirEntry[]>("read_dir", { path });
} catch (e) {
console.error("Failed to read directory:", e);
dirEntries.value = [];
}
}
function getFileName(path: string): string {
return path.split(/[/\\]/).pop() || path;
}
async function doSave() {
if (!currentFilePath.value) {
// No file path yet — prompt user to pick a save location
try {
const chosen = await invoke<string | null>("pick_save_file");
if (!chosen) return; // user cancelled
currentFilePath.value = chosen;
} catch (e) {
console.error("Save dialog failed:", e);
return;
}
}
try {
await invoke("write_file", { path: currentFilePath.value, content: content.value });
saveStatus.value = `已保存: ${getFileName(currentFilePath.value)}`;
setTimeout(() => { saveStatus.value = ""; }, 3000);
} catch (e) {
console.error("Save failed:", e);
saveStatus.value = `保存失败: ${e}`;
}
}
async function doSaveAs() {
try {
const chosen = await invoke<string | null>("pick_save_file");
if (!chosen) return; // user cancelled
currentFilePath.value = chosen;
await invoke("write_file", { path: currentFilePath.value, content: content.value });
saveStatus.value = `已保存: ${getFileName(currentFilePath.value)}`;
setTimeout(() => { saveStatus.value = ""; }, 3000);
} catch (e) {
console.error("Save failed:", e);
saveStatus.value = `保存失败: ${e}`;
}
}
function doNew() {
content.value = "";
currentFilePath.value = null;
saveStatus.value = "";
}
// Listen for menu events from Rust
listen<string>("folder-opened", (event) => {
loadDir(event.payload);
});
listen("toggle-outline", () => {
showOutline.value = !showOutline.value;
});
listen("menu-save", () => {
doSave();
});
listen("menu-save-as", () => {
doSaveAs();
});
listen("menu-new", () => {
doNew();
});
listen("menu-event", (event) => {
console.log("Menu event:", event.payload);
});
</script>
<template>
<div class="min-h-screen bg-gray-950 text-gray-100 flex flex-col">
<!-- Header -->
<header
class="flex items-center justify-between px-6 py-3 border-b border-gray-800 bg-gray-900/70 backdrop-blur-md sticky top-0 z-10"
>
<div class="flex items-center gap-3">
<div
class="w-8 h-8 rounded-lg bg-indigo-600 flex items-center justify-center text-sm font-bold"
>
M
</div>
<h1 class="text-lg font-semibold tracking-tight">Markdown Editor</h1>
</div>
<div class="flex items-center gap-4">
<!-- Outline toggle -->
<button
class="flex items-center gap-1 px-2.5 py-1.5 text-xs rounded-md transition-colors"
:class="
showOutline
? 'bg-indigo-600/30 text-indigo-300'
: 'text-gray-400 hover:text-white hover:bg-gray-800'
"
title="切换大纲面板"
@click="showOutline = !showOutline"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-3.5 h-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h7" />
</svg>
大纲
</button>
<span class="w-px h-3 bg-gray-600" />
<span class="text-xs text-gray-400">{{ wordCount.toLocaleString() }} </span>
<span class="w-px h-3 bg-gray-600" />
<span class="text-xs text-gray-400">{{ charCount.toLocaleString() }} </span>
</div>
</header>
<!-- Three-column body -->
<div class="flex-1 flex overflow-hidden">
<!-- Left: Directory sidebar -->
<DirectorySidebar />
<DirectorySidebar
:folder-path="folderPath"
:entries="dirEntries"
/>
<!-- Middle: Outline panel -->
<transition name="outline-slide">
@@ -97,22 +151,61 @@ function onHeadingClick(heading: { text: string; level: number }) {
</transition>
<!-- Right: Editor area -->
<main class="flex-1 flex justify-center overflow-y-auto px-4 py-8">
<div class="w-full max-w-4xl">
<main class="flex-1 flex flex-col overflow-y-auto p-2">
<MarkdownEditor
v-model="content"
placeholder="输入 Markdown 内容... 支持标题、粗体、斜体、代码块等 ✨"
@update:model-value="onUpdate"
@save-shortcut="doSave"
class="flex-1 w-full"
/>
</div>
</main>
</div>
<footer>
<div class="flex items-center gap-4">
<!-- Outline toggle -->
<button
class="flex items-center gap-1 px-2.5 py-1.5 text-xs rounded-md transition-colors"
:class="
showOutline
? 'bg-indigo-600/30 text-indigo-300'
: 'text-gray-400 hover:text-white hover:bg-gray-800'
"
title="切换大纲面板"
@click="showOutline = !showOutline"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-3.5 h-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h7" />
</svg>
大纲
</button>
<!-- Footer -->
<footer
class="text-center text-xs text-gray-600 py-4 border-t border-gray-800/50"
>
基于 TipTap · 支持 Markdown 快捷输入
<span class="w-px h-3 bg-gray-600" />
<!-- Current file / save status -->
<span
v-if="saveStatus"
class="text-xs"
:class="saveStatus.startsWith('保存失败') ? 'text-red-400' : 'text-green-400'"
>{{ saveStatus }}</span>
<span v-else-if="currentFilePath" class="text-xs text-gray-400">
{{ currentFilePath.split(/[/\\]/).pop() }}
</span>
<span v-else class="text-xs text-gray-500">未保存</span>
<span class="flex-1" />
<span class="text-xs text-gray-400">{{ wordCount.toLocaleString() }} </span>
<span class="w-px h-3 bg-gray-600" />
<span class="text-xs text-gray-400">{{ charCount.toLocaleString() }} </span>
</div>
</footer>
</div>
</template>

View File

@@ -1,64 +1,58 @@
<script setup lang="ts">
import { ref } from "vue";
interface DirEntry {
name: string;
path: string;
is_dir: boolean;
}
const props = defineProps<{
folderPath: string;
entries: DirEntry[];
}>();
const collapsed = ref(false);
const toggleCollapsed = () => {
collapsed.value = !collapsed.value;
};
// ---- file tree state ----
// ---- file tree state (for opened folder) ----
interface FileItem {
id: string;
name: string;
type: "file" | "folder";
path: string;
children?: FileItem[];
expanded?: boolean;
}
const files = ref<FileItem[]>([
{
id: "1",
name: "文档",
type: "folder",
expanded: true,
children: [
{ id: "2", name: "README.md", type: "file" },
{ id: "3", name: "笔记.md", type: "file" },
],
},
{
id: "4",
name: "工作",
type: "folder",
expanded: false,
children: [{ id: "5", name: "项目计划.md", type: "file" }],
},
]);
const expandedFolders = ref<Set<string>>(new Set());
const toggleFolder = (item: FileItem) => {
if (item.type === "folder") {
item.expanded = !item.expanded;
if (expandedFolders.value.has(item.path)) {
expandedFolders.value.delete(item.path);
} else {
expandedFolders.value.add(item.path);
}
// Trigger reactivity
expandedFolders.value = new Set(expandedFolders.value);
}
};
const newFile = () => {
const name = prompt("文件名称(含扩展名):");
if (name) {
files.value.push({ id: crypto.randomUUID(), name, type: "file" });
// files are managed externally
}
};
const newFolder = () => {
const name = prompt("文件夹名称:");
if (name) {
files.value.push({
id: crypto.randomUUID(),
name,
type: "folder",
expanded: true,
children: [],
});
// folders are managed externally
}
};
@@ -94,7 +88,7 @@ const removeTag = (tag: string) => {
:class="collapsed ? 'justify-center' : ''"
>
<span v-if="!collapsed" class="text-xs font-semibold text-gray-400 uppercase tracking-wider">
目录
语柔
</span>
<button
class="p-1 rounded-md text-gray-400 hover:text-white hover:bg-gray-700 transition-colors"
@@ -154,20 +148,34 @@ const removeTag = (tag: string) => {
<div v-if="!collapsed" class="flex flex-col flex-1 overflow-hidden">
<!-- File tree -->
<div class="flex-1 overflow-y-auto px-2 py-2 space-y-0.5">
<!-- Folder path indicator -->
<div
v-for="item in files"
:key="item.id"
v-if="folderPath"
class="px-2 py-1 text-xs text-gray-500 truncate mb-2 border-b border-gray-800/50 pb-2"
:title="folderPath"
>
📁 {{ folderPath.split(/[/\\]/).pop() || folderPath }}
</div>
<!-- Empty state -->
<div
v-if="!folderPath"
class="px-2 py-4 text-xs text-gray-500 text-center"
>
点击 文件 打开文件夹 或按 Ctrl+O
</div>
<template v-for="entry in entries" :key="entry.path">
<!-- Folder -->
<div
v-if="item.type === 'folder'"
v-if="entry.is_dir"
class="flex items-center gap-1 px-2 py-1 rounded-md text-sm text-gray-300 hover:bg-gray-800 cursor-pointer select-none"
@click="toggleFolder(item)"
@click="toggleFolder({ id: entry.path, name: entry.name, type: 'folder', path: entry.path })"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-3.5 h-3.5 text-gray-500 transition-transform"
:class="item.expanded ? 'rotate-90' : ''"
:class="expandedFolders.has(entry.path) ? 'rotate-90' : ''"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -185,36 +193,12 @@ const removeTag = (tag: string) => {
>
<path stroke-linecap="round" stroke-linejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<span class="truncate">{{ item.name }}</span>
<span class="truncate">{{ entry.name }}</span>
</div>
<!-- Folder children -->
<!-- File -->
<div
v-if="item.type === 'folder' && item.expanded"
class="ml-4 space-y-0.5"
>
<div
v-for="child in item.children"
:key="child.id"
class="flex items-center gap-1.5 px-2 py-1 rounded-md text-sm text-gray-400 hover:bg-gray-800 hover:text-gray-200 cursor-pointer"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-3.5 h-3.5 text-indigo-400/60"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span class="truncate">{{ child.name }}</span>
</div>
</div>
<!-- Top-level file -->
<div
v-else-if="item.type === 'file'"
v-else
class="flex items-center gap-1.5 px-2 py-1 rounded-md text-sm text-gray-400 hover:bg-gray-800 hover:text-gray-200 cursor-pointer"
>
<svg
@@ -227,9 +211,9 @@ const removeTag = (tag: string) => {
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span class="truncate">{{ item.name }}</span>
<span class="truncate">{{ entry.name }}</span>
</div>
</div>
</template>
</div>
<!-- Action buttons -->

View File

@@ -17,6 +17,7 @@ const props = withDefaults(
const emit = defineEmits<{
"update:modelValue": [value: string];
"save-shortcut": [];
}>();
const editor = useEditor({
@@ -34,6 +35,14 @@ const editor = useEditor({
class:
"min-h-[320px] px-6 py-4 outline-none focus:outline-none",
},
handleKeyDown: (_view, event) => {
if ((event.ctrlKey || event.metaKey) && event.key === "s") {
event.preventDefault();
emit("save-shortcut");
return true;
}
return false;
},
},
onUpdate: ({ editor }) => {
emit("update:modelValue", editor.getHTML());
@@ -53,7 +62,7 @@ watch(
<template>
<div
class="flex flex-col rounded-xl border border-gray-700 bg-gray-900 shadow-2xl overflow-hidden"
class="flex flex-col flex-1 w-full border border-gray-700 bg-gray-900 shadow-2xl overflow-hidden"
>
<!-- Toolbar -->
<div
@@ -205,7 +214,7 @@ watch(
</div>
<!-- Editor content -->
<EditorContent :editor="editor" class="tiptap-editor" />
<EditorContent :editor="editor" class="tiptap-editor flex-1 flex flex-col" />
</div>
</template>
@@ -214,6 +223,7 @@ watch(
.tiptap-editor .ProseMirror {
color: #f3f4f6;
min-height: 320px;
flex: 1;
outline: none;
}