功能修复

This commit is contained in:
2026-07-08 09:51:23 +08:00
parent 67dab593ca
commit 77fb49fedc
2 changed files with 497 additions and 34 deletions

View File

@@ -9,6 +9,8 @@ const content = ref("");
const wordCount = ref(0);
const charCount = ref(0);
type DocumentMode = "preview" | "edit" | "mixed";
// File state
const currentFilePath = ref<string | null>(null);
const saveStatus = ref("");
@@ -75,7 +77,7 @@ function onEditorWheel(e: WheelEvent) {
// ---- toolbar state ----
const showThumbnail = ref(true);
const previewOnly = ref(false);
const documentMode = ref<DocumentMode>("edit");
const showMoreMenu = ref(false);
function handleExportPdf() {
@@ -319,17 +321,33 @@ listen("menu-event", (event) => {
<!-- Right: Actions -->
<div class="flex items-center gap-0.5">
<!-- Preview/Edit toggle -->
<!-- Document mode -->
<div class="mode-toggle" role="group" aria-label="文档模式">
<button
class="toolbar-btn"
:class="{ 'toolbar-btn-active': !previewOnly }"
:title="previewOnly ? '切换到编辑模式' : '切换到预览模式'"
@click="previewOnly = !previewOnly"
class="mode-toggle-btn"
:class="{ 'mode-toggle-btn-active': documentMode === 'preview' }"
title="预览模式"
@click="documentMode = 'preview'"
>
<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>
<button
class="mode-toggle-btn"
:class="{ 'mode-toggle-btn-active': documentMode === 'edit' }"
title="编辑模式"
@click="documentMode = 'edit'"
>
编辑
</button>
<button
class="mode-toggle-btn"
:class="{ 'mode-toggle-btn-active': documentMode === 'mixed' }"
title="混合模式"
@click="documentMode = 'mixed'"
>
混合
</button>
</div>
<!-- More menu -->
<div class="relative">
@@ -363,7 +381,7 @@ listen("menu-event", (event) => {
</div>
<MarkdownEditor
:model-value="content"
:preview-only="previewOnly"
:document-mode="documentMode"
placeholder="输入 Markdown 内容... 支持标题、粗体、斜体、代码块等 ✨"
@update:model-value="onUpdate"
@save-shortcut="doSave"
@@ -443,6 +461,47 @@ listen("menu-event", (event) => {
background-color: #f4f1ea;
}
.mode-toggle {
display: inline-flex;
align-items: center;
height: 30px;
padding: 2px;
border: 1px solid #e0dbcf;
border-radius: 7px;
background-color: #f4f1ea;
flex-shrink: 0;
}
.mode-toggle-btn {
display: inline-flex;
align-items: center;
justify-content: center;
height: 24px;
min-width: 42px;
padding: 0 9px;
border: none;
border-radius: 5px;
background: transparent;
color: #8c877d;
font-size: 12px;
line-height: 1;
cursor: pointer;
transition:
background-color 0.15s,
color 0.15s,
box-shadow 0.15s;
}
.mode-toggle-btn:hover {
color: #38342e;
}
.mode-toggle-btn-active {
background-color: #fffdfa;
color: #bf6a3b;
box-shadow: 0 1px 3px rgba(56, 52, 46, 0.12);
}
.dropdown-menu {
position: absolute;
right: 0;

View File

@@ -1,5 +1,11 @@
<script setup lang="ts">
import { ref, watch, nextTick } from "vue";
import {
computed,
nextTick,
ref,
watch,
type ComponentPublicInstance,
} from "vue";
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkGfm from "remark-gfm";
@@ -7,15 +13,34 @@ import remarkRehype from "remark-rehype";
import rehypeStringify from "rehype-stringify";
import rehypeSanitize from "rehype-sanitize";
type DocumentMode = "preview" | "edit" | "mixed";
interface MarkdownAstNode {
position?: {
start?: { offset?: number };
end?: { offset?: number };
};
}
interface MarkdownBlock {
id: string;
source: string;
html: string;
startOffset: number;
endOffset: number;
}
const props = withDefaults(
defineProps<{
modelValue?: string;
placeholder?: string;
documentMode?: DocumentMode;
previewOnly?: boolean;
}>(),
{
modelValue: "",
placeholder: "开始写作...",
documentMode: undefined,
previewOnly: false,
},
);
@@ -35,10 +60,33 @@ const md2html = unified()
.use(rehypeSanitize)
.use(rehypeStringify);
const mdParser = unified().use(remarkParse).use(remarkGfm);
const activeDocumentMode = computed<DocumentMode>(() => {
return props.documentMode ?? (props.previewOnly ? "preview" : "edit");
});
// ── Rendering ──────────────────────────────────────────────────────
const renderedHtml = ref("");
const renderError = ref(false);
const markdownBlocks = ref<MarkdownBlock[]>([]);
const editorTextareaRef = ref<HTMLTextAreaElement>();
const scrollRef = ref<HTMLDivElement>();
const blockTextareaRef = ref<HTMLTextAreaElement | null>(null);
const EMPTY_BLOCK_ID = "__empty__";
const editingBlockId = ref<string | null>(null);
const editingSource = ref("");
const editingBaseValue = ref("");
const editingRange = ref<{ startOffset: number; endOffset: number } | null>(
null,
);
const pendingLocalModelUpdate = ref(false);
const isEditingEmptyDocument = computed(
() => editingBlockId.value === EMPTY_BLOCK_ID,
);
function renderFullHtml(md: string) {
try {
@@ -51,13 +99,86 @@ function renderFullHtml(md: string) {
}
}
function escapeHtml(value: string) {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function renderBlockHtml(source: string) {
try {
const file = md2html.processSync(source);
return String(file.value);
} catch {
return `<pre><code>${escapeHtml(source)}</code></pre>`;
}
}
function parseBlocks(md: string) {
if (!md.trim()) {
markdownBlocks.value = [];
return;
}
try {
const tree = mdParser.parse(md) as { children?: MarkdownAstNode[] };
const blocks = (tree.children ?? [])
.map((node, index): MarkdownBlock | null => {
const startOffset = node.position?.start?.offset;
const endOffset = node.position?.end?.offset;
if (
typeof startOffset !== "number" ||
typeof endOffset !== "number" ||
endOffset < startOffset
) {
return null;
}
const source = md.slice(startOffset, endOffset);
return {
id: `${index}-${startOffset}-${endOffset}`,
source,
html: renderBlockHtml(source),
startOffset,
endOffset,
};
})
.filter((block): block is MarkdownBlock => block !== null);
markdownBlocks.value = blocks;
} catch {
markdownBlocks.value = [
{
id: "fallback-0",
source: md,
html: renderBlockHtml(md),
startOffset: 0,
endOffset: md.length,
},
];
}
}
watch(
() => props.modelValue,
(val) => {
const shouldResetScroll = !pendingLocalModelUpdate.value;
pendingLocalModelUpdate.value = false;
renderFullHtml(val);
// 切换文件时重置滚动位置到顶部
parseBlocks(val);
if (editingBlockId.value && val !== editingBaseValue.value) {
cancelBlockEdit();
}
nextTick(() => {
if (scrollRef.value) scrollRef.value.scrollTop = 0;
resizeEditorTextarea();
// 外部切换文件时重置滚动位置;本组件内编辑时保留当前位置。
if (shouldResetScroll && scrollRef.value) scrollRef.value.scrollTop = 0;
});
},
{ immediate: true },
@@ -65,18 +186,54 @@ watch(
// ── Editor textarea handlers ───────────────────────────────────────
const editorTextareaRef = ref<HTMLTextAreaElement>();
const scrollRef = ref<HTMLDivElement>();
function onEditorInput(e: Event) {
const val = (e.target as HTMLTextAreaElement).value;
emit("update:modelValue", val);
renderFullHtml(val);
function setBlockTextareaRef(el: Element | ComponentPublicInstance | null) {
blockTextareaRef.value = el instanceof HTMLTextAreaElement ? el : null;
}
function tryAutoPair(
function resizeTextarea(ta: HTMLTextAreaElement) {
ta.style.height = "auto";
ta.style.height = `${ta.scrollHeight}px`;
}
function resizeEditorTextarea() {
const ta = editorTextareaRef.value;
if (!ta) return;
resizeTextarea(ta);
}
function focusBlockEditor() {
void nextTick(() => {
const ta = blockTextareaRef.value;
if (!ta) return;
resizeTextarea(ta);
ta.focus();
ta.selectionStart = ta.selectionEnd = ta.value.length;
});
}
function emitModelValue(value: string) {
pendingLocalModelUpdate.value = true;
emit("update:modelValue", value);
}
function applyFullEditorValue(value: string) {
emitModelValue(value);
renderFullHtml(value);
parseBlocks(value);
}
function onEditorInput(e: Event) {
const ta = e.target as HTMLTextAreaElement;
const val = ta.value;
applyFullEditorValue(val);
resizeTextarea(ta);
}
function tryAutoPairForValue(
ta: HTMLTextAreaElement,
key: string,
value: string,
onChange: (nextValue: string) => void,
): boolean {
const pairs: Record<string, string> = {
"(": ")",
@@ -91,16 +248,15 @@ function tryAutoPair(
const start = ta.selectionStart;
const end = ta.selectionEnd;
const selected = props.modelValue.substring(start, end);
const selected = value.substring(start, end);
const newVal =
props.modelValue.substring(0, start) +
value.substring(0, start) +
key +
selected +
close +
props.modelValue.substring(end);
value.substring(end);
emit("update:modelValue", newVal);
renderFullHtml(newVal);
onChange(newVal);
void nextTick(() => {
ta.selectionStart = start + 1;
@@ -110,6 +266,10 @@ function tryAutoPair(
return true;
}
function tryAutoPair(ta: HTMLTextAreaElement, key: string): boolean {
return tryAutoPairForValue(ta, key, props.modelValue, applyFullEditorValue);
}
function onEditorKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
@@ -127,8 +287,7 @@ function onEditorKeydown(e: KeyboardEvent) {
props.modelValue.substring(0, start) +
" " +
props.modelValue.substring(end);
emit("update:modelValue", newVal);
renderFullHtml(newVal);
applyFullEditorValue(newVal);
void nextTick(() => {
ta.selectionStart = ta.selectionEnd = start + 2;
ta.focus();
@@ -141,6 +300,121 @@ function onEditorKeydown(e: KeyboardEvent) {
return;
}
}
// ── Mixed mode block editing ───────────────────────────────────────
function startBlockEdit(block: MarkdownBlock) {
if (activeDocumentMode.value !== "mixed") return;
if (editingBlockId.value && editingBlockId.value !== block.id) {
finalizeBlockEdit();
}
editingBlockId.value = block.id;
editingSource.value = block.source;
editingBaseValue.value = props.modelValue;
editingRange.value = {
startOffset: block.startOffset,
endOffset: block.endOffset,
};
focusBlockEditor();
}
function startEmptyBlockEdit() {
if (activeDocumentMode.value !== "mixed") return;
editingBlockId.value = EMPTY_BLOCK_ID;
editingSource.value = props.modelValue;
editingBaseValue.value = props.modelValue;
editingRange.value = {
startOffset: 0,
endOffset: props.modelValue.length,
};
focusBlockEditor();
}
function cancelBlockEdit() {
editingBlockId.value = null;
editingSource.value = "";
editingBaseValue.value = "";
editingRange.value = null;
}
function finalizeBlockEdit() {
const range = editingRange.value;
if (!editingBlockId.value || !range) {
cancelBlockEdit();
return;
}
const nextValue =
props.modelValue.slice(0, range.startOffset) +
editingSource.value +
props.modelValue.slice(range.endOffset);
cancelBlockEdit();
if (nextValue !== props.modelValue) {
emitModelValue(nextValue);
renderFullHtml(nextValue);
parseBlocks(nextValue);
}
}
function onBlockInput(e: Event) {
const ta = e.target as HTMLTextAreaElement;
editingSource.value = ta.value;
resizeTextarea(ta);
}
function onBlockKeydown(e: KeyboardEvent) {
const ta = e.target as HTMLTextAreaElement;
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
finalizeBlockEdit();
emit("save-shortcut");
return;
}
if (e.key === "Escape") {
e.preventDefault();
cancelBlockEdit();
return;
}
if (e.key === "Tab") {
e.preventDefault();
const start = ta.selectionStart;
const end = ta.selectionEnd;
editingSource.value =
editingSource.value.substring(0, start) +
" " +
editingSource.value.substring(end);
void nextTick(() => {
ta.selectionStart = ta.selectionEnd = start + 2;
resizeTextarea(ta);
ta.focus();
});
return;
}
if (
tryAutoPairForValue(ta, e.key, editingSource.value, (nextValue) => {
editingSource.value = nextValue;
void nextTick(() => resizeTextarea(ta));
})
) {
e.preventDefault();
}
}
watch(activeDocumentMode, (mode, oldMode) => {
if (oldMode === "mixed" && mode !== "mixed" && editingBlockId.value) {
finalizeBlockEdit();
}
if (mode === "edit") {
void nextTick(resizeEditorTextarea);
}
});
</script>
<template>
@@ -158,12 +432,13 @@ function onEditorKeydown(e: KeyboardEvent) {
>
<!-- Editor mode -->
<textarea
v-if="!previewOnly"
v-if="activeDocumentMode === 'edit'"
ref="editorTextareaRef"
:value="props.modelValue"
:placeholder="props.placeholder"
class="
w-full h-full min-h-[60vh] resize-none bg-transparent outline-none
markdown-source-editor
w-full min-h-[60vh] resize-none overflow-hidden bg-transparent outline-none
font-serif text-[#38342e] leading-relaxed
placeholder:text-[#b8b3a8]
"
@@ -172,12 +447,68 @@ function onEditorKeydown(e: KeyboardEvent) {
/>
<!-- Preview mode -->
<div v-else class="min-h-[60vh]">
<div v-else-if="activeDocumentMode === 'preview'" 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>
<!-- Mixed mode -->
<div v-else class="min-h-[60vh] markdown-preview markdown-mixed">
<div v-if="renderError" class="text-red-500 text-sm">
Markdown 解析错误
</div>
<textarea
v-else-if="isEditingEmptyDocument"
:ref="setBlockTextareaRef"
:value="editingSource"
:placeholder="props.placeholder"
class="markdown-block-editor"
@input="onBlockInput"
@keydown="onBlockKeydown"
@blur="finalizeBlockEdit"
/>
<div
v-else-if="!props.modelValue.trim()"
class="markdown-empty-placeholder"
role="button"
tabindex="0"
@click="startEmptyBlockEdit"
@keydown.enter.prevent="startEmptyBlockEdit"
@keydown.space.prevent="startEmptyBlockEdit"
>
{{ props.placeholder }}
</div>
<template v-else>
<div
v-for="block in markdownBlocks"
:key="block.id"
class="markdown-block"
:class="{ 'markdown-block-editing': editingBlockId === block.id }"
>
<textarea
v-if="editingBlockId === block.id"
:ref="setBlockTextareaRef"
:value="editingSource"
class="markdown-block-editor"
@input="onBlockInput"
@keydown="onBlockKeydown"
@blur="finalizeBlockEdit"
/>
<div
v-else
class="markdown-block-preview"
role="button"
tabindex="0"
v-html="block.html"
@click="startBlockEdit(block)"
@keydown.enter.prevent="startBlockEdit(block)"
@keydown.space.prevent="startBlockEdit(block)"
/>
</div>
</template>
</div>
</div>
</div>
</template>
@@ -194,6 +525,15 @@ function onEditorKeydown(e: KeyboardEvent) {
.markdown-preview > *:first-child { margin-top: 0; }
.markdown-source-editor {
display: block;
height: auto;
}
.markdown-mixed > .markdown-block:first-child .markdown-block-preview > *:first-child {
margin-top: 0;
}
.markdown-preview h1,
.markdown-preview h2,
.markdown-preview h3,
@@ -293,4 +633,68 @@ function onEditorKeydown(e: KeyboardEvent) {
border-radius: 0.25rem;
margin: 0.5em 0;
}
/* ── Mixed mode block editing ────────────────────────────────────── */
.markdown-block {
margin: -1px -0.375rem;
padding: 1px 0.375rem;
border-radius: 0.375rem;
transition:
background-color 0.15s ease,
box-shadow 0.15s ease;
}
.markdown-block:hover {
background-color: #f4f1ea;
cursor: text;
}
.markdown-block-editing,
.markdown-block-editing:hover {
background-color: transparent;
}
.markdown-block-preview {
min-height: 1.625em;
outline: none;
}
.markdown-block-preview:focus-visible,
.markdown-empty-placeholder:focus-visible {
outline: 2px solid #d6aa8e;
outline-offset: 2px;
border-radius: 0.25rem;
}
.markdown-block-editor {
display: block;
width: 100%;
min-height: 4rem;
resize: none;
overflow: hidden;
border: 1px solid #d6aa8e;
border-radius: 0.375rem;
background-color: #fffdfa;
color: #38342e;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.92em;
line-height: 1.6;
margin: 0.375rem 0;
padding: 0.75rem 0.875rem;
outline: none;
box-shadow: 0 0 0 3px rgba(191, 106, 59, 0.08);
}
.markdown-block-editor::placeholder,
.markdown-empty-placeholder {
color: #b8b3a8;
}
.markdown-empty-placeholder {
min-height: 12rem;
padding-top: 0.25rem;
cursor: text;
outline: none;
}
</style>