424 lines
12 KiB
Vue
424 lines
12 KiB
Vue
<template>
|
||
<div class="blog-editor overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||
<div class="flex flex-wrap items-center gap-1 border-b border-slate-200 bg-slate-50 px-2 py-2">
|
||
<div class="flex flex-wrap items-center gap-0.5">
|
||
<button
|
||
v-for="item in headingOptions"
|
||
:key="item.level"
|
||
type="button"
|
||
class="editor-btn"
|
||
:class="{ 'is-active': editor?.isActive('heading', { level: item.level }) }"
|
||
:title="item.label"
|
||
@click="setHeading(item.level)"
|
||
>
|
||
{{ item.short }}
|
||
</button>
|
||
</div>
|
||
|
||
<span class="editor-divider" />
|
||
|
||
<button
|
||
type="button"
|
||
class="editor-btn"
|
||
:class="{ 'is-active': editor?.isActive('bold') }"
|
||
title="درشت"
|
||
@click="editor?.chain().focus().toggleBold().run()"
|
||
>
|
||
<strong>B</strong>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="editor-btn"
|
||
:class="{ 'is-active': editor?.isActive('italic') }"
|
||
title="کج"
|
||
@click="editor?.chain().focus().toggleItalic().run()"
|
||
>
|
||
<em>I</em>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="editor-btn"
|
||
:class="{ 'is-active': editor?.isActive('underline') }"
|
||
title="زیرخط"
|
||
@click="editor?.chain().focus().toggleUnderline().run()"
|
||
>
|
||
<span class="underline">U</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="editor-btn"
|
||
:class="{ 'is-active': editor?.isActive('strike') }"
|
||
title="خطخورده"
|
||
@click="editor?.chain().focus().toggleStrike().run()"
|
||
>
|
||
<span class="line-through">S</span>
|
||
</button>
|
||
|
||
<span class="editor-divider" />
|
||
|
||
<button
|
||
type="button"
|
||
class="editor-btn"
|
||
:class="{ 'is-active': editor?.isActive({ textAlign: 'right' }) }"
|
||
title="راستچین"
|
||
@click="editor?.chain().focus().setTextAlign('right').run()"
|
||
>
|
||
≡
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="editor-btn"
|
||
:class="{ 'is-active': editor?.isActive({ textAlign: 'center' }) }"
|
||
title="وسطچین"
|
||
@click="editor?.chain().focus().setTextAlign('center').run()"
|
||
>
|
||
≡
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="editor-btn"
|
||
:class="{ 'is-active': editor?.isActive({ textAlign: 'left' }) }"
|
||
title="چپچین"
|
||
@click="editor?.chain().focus().setTextAlign('left').run()"
|
||
>
|
||
≡
|
||
</button>
|
||
|
||
<span class="editor-divider" />
|
||
|
||
<button
|
||
type="button"
|
||
class="editor-btn"
|
||
:class="{ 'is-active': editor?.isActive('bulletList') }"
|
||
title="لیست نقطهای"
|
||
@click="editor?.chain().focus().toggleBulletList().run()"
|
||
>
|
||
•≡
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="editor-btn"
|
||
:class="{ 'is-active': editor?.isActive('orderedList') }"
|
||
title="لیست شمارهدار"
|
||
@click="editor?.chain().focus().toggleOrderedList().run()"
|
||
>
|
||
1.
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="editor-btn"
|
||
:class="{ 'is-active': editor?.isActive('blockquote') }"
|
||
title="نقلقول"
|
||
@click="editor?.chain().focus().toggleBlockquote().run()"
|
||
>
|
||
"
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="editor-btn"
|
||
title="خط جداکننده"
|
||
@click="editor?.chain().focus().setHorizontalRule().run()"
|
||
>
|
||
―
|
||
</button>
|
||
|
||
<span class="editor-divider" />
|
||
|
||
<button type="button" class="editor-btn" title="لینک" @click="setLink">
|
||
🔗
|
||
</button>
|
||
<button
|
||
v-if="editor?.isActive('link')"
|
||
type="button"
|
||
class="editor-btn"
|
||
title="حذف لینک"
|
||
@click="editor?.chain().focus().unsetLink().run()"
|
||
>
|
||
✕
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="editor-btn"
|
||
:disabled="uploadingImage"
|
||
title="درج تصویر در محل مکاننما"
|
||
@click="triggerImageUpload('insert')"
|
||
>
|
||
{{ uploadingImage ? '...' : '🖼' }}
|
||
</button>
|
||
<template v-if="editor?.isActive('image')">
|
||
<button type="button" class="editor-btn" title="ویرایش تصویر" @click="editSelectedImage">
|
||
✎
|
||
</button>
|
||
<button type="button" class="editor-btn" title="جایگزینی تصویر" @click="triggerImageUpload('replace')">
|
||
↻
|
||
</button>
|
||
<button type="button" class="editor-btn" title="حذف تصویر" @click="deleteSelectedImage">
|
||
🗑
|
||
</button>
|
||
</template>
|
||
<input
|
||
ref="imageInput"
|
||
type="file"
|
||
accept="image/*"
|
||
class="hidden"
|
||
@change="onImageSelected"
|
||
/>
|
||
|
||
<span class="editor-divider" />
|
||
|
||
<button
|
||
type="button"
|
||
class="editor-btn"
|
||
:disabled="!editor?.can().chain().focus().undo().run()"
|
||
title="بازگشت"
|
||
@click="editor?.chain().focus().undo().run()"
|
||
>
|
||
↶
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="editor-btn"
|
||
:disabled="!editor?.can().chain().focus().redo().run()"
|
||
title="ازنو"
|
||
@click="editor?.chain().focus().redo().run()"
|
||
>
|
||
↷
|
||
</button>
|
||
</div>
|
||
|
||
<div class="grid lg:grid-cols-[220px_1fr]">
|
||
<aside class="border-b border-slate-200 bg-slate-50/80 p-3 lg:border-b-0 lg:border-l">
|
||
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||
افزودن بلوک
|
||
</p>
|
||
<div class="grid gap-2 sm:grid-cols-2 lg:grid-cols-1">
|
||
<button
|
||
v-for="block in blockWidgets"
|
||
:key="block.id"
|
||
type="button"
|
||
class="flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2 text-right text-sm text-slate-700 transition hover:border-brand/40 hover:bg-brand/5"
|
||
@click="block.action()"
|
||
>
|
||
<span class="text-base" aria-hidden="true">{{ block.icon }}</span>
|
||
<span>{{ block.label }}</span>
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
|
||
<div class="relative">
|
||
<EditorContent :editor="editor" class="blog-editor-content" />
|
||
<p v-if="editor" class="border-t border-slate-100 px-4 py-2 text-xs text-slate-400">
|
||
{{ wordCount }} کلمه · {{ characterCount }} کاراکتر
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||
import { EditorContent, useEditor } from '@tiptap/vue-3'
|
||
import StarterKit from '@tiptap/starter-kit'
|
||
import Link from '@tiptap/extension-link'
|
||
import TextAlign from '@tiptap/extension-text-align'
|
||
import Underline from '@tiptap/extension-underline'
|
||
import Placeholder from '@tiptap/extension-placeholder'
|
||
import CharacterCount from '@tiptap/extension-character-count'
|
||
import { BlogImage } from '@/components/blogImageExtension'
|
||
import { api } from '@/api/client'
|
||
import { normalizeEditorContent } from '@/utils/blogContent'
|
||
|
||
const props = defineProps<{
|
||
modelValue: string
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
'update:modelValue': [value: string]
|
||
}>()
|
||
|
||
const imageInput = ref<HTMLInputElement | null>(null)
|
||
const uploadingImage = ref(false)
|
||
const imageUploadMode = ref<'insert' | 'replace'>('insert')
|
||
|
||
const headingOptions = [
|
||
{ level: 2 as const, label: 'عنوان بخش', short: 'H2' },
|
||
{ level: 3 as const, label: 'زیرعنوان', short: 'H3' },
|
||
{ level: 4 as const, label: 'عنوان کوچک', short: 'H4' },
|
||
]
|
||
|
||
const editor = useEditor({
|
||
content: normalizeEditorContent(props.modelValue),
|
||
extensions: [
|
||
StarterKit.configure({
|
||
heading: { levels: [2, 3, 4] },
|
||
}),
|
||
Underline,
|
||
Link.configure({
|
||
openOnClick: false,
|
||
HTMLAttributes: { rel: 'noopener noreferrer' },
|
||
}),
|
||
BlogImage.configure({
|
||
HTMLAttributes: { class: 'blog-editor-image' },
|
||
inline: false,
|
||
}),
|
||
TextAlign.configure({
|
||
types: ['heading', 'paragraph'],
|
||
}),
|
||
Placeholder.configure({
|
||
placeholder: 'محتوای مقاله را اینجا بنویسید یا از پنل کناری بلوک اضافه کنید...',
|
||
}),
|
||
CharacterCount,
|
||
],
|
||
editorProps: {
|
||
attributes: {
|
||
class: 'blog-editor-prose',
|
||
dir: 'rtl',
|
||
},
|
||
},
|
||
onUpdate: ({ editor: current }) => {
|
||
emit('update:modelValue', current.getHTML())
|
||
},
|
||
})
|
||
|
||
const characterCount = computed(() => editor.value?.storage.characterCount.characters() ?? 0)
|
||
const wordCount = computed(() => editor.value?.storage.characterCount.words() ?? 0)
|
||
|
||
const blockWidgets = computed(() => [
|
||
{
|
||
id: 'paragraph',
|
||
icon: '¶',
|
||
label: 'پاراگراف',
|
||
action: () => editor.value?.chain().focus().setParagraph().run(),
|
||
},
|
||
{
|
||
id: 'h2',
|
||
icon: 'H',
|
||
label: 'عنوان اصلی',
|
||
action: () => editor.value?.chain().focus().toggleHeading({ level: 2 }).run(),
|
||
},
|
||
{
|
||
id: 'h3',
|
||
icon: 'h',
|
||
label: 'زیرعنوان',
|
||
action: () => editor.value?.chain().focus().toggleHeading({ level: 3 }).run(),
|
||
},
|
||
{
|
||
id: 'list',
|
||
icon: '•',
|
||
label: 'لیست',
|
||
action: () => editor.value?.chain().focus().toggleBulletList().run(),
|
||
},
|
||
{
|
||
id: 'quote',
|
||
icon: '❝',
|
||
label: 'نقلقول',
|
||
action: () => editor.value?.chain().focus().toggleBlockquote().run(),
|
||
},
|
||
{
|
||
id: 'image',
|
||
icon: '🖼',
|
||
label: 'تصویر',
|
||
action: () => insertImageAtCursor(),
|
||
},
|
||
{
|
||
id: 'divider',
|
||
icon: '―',
|
||
label: 'جداکننده',
|
||
action: () => editor.value?.chain().focus().setHorizontalRule().run(),
|
||
},
|
||
])
|
||
|
||
watch(
|
||
() => props.modelValue,
|
||
(value) => {
|
||
if (!editor.value) return
|
||
const current = editor.value.getHTML()
|
||
const normalized = normalizeEditorContent(value)
|
||
if (normalized !== current) {
|
||
editor.value.commands.setContent(normalized, { emitUpdate: false })
|
||
}
|
||
}
|
||
)
|
||
|
||
function setHeading(level: 2 | 3 | 4) {
|
||
editor.value?.chain().focus().toggleHeading({ level }).run()
|
||
}
|
||
|
||
function setLink() {
|
||
const previous = editor.value?.getAttributes('link').href as string | undefined
|
||
const url = window.prompt('آدرس لینک را وارد کنید:', previous || 'https://')
|
||
if (url === null) return
|
||
if (!url.trim()) {
|
||
editor.value?.chain().focus().extendMarkRange('link').unsetLink().run()
|
||
return
|
||
}
|
||
editor.value?.chain().focus().extendMarkRange('link').setLink({ href: url.trim() }).run()
|
||
}
|
||
|
||
function triggerImageUpload(mode: 'insert' | 'replace' = 'insert') {
|
||
imageUploadMode.value = mode
|
||
imageInput.value?.click()
|
||
}
|
||
|
||
function insertImageAtCursor() {
|
||
if (!editor.value) return
|
||
editor.value.chain().focus().run()
|
||
triggerImageUpload('insert')
|
||
}
|
||
|
||
function deleteSelectedImage() {
|
||
if (!editor.value?.isActive('image')) return
|
||
if (!confirm('این تصویر از متن حذف شود؟')) return
|
||
editor.value.chain().focus().deleteSelection().run()
|
||
}
|
||
|
||
function editSelectedImage() {
|
||
if (!editor.value?.isActive('image')) return
|
||
const currentAlt = (editor.value.getAttributes('image').alt as string) || ''
|
||
const alt = window.prompt('توضیح تصویر (زیرنویس):', currentAlt)
|
||
if (alt === null) return
|
||
editor.value.chain().focus().updateAttributes('image', { alt: alt.trim() }).run()
|
||
}
|
||
|
||
async function onImageSelected(event: Event) {
|
||
const input = event.target as HTMLInputElement
|
||
const file = input.files?.[0]
|
||
if (!file || !editor.value) return
|
||
|
||
uploadingImage.value = true
|
||
try {
|
||
const uploaded = await api.uploadMedia(file)
|
||
const currentAlt =
|
||
imageUploadMode.value === 'replace'
|
||
? ((editor.value.getAttributes('image').alt as string) || '')
|
||
: ''
|
||
const alt =
|
||
window.prompt('توضیح تصویر (زیرنویس — اختیاری):', currentAlt || file.name) ?? currentAlt
|
||
|
||
if (imageUploadMode.value === 'replace' && editor.value.isActive('image')) {
|
||
editor.value
|
||
.chain()
|
||
.focus()
|
||
.updateAttributes('image', { src: uploaded.url, alt: alt.trim() })
|
||
.run()
|
||
} else {
|
||
editor.value
|
||
.chain()
|
||
.focus()
|
||
.setImage({ src: uploaded.url, alt: alt.trim() })
|
||
.run()
|
||
}
|
||
} catch (err) {
|
||
window.alert(err instanceof Error ? err.message : 'خطا در آپلود تصویر')
|
||
} finally {
|
||
uploadingImage.value = false
|
||
input.value = ''
|
||
imageUploadMode.value = 'insert'
|
||
}
|
||
}
|
||
|
||
onBeforeUnmount(() => {
|
||
editor.value?.destroy()
|
||
})
|
||
</script>
|