切换markdown渲染为cm6
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user