功能修复
This commit is contained in:
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user