功能完善
This commit is contained in:
45
src/App.vue
45
src/App.vue
@@ -4,6 +4,7 @@ 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";
|
||||
|
||||
const content = ref("");
|
||||
const wordCount = ref(0);
|
||||
@@ -76,7 +77,7 @@ function onEditorWheel(e: WheelEvent) {
|
||||
}
|
||||
|
||||
// ---- toolbar state ----
|
||||
const showThumbnail = ref(true);
|
||||
const showOutline = ref(false);
|
||||
const documentMode = ref<DocumentMode>("edit");
|
||||
const showMoreMenu = ref(false);
|
||||
const markdownEditorRef = ref<{ flushPendingChanges: () => string | undefined } | null>(null);
|
||||
@@ -295,9 +296,7 @@ listen("menu-event", (event) => {
|
||||
<DirectorySidebar
|
||||
:folder-path="folderPath"
|
||||
:entries="dirEntries"
|
||||
:content="content"
|
||||
:current-file-path="currentFilePath"
|
||||
@heading-click="onHeadingClick"
|
||||
@open-folder="onOpenFolder"
|
||||
@toggle-folder="onToggleFolder"
|
||||
@open-file="onOpenFile"
|
||||
@@ -309,19 +308,14 @@ listen("menu-event", (event) => {
|
||||
<div
|
||||
class="flex w-full items-center h-10 px-3 bg-[#faf9f6] border-b border-[#e0dbcf] select-none shrink-0"
|
||||
>
|
||||
<!-- Left: Thumbnail toggle -->
|
||||
<!-- Left: Outline toggle -->
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
:class="{ 'toolbar-btn-active': showThumbnail }"
|
||||
title="切换缩略图"
|
||||
@click="showThumbnail = !showThumbnail"
|
||||
:class="{ 'toolbar-btn-active': showOutline }"
|
||||
title="切换大纲"
|
||||
@click="showOutline = !showOutline"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="1" y="1" width="6" height="6" rx="1"/>
|
||||
<rect x="9" y="1" width="6" height="6" rx="1"/>
|
||||
<rect x="1" y="9" width="6" height="6" rx="1"/>
|
||||
<rect x="9" y="9" width="6" height="6" rx="1"/>
|
||||
</svg>
|
||||
<i class="ri-menu-line text-base"></i>
|
||||
</button>
|
||||
|
||||
<!-- Center: Filename -->
|
||||
@@ -389,6 +383,15 @@ listen("menu-event", (event) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 flex min-h-0 overflow-hidden">
|
||||
<Transition name="outline-slide">
|
||||
<OutlinePanel
|
||||
v-if="showOutline"
|
||||
:markdown-content="content"
|
||||
@heading-click="onHeadingClick"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<MarkdownEditor
|
||||
ref="markdownEditorRef"
|
||||
:model-value="content"
|
||||
@@ -396,8 +399,9 @@ listen("menu-event", (event) => {
|
||||
placeholder="输入 Markdown 内容... 支持标题、粗体、斜体、代码块等 ✨"
|
||||
@update:model-value="onUpdate"
|
||||
@save-shortcut="doSave"
|
||||
class="flex-1 w-full"
|
||||
class="flex-1 w-full min-w-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Zoom indicator -->
|
||||
<Transition name="zoom-fade">
|
||||
@@ -445,6 +449,19 @@ listen("menu-event", (event) => {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.outline-slide-enter-active,
|
||||
.outline-slide-leave-active {
|
||||
transition:
|
||||
width 0.18s ease,
|
||||
opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.outline-slide-enter-from,
|
||||
.outline-slide-leave-to {
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ── Toolbar ───────────────────────────────────────────────────── */
|
||||
|
||||
.toolbar-btn {
|
||||
|
||||
@@ -13,17 +13,14 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
folderPath: string;
|
||||
entries: DirEntry[];
|
||||
content?: string;
|
||||
currentFilePath?: string | null;
|
||||
}>(),
|
||||
{
|
||||
content: "",
|
||||
currentFilePath: null,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
headingClick: [heading: { text: string; level: number }];
|
||||
openFolder: [];
|
||||
toggleFolder: [path: string];
|
||||
openFile: [path: string];
|
||||
@@ -44,7 +41,7 @@ const rootEntry = computed<DirEntry | null>(() => {
|
||||
});
|
||||
|
||||
// ---- sidebar view mode ----
|
||||
type ViewMode = "folder" | "search" | "outline" | "setting";
|
||||
type ViewMode = "folder" | "search" | "setting";
|
||||
const activeView = ref<ViewMode>("folder");
|
||||
const expanded = ref(true);
|
||||
|
||||
@@ -57,33 +54,6 @@ function switchView(view: ViewMode) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- outline state ----
|
||||
interface OutlineItem {
|
||||
text: string;
|
||||
level: number;
|
||||
id: string;
|
||||
}
|
||||
|
||||
const headings = computed<OutlineItem[]>(() => {
|
||||
if (!props.content) return [];
|
||||
const result: OutlineItem[] = [];
|
||||
const regex = /^(#{1,3})\s+(.+)$/gm;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(props.content)) !== null) {
|
||||
const level = match[1].length;
|
||||
// Strip any remaining markdown formatting from the heading text
|
||||
const text = match[2].replace(/\*{1,3}|_{1,3}|`+|~~|\[([^\]]*)\]\([^)]*\)/g, (_, linkText) => linkText || "").trim();
|
||||
if (text) {
|
||||
result.push({text, level, id: `heading-${result.length}`});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
function onHeadingClick(item: OutlineItem) {
|
||||
emit("headingClick", {text: item.text, level: item.level});
|
||||
}
|
||||
|
||||
// ---- settings modal ----
|
||||
const showSettings = ref(false);
|
||||
</script>
|
||||
@@ -112,15 +82,6 @@ const showSettings = ref(false);
|
||||
>
|
||||
<i class="ri-search-line text-base"></i>
|
||||
</button>
|
||||
<!-- Outline -->
|
||||
<button
|
||||
class="p-2 rounded-md transition-colors"
|
||||
:class="activeView === 'outline' ? 'text-[#bf6a3b] bg-[#ede8de]' : 'text-[#b8b3a8] hover:text-[#5c574e] hover:bg-[#ede8de]'"
|
||||
title="大纲"
|
||||
@click="switchView('outline')"
|
||||
>
|
||||
<i class="ri-menu-line text-base"></i>
|
||||
</button>
|
||||
<!-- ===== Settings ===== -->
|
||||
<button
|
||||
class="p-2 rounded-md transition-colors mt-auto"
|
||||
@@ -183,48 +144,6 @@ const showSettings = ref(false);
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== OUTLINE VIEW ===== -->
|
||||
<div v-if="activeView === 'outline'" class="flex-1 overflow-y-auto px-2 py-2">
|
||||
<template v-if="!currentFilePath">
|
||||
<p class="text-xs text-[#b8b3a8] px-2 py-4 text-center leading-relaxed">
|
||||
暂无打开的文件
|
||||
</p>
|
||||
</template>
|
||||
<template v-else-if="headings.length === 0">
|
||||
<p class="text-xs text-[#b8b3a8] px-2 py-4 text-center leading-relaxed">
|
||||
暂无标题<br/>使用 H1-H3 标题<br/>自动生成大纲
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="item in headings"
|
||||
:key="item.id"
|
||||
:class="[
|
||||
'px-2 py-1 rounded-md text-sm cursor-pointer hover:bg-[#ede8de] transition-colors truncate',
|
||||
item.level === 1
|
||||
? 'text-[#38342e] font-medium ml-0'
|
||||
: item.level === 2
|
||||
? 'text-[#8c877d] ml-3'
|
||||
: 'text-[#b8b3a8] ml-6 text-xs',
|
||||
]"
|
||||
:title="item.text"
|
||||
@click="onHeadingClick(item)"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'inline-block w-1.5 h-1.5 rounded-full mr-1.5 align-middle',
|
||||
item.level === 1
|
||||
? 'bg-[#bf6a3b]'
|
||||
: item.level === 2
|
||||
? 'bg-[#7ba587]/60'
|
||||
: 'bg-gray-500',
|
||||
]"
|
||||
/>
|
||||
{{ item.text }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -481,12 +481,12 @@ defineExpose({
|
||||
<template>
|
||||
<div
|
||||
ref="scrollRef"
|
||||
class="flex-1 w-full overflow-auto"
|
||||
class="flex-1 w-full h-full overflow-auto"
|
||||
@wheel.passive
|
||||
@keydown.capture="onRootKeydown"
|
||||
>
|
||||
<div
|
||||
class="mx-auto px-8 pb-40 pt-8 h-full"
|
||||
class="mx-auto px-8 py-8 h-full"
|
||||
:style="{
|
||||
maxWidth: `calc(700px * var(--editor-zoom, 1))`,
|
||||
fontSize: `calc(1rem * var(--editor-zoom, 1))`,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
htmlContent: string;
|
||||
markdownContent: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -16,14 +16,18 @@ interface OutlineItem {
|
||||
}
|
||||
|
||||
const headings = computed<OutlineItem[]>(() => {
|
||||
if (!props.htmlContent) return [];
|
||||
if (!props.markdownContent) return [];
|
||||
const result: OutlineItem[] = [];
|
||||
// Match h1, h2, h3 tags — case-insensitive, capture inner text
|
||||
const regex = /<h([1-3])[^>]*>(.*?)<\/h\1>/gi;
|
||||
const regex = /^(#{1,3})\s+(.+)$/gm;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(props.htmlContent)) !== null) {
|
||||
const level = parseInt(match[1]);
|
||||
const text = match[2].replace(/<[^>]*>/g, "").trim();
|
||||
while ((match = regex.exec(props.markdownContent)) !== null) {
|
||||
const level = match[1].length;
|
||||
const text = match[2]
|
||||
.replace(
|
||||
/\*{1,3}|_{1,3}|`+|~~|\[([^\]]*)\]\([^)]*\)/g,
|
||||
(_, linkText) => linkText || "",
|
||||
)
|
||||
.trim();
|
||||
if (text) {
|
||||
result.push({
|
||||
text,
|
||||
@@ -42,7 +46,7 @@ const onHeadingClick = (item: OutlineItem) => {
|
||||
|
||||
<template>
|
||||
<aside
|
||||
class="flex flex-col border-r border-[#e8e4da] bg-[#f4f1eb]/80 w-48 shrink-0 overflow-hidden"
|
||||
class="flex flex-col border-r border-[#e8e4da] bg-[#f4f1eb]/80 w-52 shrink-0 overflow-hidden"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center px-3 py-3 border-b border-[#e8e4da]">
|
||||
@@ -73,17 +77,6 @@ const onHeadingClick = (item: OutlineItem) => {
|
||||
:title="item.text"
|
||||
@click="onHeadingClick(item)"
|
||||
>
|
||||
<!-- level indicator dot -->
|
||||
<span
|
||||
:class="[
|
||||
'inline-block w-1.5 h-1.5 rounded-full mr-1.5 align-middle',
|
||||
item.level === 1
|
||||
? 'bg-[#bf6a3b]'
|
||||
: item.level === 2
|
||||
? 'bg-[#7ba587]/60'
|
||||
: 'bg-gray-500',
|
||||
]"
|
||||
/>
|
||||
{{ item.text }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user