功能修复

This commit is contained in:
2026-07-07 21:18:21 +08:00
parent 13a0e56a38
commit 67dab593ca
5 changed files with 39 additions and 443 deletions

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import {ref, watch, onMounted, onUnmounted, computed} from "vue";
import {ref, watch, onMounted, onUnmounted} from "vue";
import {listen} from "@tauri-apps/api/event";
import {invoke} from "@tauri-apps/api/core";
import MarkdownEditor from "./components/MarkdownEditor.vue";
@@ -13,11 +13,6 @@ const charCount = ref(0);
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;
@@ -78,68 +73,11 @@ function onEditorWheel(e: WheelEvent) {
}, 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
@@ -347,22 +285,17 @@ listen("menu-event", (event) => {
: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"
class="flex w-full items-center h-10 px-3 bg-[#faf9f6] border-b border-[#e0dbcf] select-none shrink-0"
>
<!-- Left: Thumbnail toggle -->
<button
@@ -386,26 +319,6 @@ listen("menu-event", (event) => {
<!-- 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"
@@ -418,18 +331,6 @@ listen("menu-event", (event) => {
</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
@@ -462,8 +363,6 @@ listen("menu-event", (event) => {
</div>
<MarkdownEditor
:model-value="content"
:is-full-width="isFullWidth"
:display-mode="displayMode"
:preview-only="previewOnly"
placeholder="输入 Markdown 内容... 支持标题、粗体、斜体、代码块等 ✨"
@update:model-value="onUpdate"

View File

@@ -15,16 +15,10 @@ const props = withDefaults(
entries: DirEntry[];
content?: string;
currentFilePath?: string | null;
isFullWidth?: boolean;
displayMode?: "split" | "wysiwyg";
isMarkdownFile?: boolean;
}>(),
{
content: "",
currentFilePath: null,
isFullWidth: false,
displayMode: "split",
isMarkdownFile: false,
},
);
@@ -35,8 +29,6 @@ const emit = defineEmits<{
openFile: [path: string];
createFile: [];
createFolder: [];
toggleFullWidth: [value: boolean];
toggleDisplayMode: [value: "split" | "wysiwyg"];
}>();
// Wrap the workspace root as a top-level tree node
@@ -254,57 +246,6 @@ const showSettings = ref(false);
</button>
</div>
<div class="px-4 py-6 space-y-5">
<!-- Display mode toggle (only for .md files) -->
<div v-if="props.isMarkdownFile" class="flex items-center justify-between">
<div class="flex-1 mr-3">
<p class="text-sm text-[#38342e] font-medium">编辑模式</p>
<p class="text-xs text-[#8c877d] mt-0.5 leading-relaxed">
{{ props.displayMode === "wysiwyg" ? "单栏所见即所得预览区直接编辑" : "双栏左侧编辑源码右侧预览效果" }}
</p>
</div>
<button
type="button"
role="switch"
:aria-checked="props.displayMode === 'wysiwyg'"
:class="[
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
props.displayMode === 'wysiwyg' ? 'bg-[#bf6a3b]' : 'bg-[#d4cfc4]',
]"
@click="emit('toggleDisplayMode', props.displayMode === 'wysiwyg' ? 'split' : 'wysiwyg')"
>
<span
:class="[
'pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-0 transition-transform duration-200 ease-in-out',
props.displayMode === 'wysiwyg' ? 'translate-x-4' : 'translate-x-0',
]"
/>
</button>
</div>
<!-- Full-width mode toggle -->
<div class="flex items-center justify-between">
<div class="flex-1 mr-3">
<p class="text-sm text-[#38342e] font-medium">全宽模式</p>
<p class="text-xs text-[#8c877d] mt-0.5 leading-relaxed">关闭后编辑器居中显示,适合专注书写</p>
</div>
<button
type="button"
role="switch"
:aria-checked="props.isFullWidth"
:class="[
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
props.isFullWidth ? 'bg-[#bf6a3b]' : 'bg-[#d4cfc4]',
]"
@click="emit('toggleFullWidth', !props.isFullWidth)"
>
<span
:class="[
'pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-0 transition-transform duration-200 ease-in-out',
props.isFullWidth ? 'translate-x-4' : 'translate-x-0',
]"
/>
</button>
</div>
</div>
</div>
</div>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, watch, nextTick, computed } from "vue";
import { ref, watch, nextTick } from "vue";
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkGfm from "remark-gfm";
@@ -11,15 +11,11 @@ const props = withDefaults(
defineProps<{
modelValue?: string;
placeholder?: string;
isFullWidth?: boolean;
displayMode?: "split" | "wysiwyg";
previewOnly?: boolean;
}>(),
{
modelValue: "",
placeholder: "开始写作...",
isFullWidth: false,
displayMode: "split",
previewOnly: false,
},
);
@@ -29,8 +25,6 @@ const emit = defineEmits<{
"save-shortcut": [];
}>();
const isSingle = computed(() => props.displayMode === "wysiwyg");
// ── Unified processors ─────────────────────────────────────────────
/** Full pipeline: markdown → HTML */
@@ -41,10 +35,7 @@ const md2html = unified()
.use(rehypeSanitize)
.use(rehypeStringify);
/** Parser only: markdown → mdast (for block extraction) */
const mdParser = unified().use(remarkParse).use(remarkGfm);
// ── Split-pane rendering ───────────────────────────────────────────
// ── Rendering ──────────────────────────────────────────────────────
const renderedHtml = ref("");
const renderError = ref(false);
@@ -63,169 +54,21 @@ function renderFullHtml(md: string) {
watch(
() => props.modelValue,
(val) => {
if (!isSingle.value) renderFullHtml(val);
renderFullHtml(val);
// 切换文件时重置滚动位置到顶部
nextTick(() => {
if (previewRef.value) previewRef.value.scrollTop = 0;
if (splitTextareaRef.value) splitTextareaRef.value.scrollTop = 0;
if (scrollRef.value) scrollRef.value.scrollTop = 0;
});
},
{ immediate: true },
);
watch(isSingle, (single) => {
if (!single) renderFullHtml(props.modelValue);
});
// ── Editor textarea handlers ───────────────────────────────────────
// ── Block-level editing (single-pane mode) ─────────────────────────
const editorTextareaRef = ref<HTMLTextAreaElement>();
const scrollRef = ref<HTMLDivElement>();
interface Block {
id: string;
source: string;
html: string;
startOffset: number;
endOffset: number;
}
const blocks = ref<Block[]>([]);
const editingBlockId = ref<string | null>(null);
const editingSource = ref("");
/** Parse markdown into editable blocks */
function parseBlocks(md: string) {
if (!md.trim()) {
blocks.value = [];
return;
}
try {
const tree = mdParser.parse(md);
blocks.value = (tree.children as any[])
.filter((c: any) => c.position?.start?.offset != null)
.map((c: any, i: number) => {
const source = md.slice(
c.position.start.offset!,
c.position.end.offset!,
);
let html = "";
try {
const file = md2html.processSync(source);
html = String(file.value);
} catch {
html = `<p>${escapeHtml(source)}</p>`;
}
return {
id: `b${i}`,
source,
html,
startOffset: c.position.start.offset!,
endOffset: c.position.end.offset!,
};
});
} catch {
blocks.value = [];
}
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
const singlePaneRef = ref<HTMLDivElement>();
// Watch modelValue → re-parse blocks in single mode
watch(
() => props.modelValue,
(val) => {
if (isSingle.value) parseBlocks(val);
// 切换文件时重置滚动位置到顶部
nextTick(() => {
if (singlePaneRef.value) singlePaneRef.value.scrollTop = 0;
});
},
{ immediate: true },
);
watch(isSingle, (single) => {
if (single) parseBlocks(props.modelValue);
});
// ── Block editing actions ──────────────────────────────────────────
function startEdit(blockId: string) {
const block = blocks.value.find((b) => b.id === blockId);
if (!block) return;
editingBlockId.value = blockId;
editingSource.value = block.source;
void nextTick(() => {
const ta = document.querySelector(
".block-editor-textarea",
) as HTMLTextAreaElement | null;
if (ta) {
autoResize(ta);
ta.focus();
// Place cursor at end
ta.setSelectionRange(ta.value.length, ta.value.length);
}
});
}
function finalizeEdit() {
const blockId = editingBlockId.value;
if (!blockId) return;
const block = blocks.value.find((b) => b.id === blockId);
editingBlockId.value = null;
if (!block) return;
const newSource = editingSource.value;
if (newSource === block.source) return; // no change
// Replace the block's portion in the full markdown
const before = props.modelValue.slice(0, block.startOffset);
const after = props.modelValue.slice(block.endOffset);
emit("update:modelValue", before + newSource + after);
}
function cancelEdit() {
editingBlockId.value = null;
}
function onBlockKeydown(e: KeyboardEvent) {
// Ctrl+S save
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
// Finalize current edit first, then save
if (editingBlockId.value) finalizeEdit();
emit("save-shortcut");
return;
}
// Escape to cancel
if (e.key === "Escape") {
e.preventDefault();
cancelEdit();
return;
}
// Auto-resize on any key
void nextTick(() => {
const ta = e.target as HTMLTextAreaElement;
autoResize(ta);
});
}
function autoResize(ta: HTMLTextAreaElement) {
ta.style.height = "auto";
ta.style.height = ta.scrollHeight + 2 + "px";
}
// ── Split-pane textarea handlers ───────────────────────────────────
const splitTextareaRef = ref<HTMLTextAreaElement>();
function onSplitInput(e: Event) {
function onEditorInput(e: Event) {
const val = (e.target as HTMLTextAreaElement).value;
emit("update:modelValue", val);
renderFullHtml(val);
@@ -267,7 +110,7 @@ function tryAutoPair(
return true;
}
function onSplitKeydown(e: KeyboardEvent) {
function onEditorKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
emit("save-shortcut");
@@ -298,122 +141,44 @@ function onSplitKeydown(e: KeyboardEvent) {
return;
}
}
// ── Scroll sync (split mode) ───────────────────────────────────────
const previewRef = ref<HTMLDivElement>();
function onSplitScroll() {
if (!splitTextareaRef.value || !previewRef.value) return;
const ratio =
splitTextareaRef.value.scrollTop /
(splitTextareaRef.value.scrollHeight -
splitTextareaRef.value.clientHeight || 1);
previewRef.value.scrollTop =
ratio *
(previewRef.value.scrollHeight - previewRef.value.clientHeight);
}
</script>
<template>
<div
:class="[
'flex flex-1 w-full border border-[#e0dbcf] bg-white shadow-2xl overflow-hidden',
!props.isFullWidth && 'mx-auto',
]"
:style="
!props.isFullWidth
? { maxWidth: `calc(1600px * var(--editor-zoom, 1))` }
: undefined
"
ref="scrollRef"
class="flex-1 w-full overflow-auto"
@wheel.passive
>
<!-- Single-pane: block editing -->
<div
v-if="isSingle"
ref="singlePaneRef"
class="flex-1 overflow-auto p-8 pb-40"
:style="{ fontSize: `calc(1rem * var(--editor-zoom, 1))` }"
class="mx-auto px-8 pb-40 pt-8 h-full"
:style="{
maxWidth: `calc(700px * var(--editor-zoom, 1))`,
fontSize: `calc(1rem * var(--editor-zoom, 1))`,
}"
>
<!-- Empty state -->
<div
v-if="blocks.length === 0"
class="text-[#b8b3a8] font-serif leading-relaxed select-none"
@click="emit('update:modelValue', '\n'); parseBlocks('\n')"
>
{{ props.placeholder }}
</div>
<!-- Editor mode -->
<textarea
v-if="!previewOnly"
ref="editorTextareaRef"
:value="props.modelValue"
:placeholder="props.placeholder"
class="
w-full h-full min-h-[60vh] resize-none bg-transparent outline-none
font-serif text-[#38342e] leading-relaxed
placeholder:text-[#b8b3a8]
"
@input="onEditorInput"
@keydown="onEditorKeydown"
/>
<!-- Blocks -->
<template v-for="block in blocks" :key="block.id">
<!-- Editing state -->
<textarea
v-if="editingBlockId === block.id"
v-model="editingSource"
class="
block-editor-textarea
w-full resize-none outline-none
font-mono text-sm text-[#38342e] leading-relaxed
bg-[#faf9f6] border border-[#bf6a3b] rounded-md
px-3 py-2 mb-1
"
rows="1"
@keydown="onBlockKeydown"
@blur="finalizeEdit"
/>
<!-- Rendered state (clickable) -->
<div
v-else
:class="[
'markdown-preview block-render py-px',
previewOnly ? '' : 'cursor-text rounded -mx-1 px-1 hover:bg-[#faf9f6] transition-colors',
]"
v-html="block.html"
@click="!previewOnly && startEdit(block.id)"
/>
</template>
</div>
<!-- Split-pane mode -->
<template v-else>
<!-- Textarea column: smoothly collapses width on preview-only -->
<div
:class="[
'flex flex-col min-h-0 transition-[width,opacity] duration-300 ease-in-out',
previewOnly
? 'w-0 overflow-hidden opacity-0 border-r-0'
: 'flex-1 min-w-0 border-r border-[#e0dbcf] opacity-100',
]"
>
<textarea
ref="splitTextareaRef"
:value="props.modelValue"
:placeholder="props.placeholder"
class="
flex-1 min-h-0 w-full resize-none bg-white p-8 pb-40 outline-none
font-serif text-[#38342e] leading-relaxed
placeholder:text-[#b8b3a8]
"
:style="{ fontSize: `calc(1rem * var(--editor-zoom, 1))` }"
@input="onSplitInput"
@keydown="onSplitKeydown"
@scroll="onSplitScroll"
/>
</div>
<!-- Preview column: smoothly expands to fill available space -->
<div
ref="previewRef"
class="flex-1 min-w-0 min-h-0 overflow-auto p-8 pb-40 transition-[width] duration-300 ease-in-out"
:style="{ fontSize: `calc(1rem * var(--editor-zoom, 1))` }"
>
<!-- Preview mode -->
<div v-else class="min-h-[60vh]">
<div v-if="renderError" class="text-red-500 text-sm">
Markdown 解析错误
</div>
<div v-else class="markdown-preview" v-html="renderedHtml" />
</div>
</template>
</div>
</div>
</template>
@@ -528,15 +293,4 @@ function onSplitScroll() {
border-radius: 0.25rem;
margin: 0.5em 0;
}
/* ── Block editing textarea ────────────────────────────────────── */
.block-editor-textarea {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
min-height: 2em;
scrollbar-width: none;
}
.block-editor-textarea::-webkit-scrollbar {
display: none;
}
</style>