first commit
This commit is contained in:
336
src/components/MarkdownEditor.vue
Normal file
336
src/components/MarkdownEditor.vue
Normal file
@@ -0,0 +1,336 @@
|
||||
<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";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: string;
|
||||
placeholder?: string;
|
||||
}>(),
|
||||
{
|
||||
modelValue: "",
|
||||
placeholder: "开始写作...",
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: string];
|
||||
}>();
|
||||
|
||||
const editor = useEditor({
|
||||
content: props.modelValue,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3] },
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: props.placeholder,
|
||||
}),
|
||||
],
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
"min-h-[320px] px-6 py-4 outline-none focus:outline-none",
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
emit("update:modelValue", editor.getHTML());
|
||||
},
|
||||
});
|
||||
|
||||
// Sync external modelValue changes back into the editor
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (editor.value && val !== editor.value.getHTML()) {
|
||||
editor.value.commands.setContent(val, { emitUpdate: false });
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col rounded-xl border border-gray-700 bg-gray-900 shadow-2xl overflow-hidden"
|
||||
>
|
||||
<!-- Toolbar -->
|
||||
<div
|
||||
v-if="editor"
|
||||
class="flex flex-wrap items-center gap-0.5 px-3 py-2 border-b border-gray-700 bg-gray-800/80 backdrop-blur"
|
||||
>
|
||||
<!-- Headings -->
|
||||
<button
|
||||
v-for="level in ([1, 2, 3] as const)"
|
||||
:key="'h' + level"
|
||||
:class="[
|
||||
'px-2.5 py-1.5 text-xs font-semibold rounded-md transition-colors',
|
||||
editor.isActive('heading', { level })
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
|
||||
]"
|
||||
@click="editor.chain().focus().toggleHeading({ level }).run()"
|
||||
>
|
||||
H{{ level }}
|
||||
</button>
|
||||
|
||||
<span class="w-px h-6 bg-gray-600 mx-1" />
|
||||
|
||||
<!-- Inline formatting -->
|
||||
<button
|
||||
:class="[
|
||||
'px-2.5 py-1.5 text-xs font-medium rounded-md transition-colors',
|
||||
editor.isActive('bold')
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
|
||||
]"
|
||||
@click="editor.chain().focus().toggleBold().run()"
|
||||
>
|
||||
<strong>B</strong>
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
'px-2.5 py-1.5 text-xs font-medium rounded-md transition-colors italic',
|
||||
editor.isActive('italic')
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
|
||||
]"
|
||||
@click="editor.chain().focus().toggleItalic().run()"
|
||||
>
|
||||
I
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
'px-2.5 py-1.5 text-xs font-medium rounded-md transition-colors line-through',
|
||||
editor.isActive('strike')
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
|
||||
]"
|
||||
@click="editor.chain().focus().toggleStrike().run()"
|
||||
>
|
||||
S
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
'px-2.5 py-1.5 text-xs font-mono font-medium rounded-md transition-colors',
|
||||
editor.isActive('code')
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
|
||||
]"
|
||||
@click="editor.chain().focus().toggleCode().run()"
|
||||
>
|
||||
</>
|
||||
</button>
|
||||
|
||||
<span class="w-px h-6 bg-gray-600 mx-1" />
|
||||
|
||||
<!-- Block elements -->
|
||||
<button
|
||||
:class="[
|
||||
'px-2.5 py-1.5 text-xs font-medium rounded-md transition-colors',
|
||||
editor.isActive('blockquote')
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
|
||||
]"
|
||||
@click="editor.chain().focus().toggleBlockquote().run()"
|
||||
>
|
||||
"Quote"
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
'px-2.5 py-1.5 text-xs font-mono font-medium rounded-md transition-colors',
|
||||
editor.isActive('codeBlock')
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
|
||||
]"
|
||||
@click="editor.chain().focus().toggleCodeBlock().run()"
|
||||
>
|
||||
Code
|
||||
</button>
|
||||
|
||||
<span class="w-px h-6 bg-gray-600 mx-1" />
|
||||
|
||||
<!-- Lists -->
|
||||
<button
|
||||
:class="[
|
||||
'px-2.5 py-1.5 text-xs font-medium rounded-md transition-colors',
|
||||
editor.isActive('bulletList')
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
|
||||
]"
|
||||
@click="editor.chain().focus().toggleBulletList().run()"
|
||||
>
|
||||
• List
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
'px-2.5 py-1.5 text-xs font-medium rounded-md transition-colors',
|
||||
editor.isActive('orderedList')
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
|
||||
]"
|
||||
@click="editor.chain().focus().toggleOrderedList().run()"
|
||||
>
|
||||
1. List
|
||||
</button>
|
||||
|
||||
<span class="w-px h-6 bg-gray-600 mx-1" />
|
||||
|
||||
<!-- Horizontal rule -->
|
||||
<button
|
||||
class="px-2.5 py-1.5 text-xs font-medium rounded-md text-gray-300 hover:bg-gray-700 hover:text-white transition-colors"
|
||||
@click="editor.chain().focus().setHorizontalRule().run()"
|
||||
>
|
||||
──
|
||||
</button>
|
||||
|
||||
<span class="flex-1" />
|
||||
|
||||
<!-- Undo / Redo -->
|
||||
<button
|
||||
class="px-2.5 py-1.5 text-xs font-medium rounded-md text-gray-300 hover:bg-gray-700 hover:text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
:disabled="!editor.can().chain().focus().undo().run()"
|
||||
@click="editor.chain().focus().undo().run()"
|
||||
>
|
||||
↶
|
||||
</button>
|
||||
<button
|
||||
class="px-2.5 py-1.5 text-xs font-medium rounded-md text-gray-300 hover:bg-gray-700 hover:text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
:disabled="!editor.can().chain().focus().redo().run()"
|
||||
@click="editor.chain().focus().redo().run()"
|
||||
>
|
||||
↷
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Editor content -->
|
||||
<EditorContent :editor="editor" class="tiptap-editor" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* TipTap / ProseMirror content styling */
|
||||
.tiptap-editor .ProseMirror {
|
||||
color: #f3f4f6;
|
||||
min-height: 320px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
|
||||
color: #6b7280;
|
||||
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: #fff;
|
||||
}
|
||||
.tiptap-editor .ProseMirror h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #fff;
|
||||
}
|
||||
.tiptap-editor .ProseMirror h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Blockquote */
|
||||
.tiptap-editor .ProseMirror blockquote {
|
||||
border-left: 4px solid #6366f1;
|
||||
padding-left: 1rem;
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
color: #9ca3af;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Inline code */
|
||||
.tiptap-editor .ProseMirror code {
|
||||
background-color: #1f2937;
|
||||
color: #f472b6;
|
||||
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: #1f2937;
|
||||
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: #e5e7eb;
|
||||
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: #374151;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
.tiptap-editor .ProseMirror a {
|
||||
color: #818cf8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Bold / Italic / Strike */
|
||||
.tiptap-editor .ProseMirror strong {
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
.tiptap-editor .ProseMirror em {
|
||||
font-style: italic;
|
||||
}
|
||||
.tiptap-editor .ProseMirror s {
|
||||
text-decoration: line-through;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* 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