Files
admin/src/components/BlogEditor.vue
T

424 lines
12 KiB
Vue
Raw Normal View History

2026-07-14 11:19:41 +03:30
<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>