Files
yurou/src/App.vue
2026-07-07 17:34:48 +08:00

582 lines
17 KiB
Vue

<script setup lang="ts">
import {ref, watch, onMounted, onUnmounted, computed} 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";
const content = ref("");
const wordCount = ref(0);
const charCount = ref(0);
// File state
const currentFilePath = ref<string | null>(null);
const saveStatus = ref("");
// Check if current file is a Markdown file (for display-mode toggle)
const isMarkdownFile = computed(() => {
return currentFilePath.value?.toLowerCase().endsWith(".md") ?? false;
});
// Folder state
interface DirEntry {
name: string;
path: string;
is_dir: boolean;
children?: DirEntry[];
}
const folderPath = ref("");
const dirEntries = ref<DirEntry[]>([]);
// ---- editor zoom (Ctrl+scroll) ----
const ZOOM_MIN = 0.7;
const ZOOM_MAX = 1.5;
const ZOOM_STEP = 0.1;
function loadZoomPref(): number {
try {
const stored = localStorage.getItem("editor-zoom");
if (stored) {
const val = parseFloat(stored);
if (!isNaN(val) && val >= ZOOM_MIN && val <= ZOOM_MAX) return val;
}
} catch { /* localStorage unavailable */
}
return 1.0;
}
const editorZoom = ref(loadZoomPref());
const showZoomIndicator = ref(false);
let zoomIndicatorTimer: ReturnType<typeof setTimeout> | null = null;
function applyZoomCssVars() {
document.documentElement.style.setProperty("--editor-zoom", String(editorZoom.value));
}
function saveZoomPref() {
try {
localStorage.setItem("editor-zoom", String(editorZoom.value));
} catch { /* noop */
}
}
function onEditorWheel(e: WheelEvent) {
if (!e.ctrlKey && !e.metaKey) return;
e.preventDefault();
const delta = e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP;
const next = Math.round((editorZoom.value + delta) * 10) / 10;
editorZoom.value = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, next));
applyZoomCssVars();
saveZoomPref();
// Show zoom indicator and reset timer
showZoomIndicator.value = true;
if (zoomIndicatorTimer) clearTimeout(zoomIndicatorTimer);
zoomIndicatorTimer = setTimeout(() => {
showZoomIndicator.value = false;
}, 1500);
}
// ---- full-width mode ----
function loadFullWidthPref(): boolean {
try {
return localStorage.getItem("editor-full-width") === "true";
} catch {
return false;
}
}
const isFullWidth = ref(loadFullWidthPref());
function onToggleFullWidth(val: boolean) {
isFullWidth.value = val;
try {
localStorage.setItem("editor-full-width", String(val));
} catch { /* noop */
}
}
// ---- display mode (split / wysiwyg) ----
type DisplayMode = "split" | "wysiwyg";
function loadDisplayModePref(): DisplayMode {
try {
const v = localStorage.getItem("editor-display-mode");
if (v === "split" || v === "wysiwyg") return v;
} catch { /* noop */
}
return "split";
}
const displayMode = ref<DisplayMode>(loadDisplayModePref());
function onToggleDisplayMode(val: DisplayMode) {
displayMode.value = val;
try {
localStorage.setItem("editor-display-mode", val);
} catch { /* noop */
}
}
// ---- toolbar state ----
const showThumbnail = ref(true);
const previewOnly = ref(false);
const showMoreMenu = ref(false);
function toggleFullWidth() {
isFullWidth.value = !isFullWidth.value;
try {
localStorage.setItem("editor-full-width", String(isFullWidth.value));
} catch { /* noop */
}
}
function toggleDisplayMode() {
displayMode.value = displayMode.value === "split" ? "wysiwyg" : "split";
try {
localStorage.setItem("editor-display-mode", displayMode.value);
} catch { /* noop */
}
}
function handleExportPdf() {
showMoreMenu.value = false;
// TODO: implement PDF export
}
function closeMoreMenu() {
showMoreMenu.value = false;
}
watch(showMoreMenu, (val) => {
if (val) {
setTimeout(() => {
document.addEventListener("click", closeMoreMenu, {once: true});
}, 0);
}
});
onMounted(() => {
applyZoomCssVars();
window.addEventListener("wheel", onEditorWheel, {passive: false});
});
onUnmounted(() => {
window.removeEventListener("wheel", onEditorWheel);
if (zoomIndicatorTimer) clearTimeout(zoomIndicatorTimer);
});
function stripMarkdown(md: string): string {
return md
// Remove images ![alt](url)
.replace(/!\[[^\]]*\]\([^)]*\)/g, "")
// Convert links [text](url) to just text
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
// Remove heading markers
.replace(/^#{1,6}\s+/gm, "")
// Remove bold/italic markers
.replace(/\*{1,3}|_{1,3}/g, "")
// Remove strikethrough
.replace(/~~/g, "")
// Remove inline code markers
.replace(/`+/g, "")
// Remove blockquote markers
.replace(/^>\s?/gm, "")
// Remove list markers (-, *, +, or 1.)
.replace(/^[\s]*[-*+]\s+/gm, "")
.replace(/^[\s]*\d+\.\s+/gm, "")
// Remove horizontal rules
.replace(/^[-*_]{3,}\s*$/gm, "")
.trim();
}
function onUpdate(md: string) {
content.value = md;
const text = stripMarkdown(md);
charCount.value = text.length;
wordCount.value = text.trim() ? text.trim().split(/\s+/).length : 0;
}
function onHeadingClick(heading: { text: string; level: number }) {
const selector = `.markdown-preview h${heading.level}`;
const headingEls = document.querySelectorAll(selector);
for (const el of headingEls) {
if (el.textContent?.trim() === heading.text) {
el.scrollIntoView({behavior: "smooth", block: "start"});
break;
}
}
}
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 = [];
}
}
async function onOpenFolder() {
try {
const chosen = await invoke<string | null>("pick_folder");
if (chosen) {
await loadDir(chosen);
}
} catch (e) {
console.error("Failed to pick folder:", e);
}
}
function getFileName(path: string): string {
return path.split(/[/\\]/).pop() || path;
}
// ---- file tree helpers ----
function findEntry(entries: DirEntry[], path: string): DirEntry | null {
for (const entry of entries) {
if (entry.path === path) return entry;
if (entry.children) {
const found = findEntry(entry.children, path);
if (found) return found;
}
}
return null;
}
async function onToggleFolder(path: string) {
const entry = findEntry(dirEntries.value, path);
if (!entry || entry.children !== undefined) return;
try {
const children = await invoke<DirEntry[]>("read_dir", {path});
entry.children = children;
} catch (e) {
console.error("Failed to read subdirectory:", e);
}
}
async function onOpenFile(path: string) {
try {
const fileContent = await invoke<string>("read_file", {path});
content.value = fileContent;
currentFilePath.value = path;
saveStatus.value = "";
} catch (e) {
console.error("Failed to open file:", e);
}
}
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("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="h-screen bg-[#faf9f6] text-[#38342e] flex flex-col">
<!-- Three-column body -->
<div class="flex-1 flex overflow-hidden bg-[#faf9f6]">
<!-- Left: Directory sidebar -->
<DirectorySidebar
:folder-path="folderPath"
:entries="dirEntries"
:content="content"
:current-file-path="currentFilePath"
:is-full-width="isFullWidth"
:display-mode="displayMode"
:is-markdown-file="isMarkdownFile"
@heading-click="onHeadingClick"
@open-folder="onOpenFolder"
@toggle-folder="onToggleFolder"
@open-file="onOpenFile"
@toggle-full-width="onToggleFullWidth"
@toggle-display-mode="onToggleDisplayMode"
/>
<!-- Right: Editor area -->
<main class="flex-1 flex flex-col overflow-hidden relative">
<!-- Toolbar -->
<div
class="flex w-full items-center h-10 px-3 bg-[#faf9f6] border border-[#e0dbcf] border-b-0 select-none shrink-0"
>
<!-- Left: Thumbnail toggle -->
<button
class="toolbar-btn"
:class="{ 'toolbar-btn-active': showThumbnail }"
title="切换缩略图"
@click="showThumbnail = !showThumbnail"
>
<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>
</button>
<!-- Center: Filename -->
<div class="flex-1 text-center text-sm text-[#8c877d] font-medium truncate px-3 select-none">
{{ currentFilePath ? getFileName(currentFilePath) : '未命名文档' }}
</div>
<!-- Right: Actions -->
<div class="flex items-center gap-0.5">
<!-- Display mode toggle (only for .md files) -->
<button
v-if="isMarkdownFile"
class="toolbar-btn"
:title="displayMode === 'wysiwyg' ? '切换到双栏' : '切换到单栏'"
@click="toggleDisplayMode"
>
<!-- Split icon (shown when single, meaning click to switch to split) -->
<svg v-if="displayMode === 'wysiwyg'" width="16" height="16" viewBox="0 0 16 16" fill="none"
stroke="currentColor" stroke-width="1.5">
<rect x="1" y="2" width="6" height="12" rx="1"/>
<rect x="9" y="2" width="6" height="12" rx="1"/>
</svg>
<!-- Single icon -->
<svg v-else width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor"
stroke-width="1.5">
<rect x="3" y="2" width="10" height="12" rx="1"/>
</svg>
</button>
<!-- Preview/Edit toggle -->
<button
class="toolbar-btn"
:class="{ 'toolbar-btn-active': !previewOnly }"
:title="previewOnly ? '切换到编辑模式' : '切换到预览模式'"
@click="previewOnly = !previewOnly"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M11.5 1.5l3 3L5 14H2v-3L11.5 1.5z"/>
</svg>
</button>
<!-- Full-width toggle -->
<button
class="toolbar-btn"
:class="{ 'toolbar-btn-active': isFullWidth }"
title="切换全宽"
@click="toggleFullWidth"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M10 1h5v5M6 15H1v-5M15 1l-5 5M1 15l5-5"/>
</svg>
</button>
<!-- More menu -->
<div class="relative">
<button
class="toolbar-btn"
title="更多"
@click="showMoreMenu = !showMoreMenu"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<circle cx="3" cy="8" r="1.5"/>
<circle cx="8" cy="8" r="1.5"/>
<circle cx="13" cy="8" r="1.5"/>
</svg>
</button>
<div
v-if="showMoreMenu"
class="dropdown-menu"
@click.stop
>
<button class="dropdown-item" @click="handleExportPdf">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M5 1h6l4 4v9a1 1 0 01-1 1H2a1 1 0 01-1-1V2a1 1 0 011-1z"/>
<path d="M5 1v4h4"/>
<path d="M5 9h6M5 12h4"/>
</svg>
导出 PDF
</button>
</div>
</div>
</div>
</div>
<MarkdownEditor
:model-value="content"
:is-full-width="isFullWidth"
:display-mode="displayMode"
:preview-only="previewOnly"
placeholder="输入 Markdown 内容... 支持标题、粗体、斜体、代码块等 ✨"
@update:model-value="onUpdate"
@save-shortcut="doSave"
class="flex-1 w-full"
/>
<!-- Zoom indicator -->
<Transition name="zoom-fade">
<div
v-if="showZoomIndicator"
class="fixed top-4 right-4 z-50 px-2.5 py-1 rounded-md bg-[#38342e]/80 text-white text-xs font-medium pointer-events-none select-none backdrop-blur-sm"
>
{{ Math.round(editorZoom * 100) }}%
</div>
</Transition>
</main>
</div>
<footer class="border-t border-[#e8e4da] bg-[#f4f1eb]/60 backdrop-blur-sm">
<div class="flex items-center gap-4 px-4 py-1.5">
<!-- Current file / save status -->
<span
v-if="saveStatus"
class="text-xs"
:class="saveStatus.startsWith('保存失败') ? 'text-red-500' : 'text-emerald-600'"
>{{ saveStatus }}</span>
<span v-else-if="currentFilePath" class="text-xs text-[#8c877d]">
{{ currentFilePath.split(/[/\\]/).pop() }}
</span>
<span v-else class="text-xs text-[#b8b3a8]">未保存</span>
<span class="flex-1"/>
<span class="text-xs text-[#8c877d]">{{ wordCount.toLocaleString() }} </span>
<span class="w-px h-3 bg-[#d4cfc4]"/>
<span class="text-xs text-[#8c877d]">{{ charCount.toLocaleString() }} </span>
</div>
</footer>
</div>
</template>
<style>
.zoom-fade-enter-active,
.zoom-fade-leave-active {
transition: opacity 0.2s ease;
}
.zoom-fade-enter-from,
.zoom-fade-leave-to {
opacity: 0;
}
/* ── Toolbar ───────────────────────────────────────────────────── */
.toolbar-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 6px;
color: #8c877d;
background: transparent;
border: none;
cursor: pointer;
transition: background-color 0.15s, color 0.15s;
flex-shrink: 0;
}
.toolbar-btn:hover {
background-color: #ede8dd;
color: #38342e;
}
.toolbar-btn-active {
color: #bf6a3b;
background-color: #f4f1ea;
}
.dropdown-menu {
position: absolute;
right: 0;
top: 100%;
margin-top: 4px;
background: white;
border: 1px solid #e0dbcf;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
min-width: 140px;
padding: 4px;
z-index: 50;
}
.dropdown-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 10px;
border-radius: 6px;
font-size: 13px;
color: #38342e;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
}
.dropdown-item:hover {
background-color: #f4f1ea;
}
</style>