Files
admin/src/components/BlogEditor.vue
T
2026-07-14 11:19:41 +03:30

424 lines
12 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>