功能完善
This commit is contained in:
61
src/App.vue
61
src/App.vue
@@ -4,6 +4,7 @@ import {listen} from "@tauri-apps/api/event";
|
|||||||
import {invoke} from "@tauri-apps/api/core";
|
import {invoke} from "@tauri-apps/api/core";
|
||||||
import MarkdownEditor from "./components/MarkdownEditor.vue";
|
import MarkdownEditor from "./components/MarkdownEditor.vue";
|
||||||
import DirectorySidebar from "./components/DirectorySidebar.vue";
|
import DirectorySidebar from "./components/DirectorySidebar.vue";
|
||||||
|
import OutlinePanel from "./components/OutlinePanel.vue";
|
||||||
|
|
||||||
const content = ref("");
|
const content = ref("");
|
||||||
const wordCount = ref(0);
|
const wordCount = ref(0);
|
||||||
@@ -76,7 +77,7 @@ function onEditorWheel(e: WheelEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- toolbar state ----
|
// ---- toolbar state ----
|
||||||
const showThumbnail = ref(true);
|
const showOutline = ref(false);
|
||||||
const documentMode = ref<DocumentMode>("edit");
|
const documentMode = ref<DocumentMode>("edit");
|
||||||
const showMoreMenu = ref(false);
|
const showMoreMenu = ref(false);
|
||||||
const markdownEditorRef = ref<{ flushPendingChanges: () => string | undefined } | null>(null);
|
const markdownEditorRef = ref<{ flushPendingChanges: () => string | undefined } | null>(null);
|
||||||
@@ -295,9 +296,7 @@ listen("menu-event", (event) => {
|
|||||||
<DirectorySidebar
|
<DirectorySidebar
|
||||||
:folder-path="folderPath"
|
:folder-path="folderPath"
|
||||||
:entries="dirEntries"
|
:entries="dirEntries"
|
||||||
:content="content"
|
|
||||||
:current-file-path="currentFilePath"
|
:current-file-path="currentFilePath"
|
||||||
@heading-click="onHeadingClick"
|
|
||||||
@open-folder="onOpenFolder"
|
@open-folder="onOpenFolder"
|
||||||
@toggle-folder="onToggleFolder"
|
@toggle-folder="onToggleFolder"
|
||||||
@open-file="onOpenFile"
|
@open-file="onOpenFile"
|
||||||
@@ -309,19 +308,14 @@ listen("menu-event", (event) => {
|
|||||||
<div
|
<div
|
||||||
class="flex w-full items-center h-10 px-3 bg-[#faf9f6] border-b border-[#e0dbcf] select-none shrink-0"
|
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
|
<button
|
||||||
class="toolbar-btn"
|
class="toolbar-btn"
|
||||||
:class="{ 'toolbar-btn-active': showThumbnail }"
|
:class="{ 'toolbar-btn-active': showOutline }"
|
||||||
title="切换缩略图"
|
title="切换大纲"
|
||||||
@click="showThumbnail = !showThumbnail"
|
@click="showOutline = !showOutline"
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
<i class="ri-menu-line text-base"></i>
|
||||||
<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>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Center: Filename -->
|
<!-- Center: Filename -->
|
||||||
@@ -389,15 +383,25 @@ listen("menu-event", (event) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<MarkdownEditor
|
<div class="flex-1 flex min-h-0 overflow-hidden">
|
||||||
ref="markdownEditorRef"
|
<Transition name="outline-slide">
|
||||||
:model-value="content"
|
<OutlinePanel
|
||||||
:document-mode="documentMode"
|
v-if="showOutline"
|
||||||
placeholder="输入 Markdown 内容... 支持标题、粗体、斜体、代码块等 ✨"
|
:markdown-content="content"
|
||||||
@update:model-value="onUpdate"
|
@heading-click="onHeadingClick"
|
||||||
@save-shortcut="doSave"
|
/>
|
||||||
class="flex-1 w-full"
|
</Transition>
|
||||||
/>
|
|
||||||
|
<MarkdownEditor
|
||||||
|
ref="markdownEditorRef"
|
||||||
|
:model-value="content"
|
||||||
|
:document-mode="documentMode"
|
||||||
|
placeholder="输入 Markdown 内容... 支持标题、粗体、斜体、代码块等 ✨"
|
||||||
|
@update:model-value="onUpdate"
|
||||||
|
@save-shortcut="doSave"
|
||||||
|
class="flex-1 w-full min-w-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Zoom indicator -->
|
<!-- Zoom indicator -->
|
||||||
<Transition name="zoom-fade">
|
<Transition name="zoom-fade">
|
||||||
@@ -445,6 +449,19 @@ listen("menu-event", (event) => {
|
|||||||
opacity: 0;
|
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 ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.toolbar-btn {
|
.toolbar-btn {
|
||||||
|
|||||||
@@ -13,17 +13,14 @@ const props = withDefaults(
|
|||||||
defineProps<{
|
defineProps<{
|
||||||
folderPath: string;
|
folderPath: string;
|
||||||
entries: DirEntry[];
|
entries: DirEntry[];
|
||||||
content?: string;
|
|
||||||
currentFilePath?: string | null;
|
currentFilePath?: string | null;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
content: "",
|
|
||||||
currentFilePath: null,
|
currentFilePath: null,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
headingClick: [heading: { text: string; level: number }];
|
|
||||||
openFolder: [];
|
openFolder: [];
|
||||||
toggleFolder: [path: string];
|
toggleFolder: [path: string];
|
||||||
openFile: [path: string];
|
openFile: [path: string];
|
||||||
@@ -44,7 +41,7 @@ const rootEntry = computed<DirEntry | null>(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---- sidebar view mode ----
|
// ---- sidebar view mode ----
|
||||||
type ViewMode = "folder" | "search" | "outline" | "setting";
|
type ViewMode = "folder" | "search" | "setting";
|
||||||
const activeView = ref<ViewMode>("folder");
|
const activeView = ref<ViewMode>("folder");
|
||||||
const expanded = ref(true);
|
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 ----
|
// ---- settings modal ----
|
||||||
const showSettings = ref(false);
|
const showSettings = ref(false);
|
||||||
</script>
|
</script>
|
||||||
@@ -112,15 +82,6 @@ const showSettings = ref(false);
|
|||||||
>
|
>
|
||||||
<i class="ri-search-line text-base"></i>
|
<i class="ri-search-line text-base"></i>
|
||||||
</button>
|
</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 ===== -->
|
<!-- ===== Settings ===== -->
|
||||||
<button
|
<button
|
||||||
class="p-2 rounded-md transition-colors mt-auto"
|
class="p-2 rounded-md transition-colors mt-auto"
|
||||||
@@ -183,48 +144,6 @@ const showSettings = ref(false);
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
@@ -481,12 +481,12 @@ defineExpose({
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
ref="scrollRef"
|
ref="scrollRef"
|
||||||
class="flex-1 w-full overflow-auto"
|
class="flex-1 w-full h-full overflow-auto"
|
||||||
@wheel.passive
|
@wheel.passive
|
||||||
@keydown.capture="onRootKeydown"
|
@keydown.capture="onRootKeydown"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="mx-auto px-8 pb-40 pt-8 h-full"
|
class="mx-auto px-8 py-8 h-full"
|
||||||
:style="{
|
:style="{
|
||||||
maxWidth: `calc(700px * var(--editor-zoom, 1))`,
|
maxWidth: `calc(700px * var(--editor-zoom, 1))`,
|
||||||
fontSize: `calc(1rem * var(--editor-zoom, 1))`,
|
fontSize: `calc(1rem * var(--editor-zoom, 1))`,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
htmlContent: string;
|
markdownContent: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -16,14 +16,18 @@ interface OutlineItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const headings = computed<OutlineItem[]>(() => {
|
const headings = computed<OutlineItem[]>(() => {
|
||||||
if (!props.htmlContent) return [];
|
if (!props.markdownContent) return [];
|
||||||
const result: OutlineItem[] = [];
|
const result: OutlineItem[] = [];
|
||||||
// Match h1, h2, h3 tags — case-insensitive, capture inner text
|
const regex = /^(#{1,3})\s+(.+)$/gm;
|
||||||
const regex = /<h([1-3])[^>]*>(.*?)<\/h\1>/gi;
|
|
||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
while ((match = regex.exec(props.htmlContent)) !== null) {
|
while ((match = regex.exec(props.markdownContent)) !== null) {
|
||||||
const level = parseInt(match[1]);
|
const level = match[1].length;
|
||||||
const text = match[2].replace(/<[^>]*>/g, "").trim();
|
const text = match[2]
|
||||||
|
.replace(
|
||||||
|
/\*{1,3}|_{1,3}|`+|~~|\[([^\]]*)\]\([^)]*\)/g,
|
||||||
|
(_, linkText) => linkText || "",
|
||||||
|
)
|
||||||
|
.trim();
|
||||||
if (text) {
|
if (text) {
|
||||||
result.push({
|
result.push({
|
||||||
text,
|
text,
|
||||||
@@ -42,7 +46,7 @@ const onHeadingClick = (item: OutlineItem) => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside
|
<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 -->
|
<!-- Header -->
|
||||||
<div class="flex items-center px-3 py-3 border-b border-[#e8e4da]">
|
<div class="flex items-center px-3 py-3 border-b border-[#e8e4da]">
|
||||||
@@ -73,17 +77,6 @@ const onHeadingClick = (item: OutlineItem) => {
|
|||||||
:title="item.text"
|
:title="item.text"
|
||||||
@click="onHeadingClick(item)"
|
@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 }}
|
{{ item.text }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user