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>
|