切换markdown渲染为cm6

This commit is contained in:
2026-07-03 15:06:53 +08:00
parent a37bb0a894
commit 8f95ea3d39
8 changed files with 917 additions and 688 deletions

View File

@@ -1,18 +1,25 @@
<script setup lang="ts">
import { watch } from "vue";
import { useEditor, EditorContent } from "@tiptap/vue-3";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import { Markdown } from "@tiptap/markdown";
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
import {
EditorView,
placeholder,
keymap,
} from "@codemirror/view";
import { EditorState, Annotation, Transaction } from "@codemirror/state";
import { markdown } from "@codemirror/lang-markdown";
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
import { livePreview } from "../editor/livePreview";
const props = withDefaults(
defineProps<{
modelValue?: string;
placeholder?: string;
isFullWidth?: boolean;
}>(),
{
modelValue: "",
placeholder: "开始写作...",
isFullWidth: false,
},
);
@@ -21,42 +28,173 @@ const emit = defineEmits<{
"save-shortcut": [];
}>();
const editor = useEditor({
content: props.modelValue,
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
}),
Placeholder.configure({
placeholder: props.placeholder,
}),
Markdown,
],
editorProps: {
attributes: {
class:
"min-h-[320px] px-6 py-4 outline-none focus:outline-none",
const editorContainer = ref<HTMLDivElement>();
const editorView = ref<EditorView>();
// Annotation to mark sync transactions, so we don't re-emit to parent
const syncAnnotation = Annotation.define<boolean>();
// ── Editor theme (warm color palette, matching original design) ────
const editorTheme = EditorView.theme(
{
"&": {
maxHeight: "100%",
fontSize: "calc(1rem * var(--editor-zoom, 1))",
backgroundColor: "white",
},
handleKeyDown: (_view, event) => {
if ((event.ctrlKey || event.metaKey) && event.key === "s") {
event.preventDefault();
emit("save-shortcut");
return true;
".cm-scroller": {
overflow: "auto",
fontFamily:
"ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif",
lineHeight: "1.625",
},
".cm-content": {
padding: "2rem 2.5rem 10rem",
minHeight: "320px",
color: "#38342e",
caretColor: "#38342e",
},
".cm-cursor": {
borderLeftColor: "#38342e",
},
".cm-selectionBackground, .cm-content ::selection": {
backgroundColor: "#d4cfc4",
},
// ── Live Preview: styled content ──
".cm-lp-bold": {
fontWeight: "700",
color: "#2d2a26",
},
".cm-lp-italic": {
fontStyle: "italic",
},
".cm-lp-strikethrough": {
textDecoration: "line-through",
color: "#8c877d",
},
".cm-lp-code": {
backgroundColor: "#f4f1ea",
color: "#bf6a3b",
borderRadius: "0.25rem",
padding: "0.125rem 0.375rem",
fontSize: "0.875em",
fontFamily:
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
},
".cm-lp-link": {
color: "#bf6a3b",
textDecoration: "underline",
cursor: "pointer",
},
".cm-lp-heading": {
fontWeight: "700",
color: "#2d2a26",
},
".cm-lp-h1": {
fontSize: "1.5em",
},
".cm-lp-h2": {
fontSize: "1.25em",
},
".cm-lp-h3": {
fontSize: "1.125em",
},
".cm-lp-blockquote": {
borderLeft: "4px solid #bf6a3b",
paddingLeft: "1rem",
color: "#8c877d",
fontStyle: "italic",
},
// Placeholder
".cm-placeholder": {
color: "#b8b3a8",
},
// ── Remove focus outline ──
"&.cm-editor.cm-focused": {
outline: "none",
},
"&.cm-editor .cm-content": {
outline: "none",
},
},
{ dark: false },
);
// ── Extensions ─────────────────────────────────────────────────────
function createExtensions() {
return [
markdown(),
livePreview(),
EditorView.lineWrapping,
placeholder(props.placeholder),
keymap.of([
{
key: "Mod-s",
run: () => {
emit("save-shortcut");
return true;
},
preventDefault: true,
},
]),
history(),
keymap.of([...defaultKeymap, ...historyKeymap]),
EditorView.updateListener.of((update) => {
if (!update.docChanged) return;
const isSync = update.transactions.some((tr) =>
tr.annotation(syncAnnotation),
);
if (!isSync) {
emit("update:modelValue", update.state.doc.toString());
}
return false;
},
},
onUpdate: ({ editor }) => {
emit("update:modelValue", editor.getMarkdown());
},
}),
editorTheme,
];
}
// ── Lifecycle ──────────────────────────────────────────────────────
onMounted(() => {
if (!editorContainer.value) return;
const state = EditorState.create({
doc: props.modelValue,
extensions: createExtensions(),
});
editorView.value = new EditorView({
state,
parent: editorContainer.value,
});
});
onBeforeUnmount(() => {
editorView.value?.destroy();
editorView.value = undefined;
});
// Sync external modelValue changes back into the editor
watch(
() => props.modelValue,
(val) => {
if (editor.value && val !== editor.value.getMarkdown()) {
editor.value.commands.setContent(val, { contentType: "markdown", emitUpdate: false });
const view = editorView.value;
if (!view) return;
if (val !== view.state.doc.toString()) {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: val },
annotations: [
syncAnnotation.of(true),
Transaction.addToHistory.of(false),
],
// Try to preserve cursor; if the replacement makes it invalid,
// CM6 will clamp it to a valid position.
selection: view.state.selection,
});
}
},
);
@@ -64,137 +202,16 @@ watch(
<template>
<div
class="flex flex-col flex-1 w-full border-[#e0dbcf] bg-white shadow-2xl overflow-y-auto"
:class="[
'flex flex-col flex-1 w-full border border-[#e0dbcf] bg-white shadow-2xl overflow-hidden',
!props.isFullWidth && 'mx-auto',
]"
:style="
!props.isFullWidth
? { maxWidth: `calc(800px * var(--editor-zoom, 1))` }
: undefined
"
>
<!-- Editor content -->
<EditorContent :editor="editor" class="tiptap-editor flex-1 flex flex-col" />
<div ref="editorContainer" class="flex-1 min-h-0" />
</div>
</template>
<style>
/* TipTap / ProseMirror content styling */
.tiptap-editor .ProseMirror {
color: #38342e;
min-height: 320px;
flex: 1;
outline: none;
overflow-y: auto;
}
.tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
color: #b8b3a8;
float: left;
height: 0;
pointer-events: none;
content: attr(data-placeholder);
}
/* Headings */
.tiptap-editor .ProseMirror h1 {
font-size: 1.5rem;
font-weight: 700;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
color: #2d2a26;
}
.tiptap-editor .ProseMirror h2 {
font-size: 1.25rem;
font-weight: 700;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
color: #2d2a26;
}
.tiptap-editor .ProseMirror h3 {
font-size: 1.125rem;
font-weight: 600;
margin-top: 1rem;
margin-bottom: 0.5rem;
color: #2d2a26;
}
/* Blockquote */
.tiptap-editor .ProseMirror blockquote {
border-left: 4px solid #bf6a3b;
padding-left: 1rem;
margin-top: 0.75rem;
margin-bottom: 0.75rem;
color: #8c877d;
font-style: italic;
}
/* Inline code */
.tiptap-editor .ProseMirror code {
background-color: #f4f1ea;
color: #bf6a3b;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.875rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
/* Code block */
.tiptap-editor .ProseMirror pre {
background-color: #f4f1ea;
border-radius: 0.5rem;
padding: 1rem;
margin-top: 0.75rem;
margin-bottom: 0.75rem;
overflow-x: auto;
}
.tiptap-editor .ProseMirror pre code {
background-color: transparent;
color: #5c574e;
padding: 0;
}
/* Lists */
.tiptap-editor .ProseMirror ul {
list-style-type: disc;
padding-left: 1.5rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.tiptap-editor .ProseMirror ol {
list-style-type: decimal;
padding-left: 1.5rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.tiptap-editor .ProseMirror li {
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
/* Horizontal rule */
.tiptap-editor .ProseMirror hr {
border-color: #e8e4da;
margin-top: 1.5rem;
margin-bottom: 1.5rem;
}
/* Links */
.tiptap-editor .ProseMirror a {
color: #bf6a3b;
text-decoration: underline;
}
/* Bold / Italic / Strike */
.tiptap-editor .ProseMirror strong {
font-weight: 700;
color: #2d2a26;
}
.tiptap-editor .ProseMirror em {
font-style: italic;
}
.tiptap-editor .ProseMirror s {
text-decoration: line-through;
color: #8c877d;
}
/* Paragraph spacing */
.tiptap-editor .ProseMirror p {
margin-top: 0.375rem;
margin-bottom: 0.375rem;
line-height: 1.625;
}
</style>