205 lines
6.7 KiB
Vue
205 lines
6.7 KiB
Vue
<script setup>
|
|
import Link from '@tiptap/extension-link';
|
|
import TextAlign from '@tiptap/extension-text-align';
|
|
import Underline from '@tiptap/extension-underline';
|
|
import StarterKit from '@tiptap/starter-kit';
|
|
import { EditorContent, useEditor } from '@tiptap/vue-3';
|
|
import { watch } from 'vue';
|
|
|
|
const props = defineProps({
|
|
modelValue: {
|
|
type: String,
|
|
default: '',
|
|
},
|
|
});
|
|
|
|
const emit = defineEmits(['update:modelValue']);
|
|
|
|
const editor = useEditor({
|
|
content: props.modelValue,
|
|
extensions: [
|
|
StarterKit,
|
|
Underline,
|
|
Link.configure({ openOnClick: false }),
|
|
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
|
],
|
|
editorProps: {
|
|
attributes: { class: 'rte__content' },
|
|
},
|
|
onUpdate({ editor: e }) {
|
|
emit('update:modelValue', e.getHTML());
|
|
},
|
|
});
|
|
|
|
// Sync external value changes (e.g. block switched)
|
|
watch(
|
|
() => props.modelValue,
|
|
(val) => {
|
|
if (editor.value && editor.value.getHTML() !== val) {
|
|
editor.value.commands.setContent(val, false);
|
|
}
|
|
},
|
|
);
|
|
|
|
const setLink = () => {
|
|
const url = window.prompt('URL', editor.value?.getAttributes('link').href ?? '');
|
|
|
|
if (url === null) return;
|
|
|
|
if (url === '') {
|
|
editor.value?.chain().focus().extendMarkRange('link').unsetLink().run();
|
|
} else {
|
|
editor.value?.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<div class="rte" v-if="editor">
|
|
<div class="rte__toolbar">
|
|
<!-- Text style -->
|
|
<div class="rte__group">
|
|
<button type="button" :class="{ 'rte__btn--active': editor.isActive('bold') }" @click="editor.chain().focus().toggleBold().run()" title="Bold"><strong>B</strong></button>
|
|
<button type="button" :class="{ 'rte__btn--active': editor.isActive('italic') }" @click="editor.chain().focus().toggleItalic().run()" title="Italic"><em>I</em></button>
|
|
<button type="button" :class="{ 'rte__btn--active': editor.isActive('underline') }" @click="editor.chain().focus().toggleUnderline().run()" title="Underline"><u>U</u></button>
|
|
</div>
|
|
|
|
<!-- Headings -->
|
|
<div class="rte__group">
|
|
<button type="button" :class="{ 'rte__btn--active': editor.isActive('heading', { level: 2 }) }" @click="editor.chain().focus().toggleHeading({ level: 2 }).run()" title="Heading 2">H2</button>
|
|
<button type="button" :class="{ 'rte__btn--active': editor.isActive('heading', { level: 3 }) }" @click="editor.chain().focus().toggleHeading({ level: 3 }).run()" title="Heading 3">H3</button>
|
|
<button type="button" :class="{ 'rte__btn--active': editor.isActive('paragraph') }" @click="editor.chain().focus().setParagraph().run()" title="Paragraph">P</button>
|
|
</div>
|
|
|
|
<!-- Lists -->
|
|
<div class="rte__group">
|
|
<button type="button" :class="{ 'rte__btn--active': editor.isActive('bulletList') }" @click="editor.chain().focus().toggleBulletList().run()" title="Bullet list">• List</button>
|
|
<button type="button" :class="{ 'rte__btn--active': editor.isActive('orderedList') }" @click="editor.chain().focus().toggleOrderedList().run()" title="Numbered list">1. List</button>
|
|
</div>
|
|
|
|
<!-- Alignment -->
|
|
<div class="rte__group">
|
|
<button type="button" :class="{ 'rte__btn--active': editor.isActive({ textAlign: 'left' }) }" @click="editor.chain().focus().setTextAlign('left').run()" title="Align left">⬅</button>
|
|
<button type="button" :class="{ 'rte__btn--active': editor.isActive({ textAlign: 'center' }) }" @click="editor.chain().focus().setTextAlign('center').run()" title="Align center">↔</button>
|
|
<button type="button" :class="{ 'rte__btn--active': editor.isActive({ textAlign: 'right' }) }" @click="editor.chain().focus().setTextAlign('right').run()" title="Align right">➡</button>
|
|
</div>
|
|
|
|
<!-- Link -->
|
|
<div class="rte__group">
|
|
<button type="button" :class="{ 'rte__btn--active': editor.isActive('link') }" @click="setLink" title="Set link">🔗</button>
|
|
<button v-if="editor.isActive('link')" type="button" @click="editor.chain().focus().unsetLink().run()" title="Remove link">✕ Link</button>
|
|
</div>
|
|
|
|
<!-- History -->
|
|
<div class="rte__group">
|
|
<button type="button" :disabled="!editor.can().undo()" @click="editor.chain().focus().undo().run()" title="Undo">↩</button>
|
|
<button type="button" :disabled="!editor.can().redo()" @click="editor.chain().focus().redo().run()" title="Redo">↪</button>
|
|
</div>
|
|
</div>
|
|
|
|
<EditorContent :editor="editor" />
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.rte {
|
|
border: 1px solid rgba(14, 116, 144, 0.35);
|
|
border-radius: 0.75rem;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.rte__toolbar {
|
|
align-items: center;
|
|
background: rgba(248, 250, 252, 0.95);
|
|
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.25rem;
|
|
padding: 0.4rem 0.5rem;
|
|
}
|
|
|
|
.rte__group {
|
|
align-items: center;
|
|
border-right: 1px solid rgba(15, 23, 42, 0.1);
|
|
display: flex;
|
|
gap: 0.1rem;
|
|
padding-right: 0.35rem;
|
|
}
|
|
|
|
.rte__group:last-child {
|
|
border-right: 0;
|
|
padding-right: 0;
|
|
}
|
|
|
|
.rte__toolbar button {
|
|
background: transparent;
|
|
border: 0;
|
|
border-radius: 0.4rem;
|
|
color: #374151;
|
|
cursor: pointer;
|
|
font-size: 0.78rem;
|
|
font-weight: 600;
|
|
min-width: 1.8rem;
|
|
padding: 0.25rem 0.4rem;
|
|
transition: background 0.1s, color 0.1s;
|
|
}
|
|
|
|
.rte__toolbar button:hover:not(:disabled) {
|
|
background: rgba(14, 116, 144, 0.1);
|
|
color: #0f766e;
|
|
}
|
|
|
|
.rte__toolbar button:disabled {
|
|
color: #cbd5e1;
|
|
cursor: default;
|
|
}
|
|
|
|
.rte__btn--active {
|
|
background: rgba(14, 116, 144, 0.15) !important;
|
|
color: #0f766e !important;
|
|
}
|
|
|
|
/* Editor area — :deep() needed to reach ProseMirror's non-scoped DOM */
|
|
:deep(.rte__content) {
|
|
min-height: 8rem;
|
|
outline: none;
|
|
padding: 0.75rem 1rem;
|
|
}
|
|
|
|
:deep(.rte__content p),
|
|
:deep(.rte__content h2),
|
|
:deep(.rte__content h3),
|
|
:deep(.rte__content ul),
|
|
:deep(.rte__content ol) {
|
|
margin: 0 0 0.6em;
|
|
}
|
|
|
|
:deep(.rte__content p:last-child),
|
|
:deep(.rte__content h2:last-child),
|
|
:deep(.rte__content h3:last-child),
|
|
:deep(.rte__content ul:last-child),
|
|
:deep(.rte__content ol:last-child) {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
:deep(.rte__content h2) {
|
|
font-size: 1.2rem;
|
|
font-weight: 700;
|
|
}
|
|
|
|
:deep(.rte__content h3) {
|
|
font-size: 1rem;
|
|
font-weight: 700;
|
|
}
|
|
|
|
:deep(.rte__content ul),
|
|
:deep(.rte__content ol) {
|
|
padding-left: 1.4rem;
|
|
}
|
|
|
|
:deep(.rte__content a) {
|
|
color: #0f766e;
|
|
text-decoration: underline;
|
|
}
|
|
</style>
|