功能完善
This commit is contained in:
146
src/App.vue
146
src/App.vue
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {ref, watch, onMounted, onUnmounted} from "vue";
|
import {computed, ref, watch, onMounted, onUnmounted} from "vue";
|
||||||
import {listen} from "@tauri-apps/api/event";
|
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";
|
||||||
@@ -15,6 +15,7 @@ type DocumentMode = "preview" | "edit" | "mixed";
|
|||||||
// File state
|
// File state
|
||||||
const currentFilePath = ref<string | null>(null);
|
const currentFilePath = ref<string | null>(null);
|
||||||
const saveStatus = ref("");
|
const saveStatus = ref("");
|
||||||
|
const hasDraftDocument = ref(false);
|
||||||
|
|
||||||
// Folder state
|
// Folder state
|
||||||
interface DirEntry {
|
interface DirEntry {
|
||||||
@@ -26,6 +27,8 @@ interface DirEntry {
|
|||||||
|
|
||||||
const folderPath = ref("");
|
const folderPath = ref("");
|
||||||
const dirEntries = ref<DirEntry[]>([]);
|
const dirEntries = ref<DirEntry[]>([]);
|
||||||
|
const LAST_WORKSPACE_PATH_KEY = "yurou:last-workspace-path";
|
||||||
|
const editorResetKey = ref(0);
|
||||||
|
|
||||||
// ---- editor zoom (Ctrl+scroll) ----
|
// ---- editor zoom (Ctrl+scroll) ----
|
||||||
const ZOOM_MIN = 0.7;
|
const ZOOM_MIN = 0.7;
|
||||||
@@ -81,6 +84,9 @@ 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);
|
||||||
|
const showStartActions = computed(() => {
|
||||||
|
return !folderPath.value && !currentFilePath.value && !hasDraftDocument.value;
|
||||||
|
});
|
||||||
|
|
||||||
function handleExportPdf() {
|
function handleExportPdf() {
|
||||||
showMoreMenu.value = false;
|
showMoreMenu.value = false;
|
||||||
@@ -102,6 +108,7 @@ watch(showMoreMenu, (val) => {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
applyZoomCssVars();
|
applyZoomCssVars();
|
||||||
window.addEventListener("wheel", onEditorWheel, {passive: false});
|
window.addEventListener("wheel", onEditorWheel, {passive: false});
|
||||||
|
void restoreLastWorkspace();
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -135,11 +142,31 @@ function stripMarkdown(md: string): string {
|
|||||||
|
|
||||||
function onUpdate(md: string) {
|
function onUpdate(md: string) {
|
||||||
content.value = md;
|
content.value = md;
|
||||||
|
updateDocumentMetrics(md);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDocumentMetrics(md: string) {
|
||||||
const text = stripMarkdown(md);
|
const text = stripMarkdown(md);
|
||||||
charCount.value = text.length;
|
charCount.value = text.length;
|
||||||
wordCount.value = text.trim() ? text.trim().split(/\s+/).length : 0;
|
wordCount.value = text.trim() ? text.trim().split(/\s+/).length : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setDocumentContent(md: string) {
|
||||||
|
content.value = md;
|
||||||
|
updateDocumentMetrics(md);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetMarkdownEditorState() {
|
||||||
|
setDocumentContent("");
|
||||||
|
currentFilePath.value = null;
|
||||||
|
hasDraftDocument.value = false;
|
||||||
|
saveStatus.value = "";
|
||||||
|
showOutline.value = false;
|
||||||
|
documentMode.value = "edit";
|
||||||
|
showMoreMenu.value = false;
|
||||||
|
editorResetKey.value += 1;
|
||||||
|
}
|
||||||
|
|
||||||
function onHeadingClick(heading: { text: string; level: number }) {
|
function onHeadingClick(heading: { text: string; level: number }) {
|
||||||
const selector = `.markdown-preview h${heading.level}`;
|
const selector = `.markdown-preview h${heading.level}`;
|
||||||
const headingEls = document.querySelectorAll(selector);
|
const headingEls = document.querySelectorAll(selector);
|
||||||
@@ -151,21 +178,73 @@ function onHeadingClick(heading: { text: string; level: number }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDir(path: string) {
|
function getLastWorkspacePath() {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(LAST_WORKSPACE_PATH_KEY);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveLastWorkspacePath(path: string) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LAST_WORKSPACE_PATH_KEY, path);
|
||||||
|
} catch { /* noop */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLastWorkspacePath() {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(LAST_WORKSPACE_PATH_KEY);
|
||||||
|
} catch { /* noop */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDir(path: string): Promise<boolean> {
|
||||||
folderPath.value = path;
|
folderPath.value = path;
|
||||||
|
dirEntries.value = [];
|
||||||
try {
|
try {
|
||||||
dirEntries.value = await invoke<DirEntry[]>("read_dir", {path});
|
dirEntries.value = await invoke<DirEntry[]>("read_dir", {path});
|
||||||
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to read directory:", e);
|
console.error("Failed to read directory:", e);
|
||||||
|
folderPath.value = "";
|
||||||
dirEntries.value = [];
|
dirEntries.value = [];
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function switchWorkspace(path: string, options: { persist?: boolean; resetEditor?: boolean } = {}) {
|
||||||
|
if (options.resetEditor !== false) {
|
||||||
|
resetMarkdownEditorState();
|
||||||
|
}
|
||||||
|
|
||||||
|
const loaded = await loadDir(path);
|
||||||
|
|
||||||
|
if (loaded && options.persist !== false) {
|
||||||
|
saveLastWorkspacePath(path);
|
||||||
|
} else if (!loaded) {
|
||||||
|
clearLastWorkspacePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
return loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreLastWorkspace() {
|
||||||
|
const lastWorkspacePath = getLastWorkspacePath();
|
||||||
|
if (!lastWorkspacePath) return;
|
||||||
|
|
||||||
|
await switchWorkspace(lastWorkspacePath, {
|
||||||
|
persist: false,
|
||||||
|
resetEditor: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function onOpenFolder() {
|
async function onOpenFolder() {
|
||||||
try {
|
try {
|
||||||
const chosen = await invoke<string | null>("pick_folder");
|
const chosen = await invoke<string | null>("pick_folder");
|
||||||
if (chosen) {
|
if (chosen) {
|
||||||
await loadDir(chosen);
|
await switchWorkspace(chosen);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to pick folder:", e);
|
console.error("Failed to pick folder:", e);
|
||||||
@@ -202,8 +281,9 @@ async function onToggleFolder(path: string) {
|
|||||||
async function onOpenFile(path: string) {
|
async function onOpenFile(path: string) {
|
||||||
try {
|
try {
|
||||||
const fileContent = await invoke<string>("read_file", {path});
|
const fileContent = await invoke<string>("read_file", {path});
|
||||||
content.value = fileContent;
|
setDocumentContent(fileContent);
|
||||||
currentFilePath.value = path;
|
currentFilePath.value = path;
|
||||||
|
hasDraftDocument.value = false;
|
||||||
saveStatus.value = "";
|
saveStatus.value = "";
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to open file:", e);
|
console.error("Failed to open file:", e);
|
||||||
@@ -261,14 +341,16 @@ async function doSaveAs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function doNew() {
|
function doNew() {
|
||||||
content.value = "";
|
setDocumentContent("");
|
||||||
currentFilePath.value = null;
|
currentFilePath.value = null;
|
||||||
|
hasDraftDocument.value = true;
|
||||||
saveStatus.value = "";
|
saveStatus.value = "";
|
||||||
|
editorResetKey.value += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for menu events from Rust
|
// Listen for menu events from Rust
|
||||||
listen<string>("folder-opened", (event) => {
|
listen<string>("folder-opened", (event) => {
|
||||||
loadDir(event.payload);
|
void switchWorkspace(event.payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
listen("menu-save", () => {
|
listen("menu-save", () => {
|
||||||
@@ -306,6 +388,7 @@ listen("menu-event", (event) => {
|
|||||||
<main class="flex-1 flex flex-col overflow-hidden relative">
|
<main class="flex-1 flex flex-col overflow-hidden relative">
|
||||||
<!-- ════════════════ Toolbar ════════════════ -->
|
<!-- ════════════════ Toolbar ════════════════ -->
|
||||||
<div
|
<div
|
||||||
|
v-if="!showStartActions"
|
||||||
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: Outline toggle -->
|
<!-- Left: Outline toggle -->
|
||||||
@@ -320,11 +403,11 @@ listen("menu-event", (event) => {
|
|||||||
|
|
||||||
<!-- Center: Filename -->
|
<!-- Center: Filename -->
|
||||||
<div class="flex-1 text-center text-sm text-[#8c877d] font-medium truncate px-3 select-none">
|
<div class="flex-1 text-center text-sm text-[#8c877d] font-medium truncate px-3 select-none">
|
||||||
{{ currentFilePath ? getFileName(currentFilePath) : '未命名文档' }}
|
{{ showStartActions ? '未选择工作目录' : currentFilePath ? getFileName(currentFilePath) : '未命名文档' }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right: Actions -->
|
<!-- Right: Actions -->
|
||||||
<div class="flex items-center gap-0.5">
|
<div v-if="!showStartActions" class="flex items-center gap-0.5">
|
||||||
<!-- Document mode -->
|
<!-- Document mode -->
|
||||||
<div class="mode-toggle" role="group" aria-label="文档模式">
|
<div class="mode-toggle" role="group" aria-label="文档模式">
|
||||||
<button
|
<button
|
||||||
@@ -386,13 +469,15 @@ listen("menu-event", (event) => {
|
|||||||
<div class="flex-1 flex min-h-0 overflow-hidden">
|
<div class="flex-1 flex min-h-0 overflow-hidden">
|
||||||
<Transition name="outline-slide">
|
<Transition name="outline-slide">
|
||||||
<OutlinePanel
|
<OutlinePanel
|
||||||
v-if="showOutline"
|
v-if="showOutline && !showStartActions"
|
||||||
:markdown-content="content"
|
:markdown-content="content"
|
||||||
@heading-click="onHeadingClick"
|
@heading-click="onHeadingClick"
|
||||||
/>
|
/>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
|
v-if="!showStartActions"
|
||||||
|
:key="editorResetKey"
|
||||||
ref="markdownEditorRef"
|
ref="markdownEditorRef"
|
||||||
:model-value="content"
|
:model-value="content"
|
||||||
:document-mode="documentMode"
|
:document-mode="documentMode"
|
||||||
@@ -401,6 +486,19 @@ listen("menu-event", (event) => {
|
|||||||
@save-shortcut="doSave"
|
@save-shortcut="doSave"
|
||||||
class="flex-1 w-full min-w-0"
|
class="flex-1 w-full min-w-0"
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="start-actions flex-1 flex items-center justify-center px-8"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-center gap-3">
|
||||||
|
<button class="start-action-btn" @click="onOpenFolder">
|
||||||
|
选择工作目录
|
||||||
|
</button>
|
||||||
|
<button class="start-action-btn" @click="doNew">
|
||||||
|
新建文件
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Zoom indicator -->
|
<!-- Zoom indicator -->
|
||||||
@@ -562,6 +660,36 @@ listen("menu-event", (event) => {
|
|||||||
.dropdown-item:hover {
|
.dropdown-item:hover {
|
||||||
background-color: #f4f1ea;
|
background-color: #f4f1ea;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.start-actions {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 253, 250, 0.35), rgba(244, 241, 235, 0.2)),
|
||||||
|
#faf9f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-action-btn {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #8c877d;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.4;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 2px;
|
||||||
|
transition:
|
||||||
|
color 0.15s ease,
|
||||||
|
text-decoration-color 0.15s ease;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 6px;
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
text-decoration-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-action-btn:hover,
|
||||||
|
.start-action-btn:focus-visible {
|
||||||
|
color: #bf6a3b;
|
||||||
|
text-decoration-color: currentColor;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ const showSettings = ref(false);
|
|||||||
<div class="w-12 shrink-0 flex flex-col items-center gap-1 py-3">
|
<div class="w-12 shrink-0 flex flex-col items-center gap-1 py-3">
|
||||||
<!-- Folder -->
|
<!-- Folder -->
|
||||||
<button
|
<button
|
||||||
class="p-2 rounded-md transition-colors"
|
class="px-2 py-1 cursor-pointer rounded-md transition-colors"
|
||||||
:class="activeView === 'folder' ? 'text-[#bf6a3b] bg-[#ede8de]' : 'text-[#b8b3a8] hover:text-[#5c574e] hover:bg-[#ede8de]'"
|
:class="activeView === 'folder' ? 'text-[#bf6a3b] bg-[#ede8de]' : 'text-[#b8b3a8] hover:text-[#5c574e] hover:bg-[#ede8de]'"
|
||||||
title="文件"
|
title="文件"
|
||||||
@click="switchView('folder')"
|
@click="switchView('folder')"
|
||||||
@@ -75,7 +75,7 @@ const showSettings = ref(false);
|
|||||||
</button>
|
</button>
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<button
|
<button
|
||||||
class="p-2 rounded-md transition-colors"
|
class="px-2 py-1 cursor-pointer rounded-md transition-colors"
|
||||||
:class="activeView === 'search' ? 'text-[#bf6a3b] bg-[#ede8de]' : 'text-[#b8b3a8] hover:text-[#5c574e] hover:bg-[#ede8de]'"
|
:class="activeView === 'search' ? 'text-[#bf6a3b] bg-[#ede8de]' : 'text-[#b8b3a8] hover:text-[#5c574e] hover:bg-[#ede8de]'"
|
||||||
title="搜索"
|
title="搜索"
|
||||||
@click="switchView('search')"
|
@click="switchView('search')"
|
||||||
@@ -84,7 +84,7 @@ const showSettings = ref(false);
|
|||||||
</button>
|
</button>
|
||||||
<!-- ===== Settings ===== -->
|
<!-- ===== Settings ===== -->
|
||||||
<button
|
<button
|
||||||
class="p-2 rounded-md transition-colors mt-auto"
|
class="px-2 py-1 cursor-pointer rounded-md transition-colors mt-auto"
|
||||||
:class="activeView === 'setting' ? 'text-[#bf6a3b] bg-[#ede8de]' : 'text-[#b8b3a8] hover:text-[#5c574e] hover:bg-[#ede8de]'"
|
:class="activeView === 'setting' ? 'text-[#bf6a3b] bg-[#ede8de]' : 'text-[#b8b3a8] hover:text-[#5c574e] hover:bg-[#ede8de]'"
|
||||||
title="设置"
|
title="设置"
|
||||||
@click="showSettings = true"
|
@click="showSettings = true"
|
||||||
@@ -116,7 +116,7 @@ const showSettings = ref(false);
|
|||||||
>
|
>
|
||||||
<p class="mb-3">暂无打开的文件夹</p>
|
<p class="mb-3">暂无打开的文件夹</p>
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md bg-[#fdf0e5] text-[#bf6a3b] border border-[#d4a574]/40 hover:bg-[#fdf0e5]/80 hover:text-[#bf6a3b] transition-colors"
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs cursor-pointer rounded-md bg-[#fdf0e5] text-[#bf6a3b] border border-[#d4a574]/40 hover:bg-[#fdf0e5]/80 hover:text-[#bf6a3b] transition-colors"
|
||||||
@click="$emit('openFolder')"
|
@click="$emit('openFolder')"
|
||||||
>
|
>
|
||||||
<i class="ri-folder-open-line"></i>
|
<i class="ri-folder-open-line"></i>
|
||||||
|
|||||||
Reference in New Issue
Block a user