add contact request
This commit is contained in:
@@ -0,0 +1,423 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user