582 lines
17 KiB
Vue
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 
|
|
.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>
|
|
|
|
|