297 lines
7.2 KiB
Vue
297 lines
7.2 KiB
Vue
<script setup lang="ts">
|
|
import { ref, watch, nextTick } from "vue";
|
|
import { unified } from "unified";
|
|
import remarkParse from "remark-parse";
|
|
import remarkGfm from "remark-gfm";
|
|
import remarkRehype from "remark-rehype";
|
|
import rehypeStringify from "rehype-stringify";
|
|
import rehypeSanitize from "rehype-sanitize";
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
modelValue?: string;
|
|
placeholder?: string;
|
|
previewOnly?: boolean;
|
|
}>(),
|
|
{
|
|
modelValue: "",
|
|
placeholder: "开始写作...",
|
|
previewOnly: false,
|
|
},
|
|
);
|
|
|
|
const emit = defineEmits<{
|
|
"update:modelValue": [value: string];
|
|
"save-shortcut": [];
|
|
}>();
|
|
|
|
// ── Unified processors ─────────────────────────────────────────────
|
|
|
|
/** Full pipeline: markdown → HTML */
|
|
const md2html = unified()
|
|
.use(remarkParse)
|
|
.use(remarkGfm)
|
|
.use(remarkRehype, { allowDangerousHtml: false })
|
|
.use(rehypeSanitize)
|
|
.use(rehypeStringify);
|
|
|
|
// ── Rendering ──────────────────────────────────────────────────────
|
|
|
|
const renderedHtml = ref("");
|
|
const renderError = ref(false);
|
|
|
|
function renderFullHtml(md: string) {
|
|
try {
|
|
const file = md2html.processSync(md);
|
|
renderedHtml.value = String(file.value);
|
|
renderError.value = false;
|
|
} catch {
|
|
renderedHtml.value = "";
|
|
renderError.value = true;
|
|
}
|
|
}
|
|
|
|
watch(
|
|
() => props.modelValue,
|
|
(val) => {
|
|
renderFullHtml(val);
|
|
// 切换文件时重置滚动位置到顶部
|
|
nextTick(() => {
|
|
if (scrollRef.value) scrollRef.value.scrollTop = 0;
|
|
});
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
// ── 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 tryAutoPair(
|
|
ta: HTMLTextAreaElement,
|
|
key: string,
|
|
): boolean {
|
|
const pairs: Record<string, string> = {
|
|
"(": ")",
|
|
"[": "]",
|
|
"{": "}",
|
|
'"': '"',
|
|
"'": "'",
|
|
"`": "`",
|
|
};
|
|
const close = pairs[key];
|
|
if (!close) return false;
|
|
|
|
const start = ta.selectionStart;
|
|
const end = ta.selectionEnd;
|
|
const selected = props.modelValue.substring(start, end);
|
|
const newVal =
|
|
props.modelValue.substring(0, start) +
|
|
key +
|
|
selected +
|
|
close +
|
|
props.modelValue.substring(end);
|
|
|
|
emit("update:modelValue", newVal);
|
|
renderFullHtml(newVal);
|
|
|
|
void nextTick(() => {
|
|
ta.selectionStart = start + 1;
|
|
ta.selectionEnd = start + 1 + selected.length;
|
|
ta.focus();
|
|
});
|
|
return true;
|
|
}
|
|
|
|
function onEditorKeydown(e: KeyboardEvent) {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
|
e.preventDefault();
|
|
emit("save-shortcut");
|
|
return;
|
|
}
|
|
|
|
const ta = e.target as HTMLTextAreaElement;
|
|
|
|
if (e.key === "Tab") {
|
|
e.preventDefault();
|
|
const start = ta.selectionStart;
|
|
const end = ta.selectionEnd;
|
|
const newVal =
|
|
props.modelValue.substring(0, start) +
|
|
" " +
|
|
props.modelValue.substring(end);
|
|
emit("update:modelValue", newVal);
|
|
renderFullHtml(newVal);
|
|
void nextTick(() => {
|
|
ta.selectionStart = ta.selectionEnd = start + 2;
|
|
ta.focus();
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (tryAutoPair(ta, e.key)) {
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
ref="scrollRef"
|
|
class="flex-1 w-full overflow-auto"
|
|
@wheel.passive
|
|
>
|
|
<div
|
|
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))`,
|
|
}"
|
|
>
|
|
<!-- 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"
|
|
/>
|
|
|
|
<!-- 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>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style>
|
|
/* ── Markdown Preview Styles (warm palette) ─────────────────────── */
|
|
|
|
.markdown-preview {
|
|
font-family: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
|
|
line-height: 1.625;
|
|
color: #38342e;
|
|
word-break: break-word;
|
|
}
|
|
|
|
.markdown-preview > *:first-child { margin-top: 0; }
|
|
|
|
.markdown-preview h1,
|
|
.markdown-preview h2,
|
|
.markdown-preview h3,
|
|
.markdown-preview h4,
|
|
.markdown-preview h5,
|
|
.markdown-preview h6 {
|
|
font-weight: 700;
|
|
color: #2d2a26;
|
|
line-height: 1.3;
|
|
margin-top: 1.5em;
|
|
margin-bottom: 0.5em;
|
|
}
|
|
.markdown-preview h1 { font-size: 1.5em; }
|
|
.markdown-preview h2 { font-size: 1.25em; }
|
|
.markdown-preview h3 { font-size: 1.125em; }
|
|
.markdown-preview h4 { font-size: 1em; }
|
|
|
|
.markdown-preview p { margin: 0.5em 0; }
|
|
|
|
.markdown-preview strong { font-weight: 700; color: #2d2a26; }
|
|
.markdown-preview em { font-style: italic; }
|
|
.markdown-preview del { text-decoration: line-through; color: #8c877d; }
|
|
|
|
.markdown-preview code {
|
|
background-color: #f4f1ea;
|
|
color: #bf6a3b;
|
|
border-radius: 0.25rem;
|
|
padding: 0.125rem 0.375rem;
|
|
font-size: 0.875em;
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
word-break: break-all;
|
|
}
|
|
|
|
.markdown-preview pre {
|
|
background-color: #f4f1ea;
|
|
border-radius: 0.375rem;
|
|
padding: 1rem;
|
|
overflow-x: auto;
|
|
margin: 0.75em 0;
|
|
}
|
|
.markdown-preview pre code {
|
|
background: none;
|
|
padding: 0;
|
|
color: #38342e;
|
|
font-size: 0.875em;
|
|
border-radius: 0;
|
|
word-break: normal;
|
|
}
|
|
|
|
.markdown-preview a { color: #bf6a3b; text-decoration: underline; }
|
|
|
|
.markdown-preview blockquote {
|
|
border-left: 4px solid #bf6a3b;
|
|
padding-left: 1rem;
|
|
margin: 0.75em 0;
|
|
color: #8c877d;
|
|
font-style: italic;
|
|
}
|
|
.markdown-preview blockquote p { margin: 0.25em 0; }
|
|
|
|
.markdown-preview ul { list-style-type: disc; padding-left: 1.5em; margin: 0.5em 0; }
|
|
.markdown-preview ol { list-style-type: decimal; padding-left: 1.5em; margin: 0.5em 0; }
|
|
.markdown-preview li { margin: 0.25em 0; }
|
|
|
|
.markdown-preview li input[type="checkbox"] {
|
|
margin-right: 0.375rem;
|
|
accent-color: #bf6a3b;
|
|
}
|
|
|
|
.markdown-preview hr {
|
|
border: none;
|
|
border-top: 1px solid #e0dbcf;
|
|
margin: 1.5em 0;
|
|
}
|
|
|
|
.markdown-preview table {
|
|
border-collapse: collapse;
|
|
width: 100%;
|
|
margin: 0.75em 0;
|
|
}
|
|
.markdown-preview th,
|
|
.markdown-preview td {
|
|
border: 1px solid #e0dbcf;
|
|
padding: 0.5rem 0.75rem;
|
|
text-align: left;
|
|
}
|
|
.markdown-preview th {
|
|
background-color: #f4f1ea;
|
|
font-weight: 700;
|
|
color: #2d2a26;
|
|
}
|
|
.markdown-preview tr:nth-child(even) td { background-color: #faf9f6; }
|
|
|
|
.markdown-preview img {
|
|
max-width: 100%;
|
|
height: auto;
|
|
border-radius: 0.25rem;
|
|
margin: 0.5em 0;
|
|
}
|
|
</style>
|