功能修复

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, 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>