100 lines
4.7 KiB
Vue
100 lines
4.7 KiB
Vue
<template>
|
|
<div class="rich-editor rounded-lg border border-(--border-light) dark:border-(--border-dark) overflow-hidden focus-within:ring-1 focus-within:ring-sky-500 focus-within:border-sky-500 transition-colors">
|
|
<!-- Toolbar -->
|
|
<div class="flex items-center gap-0.5 px-2 py-1.5 border-b border-(--border-light) dark:border-(--border-dark) bg-gray-50 dark:bg-neutral-800 flex-wrap">
|
|
<button type="button" @click="editor?.chain().focus().toggleBold().run()"
|
|
:class="{ 'bg-sky-100 dark:bg-sky-900 text-sky-600 dark:text-sky-300': editor?.isActive('bold') }"
|
|
class="toolbar-btn font-bold">B</button>
|
|
<button type="button" @click="editor?.chain().focus().toggleItalic().run()"
|
|
:class="{ 'bg-sky-100 dark:bg-sky-900 text-sky-600 dark:text-sky-300': editor?.isActive('italic') }"
|
|
class="toolbar-btn italic">I</button>
|
|
<button type="button" @click="editor?.chain().focus().toggleStrike().run()"
|
|
:class="{ 'bg-sky-100 dark:bg-sky-900 text-sky-600 dark:text-sky-300': editor?.isActive('strike') }"
|
|
class="toolbar-btn line-through">S</button>
|
|
<div class="w-px h-4 bg-gray-300 dark:bg-neutral-600 mx-1" />
|
|
<button type="button" @click="editor?.chain().focus().toggleHeading({ level: 2 }).run()"
|
|
:class="{ 'bg-sky-100 dark:bg-sky-900 text-sky-600 dark:text-sky-300': editor?.isActive('heading', { level: 2 }) }"
|
|
class="toolbar-btn text-xs font-semibold">H2</button>
|
|
<button type="button" @click="editor?.chain().focus().toggleHeading({ level: 3 }).run()"
|
|
:class="{ 'bg-sky-100 dark:bg-sky-900 text-sky-600 dark:text-sky-300': editor?.isActive('heading', { level: 3 }) }"
|
|
class="toolbar-btn text-xs font-semibold">H3</button>
|
|
<div class="w-px h-4 bg-gray-300 dark:bg-neutral-600 mx-1" />
|
|
<button type="button" @click="editor?.chain().focus().toggleBulletList().run()"
|
|
:class="{ 'bg-sky-100 dark:bg-sky-900 text-sky-600 dark:text-sky-300': editor?.isActive('bulletList') }"
|
|
class="toolbar-btn">
|
|
<UIcon name="i-lucide-list" class="text-sm" />
|
|
</button>
|
|
<button type="button" @click="editor?.chain().focus().toggleOrderedList().run()"
|
|
:class="{ 'bg-sky-100 dark:bg-sky-900 text-sky-600 dark:text-sky-300': editor?.isActive('orderedList') }"
|
|
class="toolbar-btn">
|
|
<UIcon name="i-lucide-list-ordered" class="text-sm" />
|
|
</button>
|
|
<div class="w-px h-4 bg-gray-300 dark:bg-neutral-600 mx-1" />
|
|
<button type="button" @click="editor?.chain().focus().undo().run()" class="toolbar-btn">
|
|
<UIcon name="i-lucide-undo-2" class="text-sm" />
|
|
</button>
|
|
<button type="button" @click="editor?.chain().focus().redo().run()" class="toolbar-btn">
|
|
<UIcon name="i-lucide-redo-2" class="text-sm" />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Editor area -->
|
|
<EditorContent :editor="editor"
|
|
class="min-h-32 px-3 py-2.5 text-sm text-black dark:text-white bg-white dark:bg-neutral-900 prose prose-sm dark:prose-invert max-w-none focus:outline-none" />
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { watch, onBeforeUnmount } from 'vue'
|
|
import { useEditor, EditorContent } from '@tiptap/vue-3'
|
|
import StarterKit from '@tiptap/starter-kit'
|
|
import Placeholder from '@tiptap/extension-placeholder'
|
|
|
|
const props = defineProps<{
|
|
modelValue: string
|
|
placeholder?: string
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
'update:modelValue': [value: string]
|
|
}>()
|
|
|
|
const editor = useEditor({
|
|
content: props.modelValue,
|
|
extensions: [
|
|
StarterKit,
|
|
Placeholder.configure({ placeholder: props.placeholder ?? '' }),
|
|
],
|
|
editorProps: {
|
|
attributes: { class: 'focus:outline-none' },
|
|
},
|
|
onUpdate({ editor }) {
|
|
emit('update:modelValue', editor.getHTML())
|
|
},
|
|
})
|
|
|
|
// Sync external changes (e.g. store reset)
|
|
watch(() => props.modelValue, (val) => {
|
|
if (editor.value && editor.value.getHTML() !== val) {
|
|
editor.value.commands.setContent(val, false)
|
|
}
|
|
})
|
|
|
|
onBeforeUnmount(() => editor.value?.destroy())
|
|
</script>
|
|
|
|
<style>
|
|
/* .toolbar-btn {
|
|
@apply flex items-center justify-center w-7 h-7 rounded text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-neutral-700 transition-colors text-sm;
|
|
} */
|
|
|
|
/* Tiptap placeholder */
|
|
.tiptap p.is-editor-empty:first-child::before {
|
|
content: attr(data-placeholder);
|
|
float: left;
|
|
/* color: theme('colors.gray.400'); */
|
|
pointer-events: none;
|
|
height: 0;
|
|
}
|
|
</style>
|